{ ============================================================ UIrrigation_Impl.inc - 스마트팜 HMI 관수 제어(순차 스케쥴) ============================================================ 이 파일은 UMain.pas의 implementation 부에 include 됩니다. ($I UIrrigation_Impl.inc) } // ─── 헬퍼 함수 ──────────────────────────────────────────────────────────────── function EvalIrriConditions(const AConds: array of TAutoCondItem; AMode: TAutoCondMode; AEmptyResult: Boolean): Boolean; var i: Integer; begin if Length(AConds) = 0 then Exit(AEmptyResult); if AMode = acmOR then begin for i := 0 to High(AConds) do if frmMain.EvalOneCondition(AConds[i]) then Exit(True); Result := False; // 하나도 만족 안함 end else begin for i := 0 to High(AConds) do if not frmMain.EvalOneCondition(AConds[i]) then Exit(False); Result := True; // 모두 만족 end; end; function EvalIrriConditionsTotalFlow(const AConds: array of TAutoCondItem; AMode: TAutoCondMode; AEmptyResult: Boolean): Boolean; var i: Integer; begin if Length(AConds) = 0 then Exit(AEmptyResult); if AMode = acmOR then begin for i := 0 to High(AConds) do if frmMain.EvalOneConditionTotalFlow(AConds[i]) then Exit(True); Result := False; // 하나도 만족 안함 end else begin for i := 0 to High(AConds) do if not frmMain.EvalOneConditionTotalFlow(AConds[i]) then Exit(False); Result := True; // 모두 만족 end; end; function GetNodeIndex(const NodeID: string): Integer; var i: Integer; begin Result := -1; for i := 0 to High(frmMain.FNodeConfigs) do if frmMain.FNodeConfigs[i].ID = NodeID then Exit(frmMain.FNodeConfigs[i].Index); end; // ─── 유량계(1회당 유량) 초기화 함수 ──────────────────────────────────────────────────────────────── procedure TfrmMain.EvalIrriFlowInit(const AConds: array of TAutoCondItem); var i,n: Integer; strN:string; begin if Length(AConds) = 0 then Exit; for i := 0 to High(AConds) do begin if AConds[i].NodeID.StartsWith('DI_') then begin n := GetNodeIndex(AConds[i].NodeID); if n >= 0 then begin strN := frmMain.FNodeConfigs[n].CustomName + frmMain.FNodeConfigs[n].TitleKO + frmMain.FNodeConfigs[n].TitleEN; if (strN.Contains('유량')) or (strN.Contains('Flow')) then frmMain.FSensorValues[n] := 0; end; end; end; end; // ─── 유량계(구역 최대 유량) 초기화 함수 ──────────────────────────────────────────────────────────────── procedure TfrmMain.EvalIrriFlowInitTotalFlow(const AConds: array of TAutoCondItem); var i,n: Integer; strN:string; begin if Length(AConds) = 0 then Exit; for i := 0 to High(AConds) do begin if AConds[i].NodeID.StartsWith('DI_') then begin n := GetNodeIndex(AConds[i].NodeID); if n >= 0 then begin strN := frmMain.FNodeConfigs[n].CustomName + frmMain.FNodeConfigs[n].TitleKO + frmMain.FNodeConfigs[n].TitleEN; if (strN.Contains('유량')) or (strN.Contains('Flow')) then frmMain.FSensorValuesTotalFlow[n] := 0; end; end; end; end; // ─── 스케쥴 로드 / 세이브 ──────────────────────────────────────────────────────── procedure InitActionBaseValues(var AAct: TIrriAction); procedure ProcessArray(var arr: TArray); var i, n: Integer; strN: string; begin for i := 0 to High(arr) do begin arr[i].BaseValue := 0.0; if arr[i].NodeID.StartsWith('DI_') then begin n := GetNodeIndex(arr[i].NodeID); if n >= 0 then begin strN := frmMain.FNodeConfigs[n].CustomName + frmMain.FNodeConfigs[n].TitleKO + frmMain.FNodeConfigs[n].TitleEN; if (strN.Contains('유량')) or (strN.Contains('Flow')) then begin frmMain.FSensorValues[n] := 0; end; end; end; end; end; begin ProcessArray(AAct.StartConds); ProcessArray(AAct.StopConds); ProcessArray(AAct.EndConds); end; procedure ParseCondArray(JArr: TJSONArray; var AConds: TArray); var k: Integer; JC: TJSONObject; OpInt,FOpInt: Integer; begin if not Assigned(JArr) then Exit; SetLength(AConds, JArr.Count); for k := 0 to JArr.Count - 1 do begin JC := JArr.Items[k] as TJSONObject; JC.TryGetValue('NodeID', AConds[k].NodeID); if JC.TryGetValue('Op', OpInt) then AConds[k].Op := TAutoCondOp(OpInt); JC.TryGetValue('Value', AConds[k].Value); if JC.TryGetValue('FOp', FOpInt) then AConds[k].FOp := TAutoCondFOp(FOpInt); JC.TryGetValue('PerPulse', AConds[k].PerPulse); JC.TryGetValue('FUse', AConds[k].FUse); { // 강제 보정 로직 추가: 과거 잘못 저장된 Op 값 교정 if (AConds[k].NodeID.StartsWith('DI_')) or (AConds[k].NodeID.StartsWith('DO_')) then begin if Ord(AConds[k].Op) < Ord(acoON) then AConds[k].Op := acoON; end else begin if Ord(AConds[k].Op) >= Ord(acoON) then AConds[k].Op := acoGT; end; } end; end; function CondArrayToJSON(const AConds: TArray): TJSONArray; var k: Integer; JC: TJSONObject; begin Result := TJSONArray.Create; for k := 0 to High(AConds) do begin JC := TJSONObject.Create; JC.AddPair('NodeID', AConds[k].NodeID); JC.AddPair('Op', TJSONNumber.Create(Integer(AConds[k].Op))); JC.AddPair('Value', TJSONNumber.Create(AConds[k].Value)); JC.AddPair('FOp', TJSONNumber.Create(Integer(AConds[k].FOp))); JC.AddPair('FUse', TJSONNumber.Create(Boolean(AConds[k].FUse))); JC.AddPair('PerPulse',TJSONNumber.Create(AConds[k].PerPulse)); Result.AddElement(JC); end; end; procedure TfrmMain.LoadIrriSchedules; var JSONPath, Content: string; JArr, JActArr, JCondArr: TJSONArray; JSched, JAct: TJSONObject; i, j: Integer; StartDateStr, EndDateStr: string; begin SetLength(FIrriSchedules, 0); JSONPath := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'SmartFarmHMI_Irrigation.json'); if not FileExists(JSONPath) then Exit; Content := System.IOUtils.TFile.ReadAllText(JSONPath, TEncoding.UTF8); JArr := TJSONObject.ParseJSONValue(Content) as TJSONArray; if not Assigned(JArr) then Exit; try SetLength(FIrriSchedules, JArr.Count); for i := 0 to JArr.Count - 1 do begin JSched := JArr.Items[i] as TJSONObject; FIrriSchedules[i].Enabled := False; FIrriSchedules[i].IsRunning := False; FIrriSchedules[i].WaitingManual := False; FIrriSchedules[i].RepeatDays := 1; FIrriSchedules[i].StartDate := Date; FIrriSchedules[i].EndDate := Date + 365; JSched.TryGetValue('Enabled', FIrriSchedules[i].Enabled); JSched.TryGetValue ('Name', FIrriSchedules[i].SchedName); JSched.TryGetValue('IsAutoStart', FIrriSchedules[i].IsAutoStart); JSched.TryGetValue ('StartHH', FIrriSchedules[i].StartHH); JSched.TryGetValue ('StartMM', FIrriSchedules[i].StartMM); JSched.TryGetValue('RepeatDays', FIrriSchedules[i].RepeatDays); JSched.TryGetValue('LimitOnesTimeMin', FIrriSchedules[i].LimitOnesTimeMin); if JSched.TryGetValue('StartDate', StartDateStr) then FIrriSchedules[i].StartDate := StrToDateDef(StartDateStr, Date); if JSched.TryGetValue('EndDate', EndDateStr) then FIrriSchedules[i].EndDate := StrToDateDef(EndDateStr, Date + 365); JActArr := JSched.GetValue('Actions') as TJSONArray; if Assigned(JActArr) then begin SetLength(FIrriSchedules[i].Actions, JActArr.Count); for j := 0 to JActArr.Count - 1 do begin JAct := JActArr.Items[j] as TJSONObject; JAct.TryGetValue ('TargetNodeID', FIrriSchedules[i].Actions[j].TargetNodeID); JAct.TryGetValue('DelayMins', FIrriSchedules[i].Actions[j].DelayMins); JAct.TryGetValue('TimeoutMin', FIrriSchedules[i].Actions[j].TimeoutMin); var ModeInt: Integer; if JAct.TryGetValue('StartCondMode', ModeInt) then FIrriSchedules[i].Actions[j].StartCondMode := TAutoCondMode(ModeInt) else FIrriSchedules[i].Actions[j].StartCondMode := acmAND; if JAct.TryGetValue('StopCondMode', ModeInt) then FIrriSchedules[i].Actions[j].StopCondMode := TAutoCondMode(ModeInt) else FIrriSchedules[i].Actions[j].StopCondMode := acmAND; if JAct.TryGetValue('EndCondMode', ModeInt) then FIrriSchedules[i].Actions[j].EndCondMode := TAutoCondMode(ModeInt) else FIrriSchedules[i].Actions[j].EndCondMode := acmAND; JCondArr := JAct.GetValue('StartConds') as TJSONArray; if Assigned(JCondArr) then ParseCondArray(JCondArr, FIrriSchedules[i].Actions[j].StartConds); JCondArr := JAct.GetValue('StopConds') as TJSONArray; if Assigned(JCondArr) then ParseCondArray(JCondArr, FIrriSchedules[i].Actions[j].StopConds); JCondArr := JAct.GetValue('EndConds') as TJSONArray; if Assigned(JCondArr) then ParseCondArray(JCondArr, FIrriSchedules[i].Actions[j].EndConds); end; end; end; finally JArr.Free; end; end; procedure TfrmMain.SaveIrriSchedules; var JSONPath: string; JArr, JActArr: TJSONArray; JSched, JAct: TJSONObject; i, j: Integer; begin JArr := TJSONArray.Create; try for i := 0 to High(FIrriSchedules) do begin JSched := TJSONObject.Create; JSched.AddPair('Enabled', TJSONBool.Create(FIrriSchedules[i].Enabled)); JSched.AddPair('Name', FIrriSchedules[i].SchedName); JSched.AddPair('IsAutoStart', TJSONNumber.Create(FIrriSchedules[i].IsAutoStart)); JSched.AddPair('StartHH', TJSONNumber.Create(FIrriSchedules[i].StartHH)); JSched.AddPair('StartMM', TJSONNumber.Create(FIrriSchedules[i].StartMM)); JSched.AddPair('RepeatDays', TJSONNumber.Create(FIrriSchedules[i].RepeatDays)); JSched.AddPair('LimitOnesTimeMin', TJSONNumber.Create(FIrriSchedules[i].LimitOnesTimeMin)); JSched.AddPair('StartDate', DateToStr(FIrriSchedules[i].StartDate)); JSched.AddPair('EndDate', DateToStr(FIrriSchedules[i].EndDate)); JActArr := TJSONArray.Create; for j := 0 to High(FIrriSchedules[i].Actions) do begin JAct := TJSONObject.Create; JAct.AddPair('TargetNodeID', FIrriSchedules[i].Actions[j].TargetNodeID); JAct.AddPair('DelayMins', TJSONNumber.Create(FIrriSchedules[i].Actions[j].DelayMins)); JAct.AddPair('TimeoutMin', TJSONNumber.Create(FIrriSchedules[i].Actions[j].TimeoutMin)); JAct.AddPair('StartCondMode', TJSONNumber.Create(Integer(FIrriSchedules[i].Actions[j].StartCondMode))); JAct.AddPair('StopCondMode', TJSONNumber.Create(Integer(FIrriSchedules[i].Actions[j].StopCondMode))); JAct.AddPair('EndCondMode', TJSONNumber.Create(Integer(FIrriSchedules[i].Actions[j].EndCondMode))); JAct.AddPair('StartConds', CondArrayToJSON(FIrriSchedules[i].Actions[j].StartConds)); JAct.AddPair('StopConds', CondArrayToJSON(FIrriSchedules[i].Actions[j].StopConds)); JAct.AddPair('EndConds', CondArrayToJSON(FIrriSchedules[i].Actions[j].EndConds)); JActArr.AddElement(JAct); end; JSched.AddPair('Actions', JActArr); JArr.AddElement(JSched); end; JSONPath := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'SmartFarmHMI_Irrigation.json'); System.IOUtils.TFile.WriteAllText(JSONPath, JArr.Format(2), TEncoding.UTF8); finally JArr.Free; end; end; // ─── 백엔드 상태 머신 (타이머 호출용) ────────────────────────────────────────────── procedure TfrmMain.CheckIrrigationSchedules; var i: Integer; Sched: ^TIrrigationSchedule; CurAct: ^TIrriAction; h, m, s, ms: Word; NowTime: TDateTime; ChIdx: Integer; begin NowTime := Now; DecodeTime(NowTime, h, m, s, ms); for i := 0 to High(FIrriSchedules) do begin Sched := @FIrriSchedules[i]; if not Sched.Enabled then begin Sched.IsRunning := False; Sched.WaitingManual := False; Continue; end; // ─── [1단계] 대기 중 → 트리거 시간 도달 확인 ──────────────────────────── if (not Sched.IsRunning) and (not Sched.WaitingManual) then begin if (Date >= Sched.StartDate) and (Date <= Sched.EndDate) and ((Sched.LastRunDate = 0) or (DaysBetween(Date, Sched.LastRunDate) >= Sched.RepeatDays)) and (Sched.StartHH = h) and (Sched.StartMM = m) and (s < 5) then begin if Sched.IsAutoStart = 0 then begin Sched.IsRunning := True; Sched.CurrentActionIdx := 0; Sched.IsCoolingDown := False; Sched.IsDelaying := True; Sched.ActionStartTick := NowTime; Sched.SchedStartTick := NowTime; Sched.LastRunDate := Date; SaveIrriSchedules; AddSystemLog('관수 스케쥴 자동 시작: ' + Sched.SchedName); TThread.Queue(nil, UpdateIrriDashboardUI); end else begin Sched.WaitingManual := True; Sched.LastRunDate := Date; AddSystemLog('관수 스케쥴 수동 대기: ' + Sched.SchedName); TThread.Queue(nil, UpdateIrriDashboardUI); ShowCustomAlert('관수 시작 대기', 'Irrigation Ready', '[' + Sched.SchedName + '] 관수 시작 시간입니다. 현황 화면의 [시작] 버튼을 눌러주세요.', '[' + Sched.SchedName + '] Irrigation time reached. Press [Start].'); end; end; Continue; end; if Sched.WaitingManual then Continue; // ─── [4단계] 전체 완료 ─────────────────────────────────────────────────── if Sched.CurrentActionIdx >= Length(Sched.Actions) then begin Sched.IsRunning := False; Sched.IsCoolingDown := False; AddSystemLog('관수 스케쥴 완료: ' + Sched.SchedName); TThread.Queue(nil, UpdateIrriDashboardUI); Continue; end; CurAct := @Sched.Actions[Sched.CurrentActionIdx]; ChIdx := GetNodeIndex(CurAct.TargetNodeID); // ─── 쿨다운 진행 중 ───────────────────────────────────────────────────── if Sched.IsCoolingDown then begin if (NowTime >= Sched.CooldownEndTick) and (not EvalIrriConditions(CurAct.StopConds, CurAct.StopCondMode, False)) then begin Sched.IsCoolingDown := False; if EvalIrriConditionsTotalFlow(CurAct.EndConds, CurAct.EndCondMode, False) then begin // 종료조건 충족 → 다음 구역 AddSystemLog('쿨다운 후 종료조건: ' + Sched.SchedName + ' -> ' + CurAct.TargetNodeID); Inc(Sched.CurrentActionIdx); Sched.IsDelaying := True; Sched.ActionStartTick := NowTime; end else begin // 종료조건 미달 → 시작조건 재검사 Sched.IsDelaying := True; AddSystemLog('쿨다운 완료, 재시작 대기: ' + Sched.SchedName + ' -> ' + CurAct.TargetNodeID); end; TThread.Queue(nil, UpdateIrriDashboardUI); end; Continue; end; // ─── [2단계] 시작조건 검사 대기 ───────────────────────────────────────── if Sched.IsDelaying then begin if EvalIrriConditions(CurAct.StartConds, CurAct.StartCondMode, True) then begin Sched.IsDelaying := False; Sched.ActionStartTick := NowTime; InitActionBaseValues(CurAct^); if ChIdx >= 0 then PublishControl(ChIdx, True); AddSystemLog('동작 시작: ' + Sched.SchedName + ' -> ' + CurAct.TargetNodeID); TThread.Queue(nil, UpdateIrriDashboardUI); end; Continue; end; // ─── [3단계] 동작 중 모니터링 ─────────────────────────────────────────── // 상황 A: 타임아웃 → 강제 다음 구역 스킵 var effectiveTimeout := CurAct.TimeoutMin; if effectiveTimeout <= 0 then effectiveTimeout := Sched.LimitOnesTimeMin; if (effectiveTimeout > 0) and (MinutesBetween(NowTime, Sched.ActionStartTick) >= effectiveTimeout) then begin if ChIdx >= 0 then PublishControl(ChIdx, False); AddSystemLog('[경고] 타임아웃 스킵: ' + Sched.SchedName + ' -> ' + CurAct.TargetNodeID); Inc(Sched.CurrentActionIdx); Sched.IsDelaying := True; Sched.ActionStartTick := NowTime; TThread.Queue(nil, UpdateIrriDashboardUI); Continue; end; // 상황 B: 종료조건 → 정상 완료, 다음 구역 if Sched.IsRunning and EvalIrriConditionsTotalFlow(CurAct.EndConds, CurAct.EndCondMode, False) then begin if ChIdx >= 0 then PublishControl(ChIdx, False); AddSystemLog('종료조건 달성: ' + Sched.SchedName + ' -> ' + CurAct.TargetNodeID); EvalIrriFlowInitTotalFlow(CurAct.EndConds); Inc(Sched.CurrentActionIdx); Sched.IsDelaying := True; Sched.ActionStartTick := NowTime; TThread.Queue(nil, UpdateIrriDashboardUI); Continue; end; // 상황 C: 정지조건 → 임시 정지 + 쿨다운 if Sched.IsRunning and EvalIrriConditions(CurAct.StopConds, CurAct.StopCondMode, False) then begin EvalIrriFlowInit(CurAct.StopConds); if ChIdx >= 0 then PublishControl(ChIdx, False); Sched.IsCoolingDown := True; Sched.CooldownEndTick := NowTime + CurAct.DelayMins / MinsPerDay; AddSystemLog('정지조건 달성, 쿨다운(' + IntToStr(CurAct.DelayMins) + '분): ' + Sched.SchedName + ' -> ' + CurAct.TargetNodeID); TThread.Queue(nil, UpdateIrriDashboardUI); Continue; end; end; end; // ─── UI 생성 / 갱신 ───────────────────────────────────────────────────────── procedure TfrmMain.BuildIrriUI; begin // 전체 스크린 레이아웃 lytScreenIrri := TLayout.Create(Self); lytScreenIrri.Parent := lytMainContent; lytScreenIrri.Align := TAlignLayout.Client; lytScreenIrri.Visible := False; // 상단 스케쥴 대시보드 lytIrriDashboard := TLayout.Create(Self); lytIrriDashboard.Parent := lytScreenIrri; lytIrriDashboard.Align := TAlignLayout.Top; lytIrriDashboard.Height := 250; lytIrriDashboard.Margins.Rect := RectF(20, 20, 20, 20); var DashboardTitle := BuildLabel(lytIrriDashboard, '진행 상황 (Status)', 20, TAlignLayout.Top, claLightgray, True); DashboardTitle.Height := 30; // 스케쥴 등록 목록용 스크롤박스 lytIrriSettings := TLayout.Create(Self); // wrapper for settings lytIrriSettings.Parent := lytScreenIrri; lytIrriSettings.Align := TAlignLayout.Client; var SettingsTitle := BuildLabel(lytIrriSettings, '스케쥴 목록 (Schedules)', 20, TAlignLayout.Top, claLightgray, True); SettingsTitle.Height := 30; SettingsTitle.Margins.Rect := RectF(20, 0, 20, 0); scrlIrriSettings := TScrollBox.Create(Self); scrlIrriSettings.Parent := lytIrriSettings; scrlIrriSettings.Align := TAlignLayout.Client; scrlIrriSettings.Margins.Rect := RectF(20, 10, 20, 20); // 하단 버튼 라인 - 추가 + 선택삭제 var btnRowLyt := TLayout.Create(Self); btnRowLyt.Parent := lytIrriSettings; btnRowLyt.Align := TAlignLayout.Bottom; btnRowLyt.Height := 57; btnRowLyt.Margins.Rect := RectF(0, 0, 16, 0); var btnAddSched := CreateCustomButton(btnRowLyt, '+ 새 스케쥴 추가', TAlignLayout.Client, 0, OnIrriScheduleAdd); TLabel(btnAddSched.Controls[0]).TextSettings.Font.Size := 20; btnAddSched.Fill.Color := $FF226622; btnAddSched.Margins.Rect := RectF(16, 0, 16, 0); var btnDelSel := CreateCustomButton(btnRowLyt, '🗑 ' + '선택 삭제', TAlignLayout.Right, 600, OnIrriScheduleDelSelected); TLabel(btnDelSel.Controls[0]).TextSettings.Font.Size := 20; btnDelSel.Fill.Color := $FFFF5252; btnDelSel.Margins.Rect := RectF(0, 0, 0, 0); end; procedure TfrmMain.BuildIrriActionPopupUI; var Bg, Dlg: TRectangle; BottomLyt: TLayout; BtnAdd, BtnClose: TRectangle; TopDelBtn: TRectangle; begin lytIrriActionPopup := TLayout.Create(Self); lytIrriActionPopup.Parent := lytMainContent; lytIrriActionPopup.Align := TAlignLayout.Client; lytIrriActionPopup.Visible := False; Bg := TRectangle.Create(Self); Bg.Parent := lytIrriActionPopup; Bg.Align := TAlignLayout.Client; Bg.Fill.Color := $B0000000; // Semi-transparent black Bg.Stroke.Kind := TBrushKind.None; Bg.HitTest := True; Dlg := TRectangle.Create(Self); Dlg.Parent := lytIrriActionPopup; Dlg.Align := TAlignLayout.Center; Dlg.Width := 1000; Dlg.Height := 800; Dlg.Fill.Color := $FF222222; Dlg.Stroke.Color := $FF444444; Dlg.XRadius := 10; Dlg.YRadius := 10; lblIrriActionTitle := BuildLabel(Dlg, '최상위 스케쥴 상세 동작 관리', 24, TAlignLayout.Top, claWhite, True); lblIrriActionTitle.Height := 65; lblIrriActionTitle.Margins.Rect := RectF(16, 16, 16, 0); TopDelBtn := CreateCustomButton(lblIrriActionTitle, '🗑 ' + '선택 삭제', TAlignLayout.Right, 200, OnIrriActionDelSelected); TLabel(TopDelBtn.Controls[0]).TextSettings.Font.Size := 20; TopDelBtn.Fill.Color := $FFFF5252; scrlIrriAction := TScrollBox.Create(Self); scrlIrriAction.Parent := Dlg; scrlIrriAction.Align := TAlignLayout.Client; scrlIrriAction.Margins.Rect := RectF(16, 16, 16, 16); BottomLyt := TLayout.Create(Self); BottomLyt.Parent := Dlg; BottomLyt.Align := TAlignLayout.Bottom; BottomLyt.Height := 60; BtnAdd := CreateCustomButton(BottomLyt, '+ 동작(구역) 추가', TAlignLayout.Left, 350, OnIrriActionAdd); TLabel(BtnAdd.Controls[0]).TextSettings.Font.Size := 20; BtnAdd.Margins.Rect := RectF(16, 0, 16, 16); BtnClose := CreateCustomButton(BottomLyt, '닫기 (저장)', TAlignLayout.Right, 350, OnIrriActionPopupClose); TLabel(BtnClose.Controls[0]).TextSettings.Font.Size := 20; BtnClose.Margins.Rect := RectF(16, 0, 16, 16); end; procedure TfrmMain.UpdateIrriDashboardUI; var i: Integer; StatStr: string; Lbl: TLabel; BtnStart: TRectangle; DashLyt: TLayout; begin if lytIrriDashboard = nil then Exit; // 기존 동적 컨트롤 제거 for i := lytIrriDashboard.ControlsCount - 1 downto 1 do lytIrriDashboard.Controls[i].Parent := nil; StatStr := ''; for i := 0 to High(FIrriSchedules) do begin if FIrriSchedules[i].WaitingManual then StatStr := StatStr + Format('[%s] 🔔 수동 시작 대기 중', [FIrriSchedules[i].SchedName]) + sLineBreak else if FIrriSchedules[i].IsRunning then begin var ActIdx := FIrriSchedules[i].CurrentActionIdx; var ActTotal := Length(FIrriSchedules[i].Actions); var ActName := ''; if (ActIdx >= 0) and (ActIdx < ActTotal) then ActName := FIrriSchedules[i].Actions[ActIdx].TargetNodeID; var StateStr: string; if FIrriSchedules[i].IsCoolingDown then StateStr := '쿨다운 대기 중' else if FIrriSchedules[i].IsDelaying then StateStr := '시작 조건 대기' else StateStr := '동작 중 ▶'; StatStr := StatStr + Format('[%s] %s 구역: %s (%d/%d)', [FIrriSchedules[i].SchedName, StateStr, ActName, ActIdx + 1, ActTotal]) + sLineBreak; end; end; if StatStr = '' then StatStr := '현재 진행 중인 관수 스케쥴이 없습니다.'; Lbl := BuildLabel(lytIrriDashboard, StatStr, 18, TAlignLayout.Client, claWhite, False); Lbl.Margins.Rect := RectF(10, 10, 10, 10); Lbl.WordWrap := True; // 수동 대기 중인 스케쥴이 있으면 [시작] 버튼 생성 for i := 0 to High(FIrriSchedules) do begin if FIrriSchedules[i].WaitingManual then begin BtnStart := CreateCustomButton(lytIrriDashboard, '▶ [' + FIrriSchedules[i].SchedName + '] 시작', TAlignLayout.Bottom, 0, OnIrriManualStartStop); BtnStart.Fill.Color := $FF226622; BtnStart.Height := 50; BtnStart.Tag := i; BtnStart.Margins.Rect := RectF(10, 0, 10, 5); end; end; end; procedure TfrmMain.RefreshIrriUI; var i: Integer; CardH: Single; Card: TRectangle; LHeader: TLabel; SW_En: TSwitch; EdName, EdStartHH, EdStartMM, EdRepeatDays, EdLimitOnesTime: TEdit; ChkDel: TCheckBox; DtpSd, DtpEd: TDateEdit; cbIsAutoStart: TCombobox; begin if FRefreshingAutoUI then Exit; FRefreshingAutoUI := True; try scrlIrriSettings.BeginUpdate; if Self.Focused <> nil then Self.Focused := nil; for i := scrlIrriSettings.Content.ControlsCount - 1 downto 0 do begin var C := scrlIrriSettings.Content.Controls[i]; if C is TEdit then (C as TEdit).OnExit := nil; if C is TSwitch then (C as TSwitch).OnSwitch := nil; if C is TCheckBox then (C as TCheckBox).OnChange := nil; if C is TDateEdit then (C as TDateEdit).OnChange := nil; if C is TCombobox then (C as TCombobox).OnChange := nil; C.Parent := nil; // macOS 안정 end; if Length(FIrriSchedules) = 0 then begin var LEmpty := BuildLabel(scrlIrriSettings, '등록된 스케쥴이 없습니다.', 18, TAlignLayout.Top, claLightgray, False); LEmpty.Margins.Top := 20; LEmpty.TextSettings.HorzAlign := TTextAlign.Center; end; for i := 0 to High(FIrriSchedules) do begin CardH := 100; Card := TRectangle.Create(Self); Card.Parent := scrlIrriSettings; Card.Align := TAlignLayout.Top; Card.Height := CardH; Card.Margins.Rect := RectF(0, 0, 0, 10); Card.Fill.Color := IfThen(FIrriSchedules[i].IsSelected, $FF3A2020, $FF2D2D2D); Card.Stroke.Color := $FF555555; Card.XRadius := 8; Card.YRadius := 8; // 체크박스 (선택용) {$IFDEF ANDROID} var ChkBg := TRectangle.Create(Self); ChkBg.Parent := Card; ChkBg.Position.X := 12; ChkBg.Position.Y := 17; ChkBg.Width := 30; ChkBg.Height := 30; ChkBg.Fill.Color := claWhite; ChkBg.Stroke.Kind := TBrushKind.None; ChkBg.XRadius := 4; ChkBg.YRadius := 4; ChkBg.HitTest := False; {$ENDIF} ChkDel := TCheckBox.Create(Self); ChkDel.Parent := Card; ChkDel.Position.X := 10; ChkDel.Position.Y := 14; ChkDel.Width := 24; ChkDel.Height := 24; ChkDel.IsChecked := FIrriSchedules[i].IsSelected; ChkDel.Tag := i; ChkDel.Scale.X := 1.5; ChkDel.Scale.Y := 1.5; ChkDel.OnChange := OnIrriScheduleCheck; LHeader := BuildLabel(Card, '스케쥴명 : ', 20, TAlignLayout.None, claLightgray, True); LHeader.SetBounds(60, 16, 100, 30); EdName := TEdit.Create(Self); EdName.Parent := Card; EdName.SetBounds(170, 16, 160, 30); EdName.Text := FIrriSchedules[i].SchedName; EdName.Tag := i; EdName.TagFloat := 1; EdName.StyledSettings := EdName.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdName.TextSettings.Font.Size := 18; {$IFDEF ANDROID} EdName.TextSettings.FontColor := claWhite; {$ENDIF} EdName.OnExit := OnIrriScheduleEdit; BuildLabel(Card, '활성화 :', 20, TAlignLayout.None, claLightgray, False).SetBounds(920, 16, 80, 30); SW_En := TSwitch.Create(Self); SW_En.Parent := Card; SW_En.SetBounds(1080, 16, 120, 30); SW_En.IsChecked := FIrriSchedules[i].Enabled; SW_En.Tag := i; SW_En.TagFloat := 0; SW_En.OnSwitch := OnIrriScheduleEdit; // ── 시작일자 / 종료일자 ────────────────────────────────────────────── BuildLabel(Card, '시작일자 :', 20, TAlignLayout.None, claLightgray, False).SetBounds(60, 60, 100, 30); DtpSd := BuildDateEdit(Card, FIrriSchedules[i].StartDate, 18, TAlignLayout.None, claBlack, False); DtpSd.SetBounds(170, 60, 160, 30); DtpSd.Tag := i; DtpSd.TagFloat := 2; // StartDate 필드 {$IFDEF ANDROID} DtpSd.TextSettings.FontColor := claWhite; {$ENDIF} DtpSd.OnChange := OnIrriScheduleEdit; BuildLabel(Card, '종료일자 :', 20, TAlignLayout.None, claLightgray, False).SetBounds(350, 60, 100, 30); DtpEd := BuildDateEdit(Card, FIrriSchedules[i].EndDate, 18, TAlignLayout.None, claBlack, False); DtpEd.SetBounds(450, 60, 160, 30); DtpEd.Tag := i; DtpEd.TagFloat := 3; // EndDate 필드 {$IFDEF ANDROID} DtpEd.TextSettings.FontColor := claWhite; {$ENDIF} DtpEd.OnChange := OnIrriScheduleEdit; // ── 시작시간 ────────────────────────────────────────────── BuildLabel(Card, '시작시간 :', 20, TAlignLayout.None, claLightgray, False).SetBounds(630, 60, 130, 30); EdStartHH := TEdit.Create(Self); EdStartHH.Parent := Card; EdStartHH.SetBounds(775, 60, 50, 30); EdStartHH.TextSettings.Font.Size := 18; EdStartHH.MaxLength := 2; EdStartHH.Text := Format('%.2d', [FIrriSchedules[i].StartHH]); EdStartHH.StyledSettings := EdStartHH.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdStartHH.FilterChar := '0123456789'; {$IFDEF ANDROID} EdStartHH.TextSettings.FontColor := claWhite; {$ENDIF} EdStartHH.TagFloat := 4; // StartHH 필드 EdStartHH.Tag := i; EdStartHH.OnExit := OnIrriScheduleEdit; BuildLabel(Card, ':', 18, TAlignLayout.None, claWhite, True).SetBounds(830, 60, 10, 30); EdStartMM := TEdit.Create(Self); EdStartMM.Parent := Card; EdStartMM.SetBounds(840, 60, 50, 30); EdStartMM.TextSettings.Font.Size := 18; EdStartMM.MaxLength := 2; EdStartMM.Text := Format('%.2d', [FIrriSchedules[i].StartMM]); EdStartMM.StyledSettings := EdStartMM.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdStartMM.FilterChar := '0123456789'; {$IFDEF ANDROID} EdStartMM.TextSettings.FontColor := claWhite; {$ENDIF} EdStartMM.TagFloat := 5; // StartHH 필드 EdStartMM.Tag := i; EdStartMM.OnExit := OnIrriScheduleEdit; // ── 반복주기(일) ────────────────────────────────────────────── BuildLabel(Card, '반복주기(일) :', 20, TAlignLayout.None, claLightgray, False).SetBounds(350, 16, 130, 30); EdRepeatDays := TEdit.Create(Self); EdRepeatDays.Parent := Card; EdRepeatDays.SetBounds(560, 16, 50, 30); EdRepeatDays.TextSettings.Font.Size := 18; EdRepeatDays.MaxLength := 2; EdRepeatDays.Text := Format('%.d', [FIrriSchedules[i].RepeatDays]); EdRepeatDays.StyledSettings := EdRepeatDays.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdRepeatDays.FilterChar := '0123456789'; {$IFDEF ANDROID} EdRepeatDays.TextSettings.FontColor := claWhite; {$ENDIF} EdRepeatDays.TagFloat := 6; // RepeatDays 필드 EdRepeatDays.Tag := i; EdRepeatDays.OnExit := OnIrriScheduleEdit; // ── 1회당 제한시간(분) ────────────────────────────────────────────── BuildLabel(Card, '1회당 제한시간(분) :', 20, TAlignLayout.None, claLightgray, False).SetBounds(630, 16, 200, 30); EdLimitOnesTime := TEdit.Create(Self); EdLimitOnesTime.Parent := Card; EdLimitOnesTime.SetBounds(840, 16, 50, 30); EdLimitOnesTime.TextSettings.Font.Size := 18; EdLimitOnesTime.MaxLength := 2; EdLimitOnesTime.Text := Format('%.d', [FIrriSchedules[i].LimitOnesTimeMin]); EdLimitOnesTime.StyledSettings := EdLimitOnesTime.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdLimitOnesTime.FilterChar := '0123456789'; {$IFDEF ANDROID} EdLimitOnesTime.TextSettings.FontColor := claWhite; {$ENDIF} EdLimitOnesTime.TagFloat := 7; EdLimitOnesTime.Tag := i; EdLimitOnesTime.OnExit := OnIrriScheduleEdit; // ── 스케쥴 자동 실행 여부 ────────────────────────────────────────────── BuildLabel(Card, '자동 실행 여부:', 20, TAlignLayout.None, claLightgray, False).SetBounds(920, 60, 200, 30); cbIsAutoStart := TComboBox.Create(Self); cbIsAutoStart.Parent := Card; cbIsAutoStart.SetBounds(1080, 60, 120, 30); var x: Integer; for x := 0 to 1 do begin var LItms := TListBoxItem.Create(cbIsAutoStart); LItms.Parent := cbIsAutoStart; LItms.Height := 24 * 1.8; LItms.StyledSettings := LItms.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItms.TextSettings.Font.Size := 18; if x = 0 then LItms.Text := '자동(즉시)' else LItms.Text := '수동(알림)'; {$IFDEF ANDROID} LItms.TextSettings.FontColor := claWhite; {$ENDIF} cbIsAutoStart.AddObject(LItms); end; cbIsAutoStart.ItemIndex := FIrriSchedules[i].IsAutoStart; cbIsAutoStart.Tag := i; cbIsAutoStart.TagFloat := 8; cbIsAutoStart.OnChange := OnIrriScheduleEdit; var btnDetail := CreateCustomButton(Card, '동작 관리', TAlignLayout.None, 160, OnIrriActionManageClick); btnDetail.SetBounds(1240, 8, 160, 84); btnDetail.Tag := i; end; finally scrlIrriSettings.EndUpdate; FRefreshingAutoUI := False; end; end; // ─── UI 이벤트 핸들러 ──────────────────────────────────────────────────────── procedure TfrmMain.OnIrriNavClick(Sender: TObject); begin if Sender is TRectangle then SwitchScreen(TRectangle(Sender).Tag); end; procedure TfrmMain.OnIrriScheduleAdd(Sender: TObject); var S: TIrrigationSchedule; begin FillChar(S, SizeOf(S), 0); S.Enabled := False; S.SchedName := '새 스케쥴 ' + IntToStr(Length(FIrriSchedules)+1); S.IsAutoStart := 0; // 0 : 즉시 , 1 : 수동 S.StartHH := 6; S.StartMM := 0; S.RepeatDays := 1; S.StartDate := Date; S.EndDate := Date + 365; S.IsSelected := False; S.IsRunning := False; S.WaitingManual := False; S.IsCoolingDown := False; SetLength(S.Actions, 0); SetLength(FIrriSchedules, Length(FIrriSchedules)+1); FIrriSchedules[High(FIrriSchedules)] := S; SaveIrriSchedules; AddSystemLog('시스템 설정 변경: 새 관수 스케쥴 추가됨'); TThread.Queue(nil, RefreshIrriUI); end; procedure TfrmMain.OnIrriScheduleCheck(Sender: TObject); var Idx: Integer; begin if FRefreshingAutoUI then Exit; // UI 재뱌드 중 이벤트 무시 if Sender is TCheckBox then begin Idx := TCheckBox(Sender).Tag; if (Idx >= 0) and (Idx <= High(FIrriSchedules)) then FIrriSchedules[Idx].IsSelected := TCheckBox(Sender).IsChecked; end; end; procedure TfrmMain.OnIrriScheduleDel(Sender: TObject); begin // Not used in checkbox mode end; procedure TfrmMain.OnIrriScheduleDelSelected(Sender: TObject); var i, Cnt: Integer; begin Cnt := 0; for i := High(FIrriSchedules) downto 0 do if FIrriSchedules[i].IsSelected then begin Delete(FIrriSchedules, i, 1); Inc(Cnt); end; if Cnt = 0 then Exit; SaveIrriSchedules; AddSystemLog('시스템 설정 변경: 선택 스케쥴 ' + IntToStr(Cnt) + '개 삭제'); TThread.Queue(nil, RefreshIrriUI); end; procedure TfrmMain.OnIrriScheduleEdit(Sender: TObject); var Idx, FieldID: Integer; begin if FRefreshingAutoUI then Exit; Idx := -1; FieldID := -1; if Sender is TSwitch then begin Idx := (Sender as TSwitch).Tag; FieldID := Trunc((Sender as TSwitch).TagFloat); end else if Sender is TEdit then begin Idx := (Sender as TEdit).Tag; FieldID := Trunc((Sender as TEdit).TagFloat); end else if Sender is TDateEdit then begin Idx := (Sender as TDateEdit).Tag; FieldID := Trunc((Sender as TDateEdit).TagFloat); end else if Sender is TComboBox then begin Idx := (Sender as TComboBox).Tag; FieldID := Trunc((Sender as TComboBox).TagFloat); end; if (Idx < 0) or (Idx > High(FIrriSchedules)) then Exit; case FieldID of 0: if Sender is TSwitch then FIrriSchedules[Idx].Enabled := (Sender as TSwitch).IsChecked; 1: if Sender is TEdit then FIrriSchedules[Idx].SchedName := (Sender as TEdit).Text; 2: if Sender is TDateEdit then FIrriSchedules[Idx].StartDate := (Sender as TDateEdit).Date; 3: if Sender is TDateEdit then FIrriSchedules[Idx].EndDate := (Sender as TDateEdit).Date; 4: if Sender is TEdit then FIrriSchedules[Idx].StartHH := StrToIntDef((Sender as TEdit).Text, 0) mod 24; 5: if Sender is TEdit then FIrriSchedules[Idx].StartMM := StrToIntDef((Sender as TEdit).Text, 0) mod 60; 6: if Sender is TEdit then FIrriSchedules[Idx].RepeatDays := StrToIntDef((Sender as TEdit).Text, 0); 7: if Sender is TEdit then FIrriSchedules[Idx].LimitOnesTimeMin := StrToIntDef((Sender as TEdit).Text, 0); 8: if Sender is TComboBox then FIrriSchedules[Idx].IsAutoStart:= (Sender as TComboBox).ItemIndex; end; SaveIrriSchedules; AddSystemLog('시스템 설정 변경: 관수 스케쥴 [' + FIrriSchedules[Idx].SchedName + '] 수정됨'); end; procedure TfrmMain.OnIrriCondEdit(Sender: TObject); var ActIdx, CondType, CondIdx, FieldID: Integer; ValPart: Integer; Act: ^TIrriAction; ValStr: string; begin if FRefreshingAutoUI then Exit; if Sender is TComboBox then begin ActIdx := (Sender as TComboBox).Tag; ValPart := Trunc((Sender as TComboBox).TagFloat); end else if Sender is TEdit then begin ActIdx := (Sender as TEdit).Tag; ValPart := Trunc((Sender as TEdit).TagFloat); end else Exit; if (ActIdx < 0) or (ActIdx > High(FIrriSchedules[FCurrentEditIrriSchedIdx].Actions)) then Exit; Act := @FIrriSchedules[FCurrentEditIrriSchedIdx].Actions[ActIdx]; CondType := ValPart div 1000; CondIdx := (ValPart div 10) mod 100; FieldID := ValPart mod 10; var PCondArray: ^TArray; var PCondMode: ^TAutoCondMode; case CondType of 0: begin PCondArray := @Act^.StartConds; PCondMode := @Act^.StartCondMode; end; 1: begin PCondArray := @Act^.StopConds; PCondMode := @Act^.StopCondMode; end; 2: begin PCondArray := @Act^.EndConds; PCondMode := @Act^.EndCondMode; end; else Exit; end; if FieldID = 3 then // AND/OR Mode begin if (Sender as TComboBox).ItemIndex = 0 then PCondMode^ := acmAND else PCondMode^ := acmOR; end else if (CondIdx >= 0) and (CondIdx < Length(PCondArray^)) then begin case FieldID of 0: // NodeID begin ValStr := (Sender as TComboBox).Items[(Sender as TComboBox).ItemIndex]; var ss := ValStr.Split([' ']); // ID만 추출 if Length(ss) > 0 then begin var strId := ss[0]; PCondArray^[CondIdx].Op := TAutoCondOp(0); PCondArray^[CondIdx].NodeID := strId; end; // 상태에 맞게 Op 콤보박스 아이템 및 Value Enabled 처리를 위해 UI 재갱신 TThread.Queue(nil, procedure begin RefreshIrriActionPopupUI(FCurrentEditIrriSchedIdx); end); end; 1: // Op PCondArray^[CondIdx].Op := TAutoCondOp((Sender as TComboBox).ItemIndex); 2: // Value PCondArray^[CondIdx].Value := StrToFloatDef((Sender as TEdit).Text, 0.0); 4: // FOp begin PCondArray^[CondIdx].FOp := TAutoCondFOp((Sender as TComboBox).ItemIndex); TThread.Queue(nil, procedure begin RefreshIrriActionPopupUI(FCurrentEditIrriSchedIdx); end); end; 5: // PerPulse begin PCondArray^[CondIdx].PerPulse := StrToFloatDef((Sender as TEdit).Text, 0.0); end; end; end; SaveIrriSchedules; end; procedure TfrmMain.OnIrriCondAdd(Sender: TObject); var ActIdx, CondType: Integer; Act: ^TIrriAction; Item: TAutoCondItem; begin if FRefreshingAutoUI then Exit; if not (Sender is TRectangle) then Exit; ActIdx := (Sender as TRectangle).Tag; CondType := Trunc((Sender as TRectangle).TagFloat) div 1000; if (ActIdx < 0) or (ActIdx > High(FIrriSchedules[FCurrentEditIrriSchedIdx].Actions)) then Exit; Act := @FIrriSchedules[FCurrentEditIrriSchedIdx].Actions[ActIdx]; Item.NodeID := ''; Item.Op := acoGT; Item.Value := 0.0; Item.FUse := False; Item.FOp := acfoPulse; Item.PerPulse := 0.0; case CondType of 0: begin SetLength(Act^.StartConds, Length(Act^.StartConds)+1); Act^.StartConds[High(Act^.StartConds)] := Item; end; 1: begin SetLength(Act^.StopConds, Length(Act^.StopConds)+1); Act^.StopConds[High(Act^.StopConds)] := Item; end; 2: begin SetLength(Act^.EndConds, Length(Act^.EndConds)+1); Act^.EndConds[High(Act^.EndConds)] := Item; end; end; SaveIrriSchedules; RefreshIrriActionPopupUI(FCurrentEditIrriSchedIdx); end; procedure TfrmMain.OnIrriCondDel(Sender: TObject); var ActIdx, CondType, CondIdx: Integer; ValPart: Integer; Act: ^TIrriAction; begin if FRefreshingAutoUI then Exit; if not (Sender is TRectangle) then Exit; ActIdx := (Sender as TRectangle).Tag; ValPart := Trunc((Sender as TRectangle).TagFloat); CondType := ValPart div 1000; CondIdx := (ValPart div 10) mod 100; if (ActIdx < 0) or (ActIdx > High(FIrriSchedules[FCurrentEditIrriSchedIdx].Actions)) then Exit; Act := @FIrriSchedules[FCurrentEditIrriSchedIdx].Actions[ActIdx]; case CondType of 0: Delete(Act^.StartConds, CondIdx, 1); 1: Delete(Act^.StopConds, CondIdx, 1); 2: Delete(Act^.EndConds, CondIdx, 1); end; SaveIrriSchedules; RefreshIrriActionPopupUI(FCurrentEditIrriSchedIdx); end; procedure TfrmMain.RefreshIrriActionPopupUI(ASchedIdx: Integer); type PIrriAction = ^TIrriAction; var i: Integer; Card: TRectangle; LblHeader: TLabel; EdDelay: TEdit; CbNode: TComboBox; ChkDel: TCheckBox; Act: PIrriAction; TopLyt: TRectangle; BaseY: Single; begin if (ASchedIdx < 0) or (ASchedIdx > High(FIrriSchedules)) then Exit; lblIrriActionTitle.Text := '동작 순서 관리 : ' + FIrriSchedules[ASchedIdx].SchedName; scrlIrriAction.BeginUpdate; if Self.Focused <> nil then Self.Focused := nil; try for i := scrlIrriAction.Content.ControlsCount - 1 downto 0 do begin var C := scrlIrriAction.Content.Controls[i]; if C is TComboBox then (C as TComboBox).OnChange := nil; if C is TEdit then (C as TEdit).OnExit := nil; if C is TCheckBox then (C as TCheckBox).OnChange := nil; C.Parent := nil; // macOS 안정 end; for i := 0 to High(FIrriSchedules[ASchedIdx].Actions) do begin Act := @FIrriSchedules[ASchedIdx].Actions[i]; Card := TRectangle.Create(Self); Card.Parent := scrlIrriAction; Card.Align := TAlignLayout.Top; Card.Margins.Rect := RectF(0, 0, 0, 12); Card.Fill.Color := IfThen(Act^.IsSelected, $FF3A2020, $FF333333); Card.Stroke.Color := $FF555555; Card.XRadius := 5; Card.YRadius := 5; {$IFDEF ANDROID} var ChkBg := TRectangle.Create(Self); ChkBg.Parent := Card; ChkBg.Position.X := 10; ChkBg.Position.Y := 13; ChkBg.Width := 29; ChkBg.Height := 29; ChkBg.Fill.Color := claWhite; ChkBg.Stroke.Kind := TBrushKind.None; ChkBg.XRadius := 4; ChkBg.YRadius := 4; ChkBg.HitTest := False; {$ENDIF} ChkDel := TCheckBox.Create(Self); ChkDel.Parent := Card; ChkDel.Position.X := 8; ChkDel.Position.Y := 10; ChkDel.Width := 24; ChkDel.Height := 24; ChkDel.IsChecked := Act^.IsSelected; ChkDel.Tag := i; ChkDel.Scale.X := 1.5; ChkDel.Scale.Y := 1.5; ChkDel.OnChange := OnIrriActionCheck; LblHeader := BuildLabel(Card, Format('동작 순번 %d', [i+1]), 18, TAlignLayout.None, claWhite, True); LblHeader.SetBounds(42, 10, 150, 30); var LblNode := BuildLabel(Card, '작동 구역 (DO):', 16, TAlignLayout.None, claLightgray, False); LblNode.SetBounds(10, 50, 120, 30); CbNode := TComboBox.Create(Self); CbNode.Parent := Card; CbNode.SetBounds(130, 50, 205, 30); var kDO: Integer; for kDO := 0 to High(FNodeConfigs) do begin if FNodeConfigs[kDO].ID.StartsWith('DO_') then begin var LItm := TListBoxItem.Create(CbNode); LItm.Parent := CbNode; LItm.Height := 24 * 1.8; LItm.Text := FNodeConfigs[kDO].ID + ' [' + ( if FNodeConfigs[kDO].CustomName <> '' then FNodeConfigs[kDO].CustomName else if FIsKorean then FNodeConfigs[kDO].TitleKO else FNodeConfigs[kDO].TitleEN) + ']'; LItm.StyledSettings := LItm.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItm.TextSettings.Font.Size := 16; {$IFDEF ANDROID} LItm.TextSettings.FontColor := claWhite; {$ENDIF} CbNode.AddObject(LItm); end; end; var kSelDO: Integer; CbNode.ItemIndex := -1; for kSelDO := 0 to CbNode.Items.Count - 1 do if CbNode.Items[kSelDO].StartsWith(Act^.TargetNodeID + ' ') or (CbNode.Items[kSelDO] = Act^.TargetNodeID) then begin CbNode.ItemIndex := kSelDO; Break; end; CbNode.Tag := i; CbNode.TagFloat := 10; CbNode.OnChange := OnIrriActionEdit; var LblDelay := BuildLabel(Card, '쿨다운(분):', 16, TAlignLayout.None, claLightgray, False); LblDelay.SetBounds(350, 50, 90, 30); EdDelay := TEdit.Create(Self); EdDelay.Parent := Card; EdDelay.SetBounds(443, 50, 55, 30); EdDelay.Text := IntToStr(Act^.DelayMins); EdDelay.Tag := i; EdDelay.TagFloat := 11; EdDelay.FilterChar := '0123456789'; EdDelay.StyledSettings := EdDelay.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdDelay.TextSettings.Font.Size := 16; {$IFDEF ANDROID} EdDelay.TextSettings.FontColor := claWhite; {$ENDIF} EdDelay.OnExit := OnIrriActionEdit; var LblTimeout := BuildLabel(Card, '제한(분):', 16, TAlignLayout.None, claLightgray, False); LblTimeout.SetBounds(510, 50, 80, 30); var EdTimeout := TEdit.Create(Self); EdTimeout.Parent := Card; EdTimeout.SetBounds(593, 50, 55, 30); EdTimeout.Text := IntToStr(Act^.TimeoutMin); EdTimeout.Tag := i; EdTimeout.TagFloat := 12; EdTimeout.FilterChar := '0123456789'; EdTimeout.StyledSettings := EdTimeout.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdTimeout.TextSettings.Font.Size := 16; {$IFDEF ANDROID} EdTimeout.TextSettings.FontColor := claWhite; {$ENDIF} EdTimeout.OnExit := OnIrriActionEdit; BaseY := 100; var DrawCondGroup := procedure(ATitle: string; AConds: TArray; AMode: TAutoCondMode; CondType: Integer) var k,m: Integer; cSen, cOp, cMode, cFOp: TComboBox; eVal, EdPerPulse: TEdit; btnDel, btnAdd: TRectangle; bIsDIDO, bIsFlow: Boolean; LItem: TListBoxItem; begin var lTitle := BuildLabel(Card, ATitle, 14, TAlignLayout.None, claLightgray, True); lTitle.SetBounds(10, BaseY, 80, 30); cMode := TComboBox.Create(Self); cMode.Parent := Card; cMode.SetBounds(95, BaseY, 150, 30); for m := 0 to 1 do begin LItem := TListBoxItem.Create(cMode); LItem.Parent := cMode; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 16; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} if m=0 then LItem.Text := if FIsKorean then 'AND (모두 만족)' else 'AND (all match)' else LItem.Text := if FIsKorean then 'OR (하나 만족)' else 'OR (any match)'; cMode.AddObject(LItem); end; cMode.ItemIndex := Integer(AMode); cMode.Tag := i; cMode.TagFloat := CondType * 1000 + 3; cMode.OnChange := OnIrriCondEdit; btnAdd := CreateCustomButton(Card, '+ 추가', TAlignLayout.None, 80, OnIrriCondAdd); TLabel(btnAdd.Controls[0]).TextSettings.Font.Size := 16; btnAdd.SetBounds(255, BaseY, 80, 30); btnAdd.Fill.Color := $FF226622; btnAdd.Tag := i; btnAdd.TagFloat := CondType * 1000 + 4; if (CondType = 0) and (Length(AConds) = 0) then begin BuildLabel(Card, ' [조건 없음 - 무조건 동작]', 16, TAlignLayout.None, claGray, False).SetBounds(350, BaseY, 200, 30); end; BaseY := BaseY + 40; for k := 0 to High(AConds) do begin bIsFlow := False; cSen := TComboBox.Create(Self); cSen.Parent := Card; cSen.SetBounds(95, BaseY, 240, 30); var kS: Integer; for kS := 0 to High(FNodeConfigs) do begin var strId := FNodeConfigs[kS].ID; // 유량계 힌트 표시 (노드 ID가 DI 이고 유량이름을 포함하거나 DI_F 등 규칙이 있다면) var strName := FNodeConfigs[kS].CustomName; if strName = '' then begin if FIsKorean then strName := FNodeConfigs[kS].TitleKO else strName := FNodeConfigs[kS].TitleEN; end; if (strId.StartsWith('DI_')) and ((strName.Contains('유량')) or (strName.Contains('Flow'))) then begin strName := strName + ' (리터/펄스)'; if strId = AConds[k].NodeID then bIsFlow := True; end; var LItmS := TListBoxItem.Create(cSen); LItmS.Parent := cSen; LItmS.Height := 24 * 1.8; LItmS.Text := strId + ' [' + strName + ']'; LItmS.StyledSettings := LItmS.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItmS.TextSettings.Font.Size := 16; {$IFDEF ANDROID} LItmS.TextSettings.FontColor := claWhite; {$ENDIF} cSen.AddObject(LItmS); end; var kSel: Integer; cSen.ItemIndex := -1; for kSel := 0 to cSen.Items.Count - 1 do if cSen.Items[kSel].StartsWith(AConds[k].NodeID + ' ') or (cSen.Items[kSel] = AConds[k].NodeID) then begin cSen.ItemIndex := kSel; Break; end; cSen.Tag := i; cSen.TagFloat := CondType * 1000 + k * 10 + 0; cSen.OnChange := OnIrriCondEdit; bIsDIDO := (AConds[k].NodeID.StartsWith('DI_')) or (AConds[k].NodeID.StartsWith('DO_')); cOp := TComboBox.Create(Self); cOp.Parent := Card; cOp.SetBounds(345, BaseY, 150, 30); if bIsDIDO and (not bIsFlow) then begin for m := 0 to 1 do begin LItem := TListBoxItem.Create(cOp); LItem.Parent := cOp; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 16; if m=0 then LItem.Text := if FIsKorean then 'ON (접점닫힘)' else 'ON (Closed)' else LItem.Text := if FIsKorean then 'OFF (접점열림)' else 'OFF (Open)'; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} cOp.AddObject(LItem); end; if AConds[k].Op >= acoON then cOp.ItemIndex := Ord(AConds[k].Op) - Ord(acoON) else cOp.ItemIndex := 0; end else begin for m := 0 to 2 do begin LItem := TListBoxItem.Create(cOp); LItem.Parent := cOp; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 16; if m=0 then LItem.Text := '> (' + (if FIsKorean then '초과' else 'Greater') + ')' else if m=1 then LItem.Text := '< (' + (if FIsKorean then '미만' else 'Less') + ')' else LItem.Text := '= (' + (if FIsKorean then '같음' else 'Equal') + ')'; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} cOp.AddObject(LItem); end; if AConds[k].Op <= acoEQ then cOp.ItemIndex := Ord(AConds[k].Op) else cOp.ItemIndex := 0; end; cOp.Tag := i; cOp.TagFloat := CondType * 1000 + k * 10 + 1; cOp.OnChange := OnIrriCondEdit; eVal := TEdit.Create(Self); eVal.Parent := Card; eVal.SetBounds(785, BaseY, 70, 30); eVal.Enabled := not (bIsDIDO and (not bIsFlow)); eVal.Text := if (bIsDIDO and (not bIsFlow)) then '--' else FloatToStr(AConds[k].Value); eVal.Tag := i; eVal.TagFloat := CondType * 1000 + k * 10 + 2; eVal.FilterChar := '0123456789.'; eVal.StyledSettings := eVal.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; eVal.TextSettings.Font.Size := 16; {$IFDEF ANDROID} eVal.TextSettings.FontColor := claWhite; {$ENDIF} eVal.OnExit := OnIrriCondEdit; cFOp := TComboBox.Create(Self); cFOp.Parent := Card; cFOp.Enabled := bIsDIDO and bIsFlow; cFOp.SetBounds(505, BaseY, 80, 30); if bIsDIDO and bIsFlow then begin for m := 0 to 2 do begin LItem := TListBoxItem.Create(cFOp); LItem.Parent := cFOp; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 16; if m=0 then LItem.Text := if FIsKorean then '펄스' else 'Pulse' else if m=1 then LItem.Text := if FIsKorean then '리터' else 'Liter' else LItem.Text := if FIsKorean then 'Kg' else 'Kg'; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} cFOp.AddObject(LItem); end; if AConds[k].FOp <= acfoKg then cFOp.ItemIndex := Ord(AConds[k].FOp) else cFOp.ItemIndex := 0; end; cFOp.Tag := i; cFOp.TagFloat := CondType * 1000 + k * 10 + 4; cFOp.OnChange := OnIrriCondEdit; if bIsDIDO and bIsFlow then begin case AConds[k].FOp of acfoLiter : BuildLabel(Card, if FIsKorean then '[ 펄스당 리터:' else '[ Liter per Pulse:', 16, TAlignLayout.None, claLightgray, False).SetBounds(595, BaseY, 100, 30); acfoKg : BuildLabel(Card, if FIsKorean then '[ 펄스당 Kg:' else '[ Kg per Pulse:', 16, TAlignLayout.None, claLightgray, False).SetBounds(595, BaseY, 100, 30); else BuildLabel(Card, '[ --', 16, TAlignLayout.None, claLightgray, False).SetBounds(595, BaseY, 100, 30); end; end else BuildLabel(Card, '[ --', 16, TAlignLayout.None, claLightgray, False).SetBounds(595, BaseY, 100, 30); EdPerPulse := TEdit.Create(Self); EdPerPulse.Parent := Card; EdPerPulse.Enabled := bIsDIDO and bIsFlow and (AConds[k].FOp <> acfoPulse) ; EdPerPulse.SetBounds(705, BaseY, 50, 30); EdPerPulse.Text := if (bIsDIDO and bIsFlow) then FloatToStr(AConds[k].PerPulse) else '--'; EdPerPulse.Tag := i; EdPerPulse.TagFloat := CondType * 1000 + k * 10 + 5; EdPerPulse.FilterChar := '0123456789.'; EdPerPulse.StyledSettings := EdPerPulse.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdPerPulse.TextSettings.Font.Size := 16; {$IFDEF ANDROID} EdPerPulse.TextSettings.FontColor := claWhite; {$ENDIF} EdPerPulse.OnExit := OnIrriCondEdit; BuildLabel(Card, ' ]', 16, TAlignLayout.None, claLightgray, False).SetBounds(755, BaseY, 20, 30); btnDel := CreateCustomButton(Card, '✕', TAlignLayout.None, 50, OnIrriCondDel); TLabel(btnDel.Controls[0]).TextSettings.Font.Size := 22; btnDel.SetBounds(865, BaseY, 80, 30); btnDel.Fill.Color := $FFFF5252; btnDel.Tag := i; btnDel.TagFloat := CondType * 1000 + k * 10 + 6; BaseY := BaseY + 36; end; BaseY := BaseY + 8; // 여백 추가 end; DrawCondGroup('시작 조건:', Act^.StartConds, Act^.StartCondMode, 0); DrawCondGroup('정지 조건:', Act^.StopConds, Act^.StopCondMode, 1); DrawCondGroup('종료 조건:', Act^.EndConds, Act^.EndCondMode, 2); Card.Height := BaseY + 10; end; finally scrlIrriAction.EndUpdate; end; end; procedure TfrmMain.OnIrriActionPopupClose(Sender: TObject); begin lytIrriActionPopup.Visible := False; end; procedure TfrmMain.OnIrriActionManageClick(Sender: TObject); begin if Sender is TRectangle then begin FCurrentEditIrriSchedIdx := TRectangle(Sender).Tag; RefreshIrriActionPopupUI(FCurrentEditIrriSchedIdx); lytIrriActionPopup.Visible := True; lytIrriActionPopup.BringToFront; end; end; procedure TfrmMain.OnIrriActionCheck(Sender: TObject); var Idx: Integer; begin if (FCurrentEditIrriSchedIdx < 0) or (FCurrentEditIrriSchedIdx > High(FIrriSchedules)) then Exit; if Sender is TCheckBox then begin Idx := TCheckBox(Sender).Tag; if (Idx >= 0) and (Idx <= High(FIrriSchedules[FCurrentEditIrriSchedIdx].Actions)) then FIrriSchedules[FCurrentEditIrriSchedIdx].Actions[Idx].IsSelected := TCheckBox(Sender).IsChecked; end; end; procedure TfrmMain.OnIrriActionAdd(Sender: TObject); var Act: TIrriAction; begin if (FCurrentEditIrriSchedIdx < 0) or (FCurrentEditIrriSchedIdx > High(FIrriSchedules)) then Exit; Act.TargetNodeID := ''; Act.DelayMins := 0; Act.TimeoutMin := 0; Act.IsSelected := False; Act.StartCondMode := acmAND; Act.StopCondMode := acmAND; Act.EndCondMode := acmAND; SetLength(Act.StartConds, 0); SetLength(Act.StopConds, 0); SetLength(Act.EndConds, 0); var Len := Length(FIrriSchedules[FCurrentEditIrriSchedIdx].Actions); SetLength(FIrriSchedules[FCurrentEditIrriSchedIdx].Actions, Len + 1); FIrriSchedules[FCurrentEditIrriSchedIdx].Actions[Len] := Act; SaveIrriSchedules; RefreshIrriActionPopupUI(FCurrentEditIrriSchedIdx); end; procedure TfrmMain.OnIrriActionDel(Sender: TObject); begin // Not used in checkbox mode end; procedure TfrmMain.OnIrriActionEdit(Sender: TObject); var SchedIdx, ActIdx, CondType: Integer; FieldType: Integer; NodeIDRaw, ItemText: string; Cond: TAutoCondItem; begin if (FCurrentEditIrriSchedIdx < 0) or (FCurrentEditIrriSchedIdx > High(FIrriSchedules)) then Exit; SchedIdx := FCurrentEditIrriSchedIdx; FieldType := 0; ActIdx := 0; CondType := 0; if Sender is TComboBox then FieldType := Trunc((Sender as TComboBox).TagFloat) else if Sender is TEdit then FieldType := Trunc((Sender as TEdit).TagFloat); case FieldType of 10: // \uc791\ub3d9 \uad6c\uc5ed (DO \ub178\ub4dc) begin ActIdx := (Sender as TComboBox).Tag; if (ActIdx < 0) or (ActIdx > High(FIrriSchedules[SchedIdx].Actions)) then Exit; ItemText := (Sender as TComboBox).Selected.Text; // 'DO_00 [...]' \ud615\uc2dd\uc5d0\uc11c ID\ub9cc \ucd94\ucd9c var SpPos := Pos(' [', ItemText); if SpPos > 0 then FIrriSchedules[SchedIdx].Actions[ActIdx].TargetNodeID := Copy(ItemText, 1, SpPos - 1) else FIrriSchedules[SchedIdx].Actions[ActIdx].TargetNodeID := ItemText; end; 11: // 쿨다운 지연시간(분) begin ActIdx := (Sender as TEdit).Tag; if (ActIdx < 0) or (ActIdx > High(FIrriSchedules[SchedIdx].Actions)) then Exit; FIrriSchedules[SchedIdx].Actions[ActIdx].DelayMins := StrToIntDef((Sender as TEdit).Text, 0); end; 12: // 1회 제한시간(분) begin ActIdx := (Sender as TEdit).Tag; if (ActIdx < 0) or (ActIdx > High(FIrriSchedules[SchedIdx].Actions)) then Exit; FIrriSchedules[SchedIdx].Actions[ActIdx].TimeoutMin := StrToIntDef((Sender as TEdit).Text, 0); end; 20: // 조건 노드 선택 begin ActIdx := (Sender as TComboBox).Tag div 10; CondType := (Sender as TComboBox).Tag mod 10; if (ActIdx < 0) or (ActIdx > High(FIrriSchedules[SchedIdx].Actions)) then Exit; ItemText := (Sender as TComboBox).Selected.Text; var SpPos := Pos(' [', ItemText); NodeIDRaw := if SpPos > 0 then Copy(ItemText, 1, SpPos - 1) else ItemText; if NodeIDRaw.StartsWith('--') then NodeIDRaw := ''; case CondType of 0: begin if Length(FIrriSchedules[SchedIdx].Actions[ActIdx].StartConds) = 0 then SetLength(FIrriSchedules[SchedIdx].Actions[ActIdx].StartConds, 1); FIrriSchedules[SchedIdx].Actions[ActIdx].StartConds[0].NodeID := NodeIDRaw; end; 1: begin if Length(FIrriSchedules[SchedIdx].Actions[ActIdx].StopConds) = 0 then SetLength(FIrriSchedules[SchedIdx].Actions[ActIdx].StopConds, 1); FIrriSchedules[SchedIdx].Actions[ActIdx].StopConds[0].NodeID := NodeIDRaw; end; 2: begin if Length(FIrriSchedules[SchedIdx].Actions[ActIdx].EndConds) = 0 then SetLength(FIrriSchedules[SchedIdx].Actions[ActIdx].EndConds, 1); FIrriSchedules[SchedIdx].Actions[ActIdx].EndConds[0].NodeID := NodeIDRaw; end; end; end; 21: // \uc870\uac74 \uc5f0\uc0b0\uc790 begin ActIdx := (Sender as TComboBox).Tag div 10; CondType := (Sender as TComboBox).Tag mod 10; if (ActIdx < 0) or (ActIdx > High(FIrriSchedules[SchedIdx].Actions)) then Exit; var Op := TAutoCondOp((Sender as TComboBox).ItemIndex); case CondType of 0: begin if Length(FIrriSchedules[SchedIdx].Actions[ActIdx].StartConds) = 0 then SetLength(FIrriSchedules[SchedIdx].Actions[ActIdx].StartConds, 1); FIrriSchedules[SchedIdx].Actions[ActIdx].StartConds[0].Op := Op; end; 1: begin if Length(FIrriSchedules[SchedIdx].Actions[ActIdx].StopConds) = 0 then SetLength(FIrriSchedules[SchedIdx].Actions[ActIdx].StopConds, 1); FIrriSchedules[SchedIdx].Actions[ActIdx].StopConds[0].Op := Op; end; 2: begin if Length(FIrriSchedules[SchedIdx].Actions[ActIdx].EndConds) = 0 then SetLength(FIrriSchedules[SchedIdx].Actions[ActIdx].EndConds, 1); FIrriSchedules[SchedIdx].Actions[ActIdx].EndConds[0].Op := Op; end; end; end; 22: // \uc870\uac74 \uc218\uce58\uac12 begin ActIdx := (Sender as TEdit).Tag div 10; CondType := (Sender as TEdit).Tag mod 10; if (ActIdx < 0) or (ActIdx > High(FIrriSchedules[SchedIdx].Actions)) then Exit; var V := StrToFloatDef((Sender as TEdit).Text, 0); case CondType of 0: begin if Length(FIrriSchedules[SchedIdx].Actions[ActIdx].StartConds) = 0 then SetLength(FIrriSchedules[SchedIdx].Actions[ActIdx].StartConds, 1); FIrriSchedules[SchedIdx].Actions[ActIdx].StartConds[0].Value := V; end; 1: begin if Length(FIrriSchedules[SchedIdx].Actions[ActIdx].StopConds) = 0 then SetLength(FIrriSchedules[SchedIdx].Actions[ActIdx].StopConds, 1); FIrriSchedules[SchedIdx].Actions[ActIdx].StopConds[0].Value := V; end; 2: begin if Length(FIrriSchedules[SchedIdx].Actions[ActIdx].EndConds) = 0 then SetLength(FIrriSchedules[SchedIdx].Actions[ActIdx].EndConds, 1); FIrriSchedules[SchedIdx].Actions[ActIdx].EndConds[0].Value := V; end; end; end; end; SaveIrriSchedules; end; procedure TfrmMain.OnIrriActionDelSelected(Sender: TObject); var i, Cnt: Integer; Acts: TArray; begin if (FCurrentEditIrriSchedIdx < 0) or (FCurrentEditIrriSchedIdx > High(FIrriSchedules)) then Exit; Cnt := 0; for i := High(FIrriSchedules[FCurrentEditIrriSchedIdx].Actions) downto 0 do if FIrriSchedules[FCurrentEditIrriSchedIdx].Actions[i].IsSelected then begin Delete(FIrriSchedules[FCurrentEditIrriSchedIdx].Actions, i, 1); Inc(Cnt); end; if Cnt = 0 then Exit; SaveIrriSchedules; RefreshIrriActionPopupUI(FCurrentEditIrriSchedIdx); end; procedure TfrmMain.OnIrriManualStartStop(Sender: TObject); var Idx: Integer; begin if not (Sender is TRectangle) then Exit; Idx := TRectangle(Sender).Tag; if (Idx < 0) or (Idx > High(FIrriSchedules)) then Exit; if not FIrriSchedules[Idx].WaitingManual then Exit; // 수동 승인: 스케쥴 시작 FIrriSchedules[Idx].WaitingManual := False; FIrriSchedules[Idx].IsRunning := True; FIrriSchedules[Idx].CurrentActionIdx := 0; FIrriSchedules[Idx].IsCoolingDown := False; FIrriSchedules[Idx].IsDelaying := True; FIrriSchedules[Idx].ActionStartTick := Now; SaveIrriSchedules; AddSystemLog('관수 스케쥴 수동 시작 승인: ' + FIrriSchedules[Idx].SchedName); TThread.Queue(nil, UpdateIrriDashboardUI); end;