SmartFarm/SOURCE/SmartFarmHMI/SmartFarmHMI_FMX/__history/UIrrigation_Impl.inc.~242~
2026-09-04 10:53:44 +09:00

1641 lines
65 KiB
Plaintext

{
============================================================
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<TAutoCondItem>);
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<TAutoCondItem>);
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<string>('NodeID', AConds[k].NodeID);
if JC.TryGetValue<Integer>('Op', OpInt) then
AConds[k].Op := TAutoCondOp(OpInt);
JC.TryGetValue<Double>('Value', AConds[k].Value);
if JC.TryGetValue<Integer>('FOp', FOpInt) then
AConds[k].FOp := TAutoCondFOp(FOpInt);
JC.TryGetValue<Double>('PerPulse', AConds[k].PerPulse);
{
// 강제 보정 로직 추가: 과거 잘못 저장된 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<TAutoCondItem>): 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('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<Boolean>('Enabled', FIrriSchedules[i].Enabled);
JSched.TryGetValue<string> ('Name', FIrriSchedules[i].SchedName);
JSched.TryGetValue<Integer>('IsAutoStart', FIrriSchedules[i].IsAutoStart);
JSched.TryGetValue<Word> ('StartHH', FIrriSchedules[i].StartHH);
JSched.TryGetValue<Word> ('StartMM', FIrriSchedules[i].StartMM);
JSched.TryGetValue<Integer>('RepeatDays', FIrriSchedules[i].RepeatDays);
JSched.TryGetValue<Integer>('LimitOnesTimeMin', FIrriSchedules[i].LimitOnesTimeMin);
if JSched.TryGetValue<string>('StartDate', StartDateStr) then
FIrriSchedules[i].StartDate := StrToDateDef(StartDateStr, Date);
if JSched.TryGetValue<string>('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<string> ('TargetNodeID', FIrriSchedules[i].Actions[j].TargetNodeID);
JAct.TryGetValue<Integer>('DelayMins', FIrriSchedules[i].Actions[j].DelayMins);
JAct.TryGetValue<Integer>('TimeoutMin', FIrriSchedules[i].Actions[j].TimeoutMin);
var ModeInt: Integer;
if JAct.TryGetValue<Integer>('StartCondMode', ModeInt) then FIrriSchedules[i].Actions[j].StartCondMode := TAutoCondMode(ModeInt) else FIrriSchedules[i].Actions[j].StartCondMode := acmAND;
if JAct.TryGetValue<Integer>('StopCondMode', ModeInt) then FIrriSchedules[i].Actions[j].StopCondMode := TAutoCondMode(ModeInt) else FIrriSchedules[i].Actions[j].StopCondMode := acmAND;
if JAct.TryGetValue<Integer>('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<TAutoCondItem>;
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.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<TAutoCondItem>; 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<TIrriAction>;
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;