{ ============================================================ UAutoControl_Impl.inc - 스마트팜 HMI 자동운전 메서드 구현 (FMX 버전) ============================================================ 이 파일은 UMain.pas의 implementation 섹션에 $I UAutoControl_Impl.inc 으로 포함됩니다. TfrmMain의 자동운전 관련 메서드가 모두 여기 정의됩니다. ============================================================ } // ─── 자동운전 모드 토글 ────────────────────────────────────────────────────── procedure TfrmMain.OnAutoModeToggle(Sender: TObject); begin FAutoMode := not FAutoMode; SaveAutoRules; if FAutoMode then begin LoadAutoRules; end else begin Glyph1.ImageIndex := -1; end; UpdateAutoModeBtnUI; if FAutoMode then begin TimerAuto.Interval := 1000; AddSystemLog('시스템 설정 변경: 자동 제어 모드 ON 설정됨'); end else begin AddSystemLog('시스템 설정 변경: 자동 제어 모드 OFF 설정됨'); end; TimerAuto.Enabled := FAutoMode; end; procedure TfrmMain.UpdateAutoModeBtnUI; begin if (pnlAutoModeBtn = nil) or (lblAutoModeStatus = nil) then Exit; if FAutoMode then begin pnlAutoModeBtn.Fill.Color := $FF4CAF50; // CLR_GREEN if FIsKorean then lblAutoModeStatus.Text := '🤖 자동 모드 ON' else lblAutoModeStatus.Text := '🤖 AUTO MODE ON'; lblAutoModeStatus.TextSettings.FontColor := claWhite; end else begin pnlAutoModeBtn.Fill.Color := $FF555555; if FIsKorean then lblAutoModeStatus.Text := '🖐 수동 모드' else lblAutoModeStatus.Text := '🖐 MANUAL MODE'; lblAutoModeStatus.TextSettings.FontColor := claLightgray; // CLR_GRAY end; end; // ─── 자동운전 규칙 UI 이벤트 ───────────────────────────────────────────────── procedure TfrmMain.OnAutoRuleAdd(Sender: TObject); var R: TAutoRule; begin R.Enabled := True; R.StartHH := 0; R.StartMM := 0; R.EndHH := 23; R.EndMM := 59; R.DOChannel := 1; R.DOState := True; R.CondMode := acmAND; SetLength(R.Conditions, 0); // 조건 없음으로 초기화 R.WasActive := False; R.IsSelected := False; R.UseSchedule := False; R.WorkMinutes := 0; R.RestMinutes := 0; if FIsKorean then R.RuleName := '새 규칙 ' + IntToStr(Length(FAutoRules)+1) else R.RuleName := 'Rule ' + IntToStr(Length(FAutoRules)+1); SetLength(FAutoRules, Length(FAutoRules)+1); FAutoRules[High(FAutoRules)] := R; SaveAutoRules; AddSystemLog('시스템 설정 변경: 새로운 자동 제어 규칙 추가됨 (' + R.RuleName + ')'); TThread.Queue(nil, procedure begin RefreshAutoUI; end); end; procedure TfrmMain.OnAutoRuleDel(Sender: TObject); var Idx, i: Integer; begin if Self.Focused <> nil then Self.Focused := nil; Idx := -1; if Sender is TRectangle then Idx := (Sender as TRectangle).Tag else if Sender is TLabel then Idx := (Sender as TLabel).Tag; if (Idx < 0) or (Idx > High(FAutoRules)) then Exit; for i := Idx to High(FAutoRules)-1 do FAutoRules[i] := FAutoRules[i+1]; SetLength(FAutoRules, Length(FAutoRules)-1); SaveAutoRules; AddSystemLog('시스템 설정 변경: 자동 제어 규칙 다일 삭제됨 (인덱스 ' + IntToStr(Idx) + ')'); TThread.Queue(nil, procedure begin RefreshAutoUI; end); end; procedure TfrmMain.OnAutoRuleCheck(Sender: TObject); var Idx: Integer; begin if Sender is TCheckBox then begin Idx := (Sender as TCheckBox).Tag; if (Idx >= 0) and (Idx <= High(FAutoRules)) then FAutoRules[Idx].IsSelected := (Sender as TCheckBox).IsChecked; end; end; procedure TfrmMain.OnAutoRuleDelSelected(Sender: TObject); var i, j: Integer; Changed: Boolean; begin if Self.Focused <> nil then Self.Focused := nil; Changed := False; // 역순으로 순회하며 선택된 항목 삭제 for i := High(FAutoRules) downto 0 do begin if FAutoRules[i].IsSelected then begin for j := i to High(FAutoRules) - 1 do FAutoRules[j] := FAutoRules[j+1]; SetLength(FAutoRules, Length(FAutoRules) - 1); Changed := True; end; end; if Changed then begin SaveAutoRules; AddSystemLog('시스템 설정 변경: 선택된 자동 제어 규칙 일괄 삭제됨'); TThread.Queue(nil, procedure begin RefreshAutoUI; end); end; end; procedure TfrmMain.OnAutoCondAdd(Sender: TObject); var RuleIdx: Integer; C: TAutoCondItem; begin RuleIdx := -1; if Sender is TRectangle then RuleIdx := (Sender as TRectangle).Tag else if Sender is TLabel then RuleIdx := (Sender as TLabel).Tag; if (RuleIdx < 0) or (RuleIdx > High(FAutoRules)) then Exit; C.NodeID := ''; C.Op := acoGT; C.Value := 0; SetLength(FAutoRules[RuleIdx].Conditions, Length(FAutoRules[RuleIdx].Conditions)+1); FAutoRules[RuleIdx].Conditions[High(FAutoRules[RuleIdx].Conditions)] := C; SaveAutoRules; TThread.Queue(nil, procedure begin RefreshAutoUI; end); end; procedure TfrmMain.OnAutoCondDel(Sender: TObject); var TagVal, RuleIdx, CondIdx, i: Integer; begin if Self.Focused <> nil then Self.Focused := nil; TagVal := -1; if Sender is TRectangle then TagVal := (Sender as TRectangle).Tag else if Sender is TLabel then TagVal := (Sender as TLabel).Tag; if TagVal = -1 then Exit; RuleIdx := TagVal and $FF; CondIdx := (TagVal shr 8) and $FF; if (RuleIdx < 0) or (RuleIdx > High(FAutoRules)) then Exit; if (CondIdx < 0) or (CondIdx > High(FAutoRules[RuleIdx].Conditions)) then Exit; for i := CondIdx to High(FAutoRules[RuleIdx].Conditions)-1 do FAutoRules[RuleIdx].Conditions[i] := FAutoRules[RuleIdx].Conditions[i+1]; SetLength(FAutoRules[RuleIdx].Conditions, Length(FAutoRules[RuleIdx].Conditions)-1); SaveAutoRules; TThread.Queue(nil, procedure begin RefreshAutoUI; end); end; procedure TfrmMain.OnAutoRuleEdit(Sender: TObject); var TagVal, RuleIdx, FieldID, CondIdx: Integer; S: string; begin if FRefreshingAutoUI then Exit; TagVal := 0; if Sender is TRectangle then TagVal := (Sender as TRectangle).Tag else if Sender is TSwitch then TagVal := (Sender as TSwitch).Tag else if Sender is TEdit then TagVal := (Sender as TEdit).Tag else if Sender is TComboBox then TagVal := (Sender as TComboBox).Tag else if Sender is TLabel then TagVal := (Sender as TLabel).Tag; RuleIdx := TagVal and $FF; FieldID := (TagVal shr 8) and $FF; CondIdx := (TagVal shr 16) and $FF; if (RuleIdx < 0) or (RuleIdx > High(FAutoRules)) then Exit; case FieldID of 0: if Sender is TSwitch then begin // Enabled FAutoRules[RuleIdx].Enabled := (Sender as TSwitch).IsChecked; SaveAutoRules; TThread.Queue(nil, procedure begin RefreshAutoUI; end); end; 1: if Sender is TEdit then FAutoRules[RuleIdx].StartHH := StrToIntDef((Sender as TEdit).Text, 0) mod 24; 2: if Sender is TEdit then FAutoRules[RuleIdx].StartMM := StrToIntDef((Sender as TEdit).Text, 0) mod 60; 3: if Sender is TEdit then FAutoRules[RuleIdx].EndHH := StrToIntDef((Sender as TEdit).Text, 0) mod 24; 4: if Sender is TEdit then FAutoRules[RuleIdx].EndMM := StrToIntDef((Sender as TEdit).Text, 0) mod 60; 5: if Sender is TComboBox then // DOChannel FAutoRules[RuleIdx].DOChannel := (Sender as TComboBox).ItemIndex; 6: if Sender is TSwitch then // DOState begin FAutoRules[RuleIdx].DOState := (Sender as TSwitch).IsChecked; SaveAutoRules; TThread.Queue(nil, procedure begin RefreshAutoUI; end); end; 10: if Sender is TEdit then // RuleName FAutoRules[RuleIdx].RuleName := (Sender as TEdit).Text; 11: if Sender is TComboBox then begin // CondMode if (Sender as TComboBox).ItemIndex = 0 then FAutoRules[RuleIdx].CondMode := acmAND else FAutoRules[RuleIdx].CondMode := acmOR; end; 12: if Sender is TSwitch then // UseSchedule begin FAutoRules[RuleIdx].UseSchedule := (Sender as TSwitch).IsChecked; SaveAutoRules; TThread.Queue(nil, procedure begin RefreshAutoUI; end); end; 13: if Sender is TEdit then // WorkMinutes FAutoRules[RuleIdx].WorkMinutes := StrToIntDef((Sender as TEdit).Text, 0); 14: if Sender is TEdit then // RestMinutes FAutoRules[RuleIdx].RestMinutes := StrToIntDef((Sender as TEdit).Text, 0); // ── 조건별 NodeID ── 20: if Sender is TComboBox then begin if (CondIdx >= 0) and (CondIdx <= High(FAutoRules[RuleIdx].Conditions)) then begin var bWasDIDO: Boolean := FAutoRules[RuleIdx].Conditions[CondIdx].NodeID.StartsWith('DI_') or FAutoRules[RuleIdx].Conditions[CondIdx].NodeID.StartsWith('DO_'); S := (Sender as TComboBox).Selected.Text; if S.StartsWith('--') then FAutoRules[RuleIdx].Conditions[CondIdx].NodeID := '' else FAutoRules[RuleIdx].Conditions[CondIdx].NodeID := S.Split([' '])[0]; var bNowDIDO: Boolean := FAutoRules[RuleIdx].Conditions[CondIdx].NodeID.StartsWith('DI_') or FAutoRules[RuleIdx].Conditions[CondIdx].NodeID.StartsWith('DO_'); // 노드 타입이 변경된 경우 기본 연산자 자동 설정 if bNowDIDO and not bWasDIDO then FAutoRules[RuleIdx].Conditions[CondIdx].Op := acoON // 수치→DI: ON으로 else if not bNowDIDO and bWasDIDO then FAutoRules[RuleIdx].Conditions[CondIdx].Op := acoGT; // DI→수치: > 로 // 노드 타입이 달라졌으면 UI 전체 새로고침 (Op 콤보박스 항목이 바뀌어야 함) if bWasDIDO <> bNowDIDO then begin SaveAutoRules; AddSystemLog('시스템 설정 변경: 자동 제어 규칙 [' + FAutoRules[RuleIdx].RuleName + '] 조건 센서타입 수정됨'); TThread.Queue(nil, procedure begin RefreshAutoUI; end); Exit; end; end; end; // ── 조건별 Op ── 21: if Sender is TComboBox then begin if (CondIdx >= 0) and (CondIdx <= High(FAutoRules[RuleIdx].Conditions)) then begin if FAutoRules[RuleIdx].Conditions[CondIdx].NodeID.StartsWith('DI_') then FAutoRules[RuleIdx].Conditions[CondIdx].Op := TAutoCondOp((Sender as TComboBox).ItemIndex + Ord(acoON)) else FAutoRules[RuleIdx].Conditions[CondIdx].Op := TAutoCondOp((Sender as TComboBox).ItemIndex); end; end; // ── 조건별 Value ── 22: if Sender is TEdit then begin if (CondIdx >= 0) and (CondIdx <= High(FAutoRules[RuleIdx].Conditions)) then TryStrToFloat((Sender as TEdit).Text, FAutoRules[RuleIdx].Conditions[CondIdx].Value); end; end; SaveAutoRules; AddSystemLog('시스템 설정 변경: 자동 제어 규칙 [' + FAutoRules[RuleIdx].RuleName + '] 속성 수정됨'); end; // ─── 자동운전 UI 빌드 ──────────────────────────────────────────────────────── procedure TfrmMain.BuildAutoUI; var AddBtnBar, DelBtnBar: TRectangle; begin RefreshAutoUI; // 하단 고정 버튼 바 구성 if btnAutoRuleBar <> nil then begin while btnAutoRuleBar.ControlsCount > 0 do btnAutoRuleBar.Controls[0].Free; AddBtnBar := TRectangle.Create(Self); AddBtnBar.Parent := btnAutoRuleBar; AddBtnBar.Align := TAlignLayout.Client; // AddBtnBar.Margins.Rect := RectF(16, 8, 8, 8); AddBtnBar.Margins.Rect := RectF(16, 8, 16, 0); AddBtnBar.Fill.Color := $FF226622; AddBtnBar.Stroke.Kind := TBrushKind.None; AddBtnBar.XRadius := 8; AddBtnBar.YRadius := 8; AddBtnBar.Cursor := crHandPoint; AddBtnBar.HitTest := True; AddBtnBar.OnClick := OnAutoRuleAdd; var LAd := BuildLabel(AddBtnBar, '+ ' + (if FIsKorean then '새 규칙 추가' else 'Add Rule'), 20, TAlignLayout.Client, claWhite, True); LAd.TextSettings.HorzAlign := TTextAlign.Center; LAd.HitTest := False; DelBtnBar := TRectangle.Create(Self); DelBtnBar.Parent := btnAutoRuleBar; DelBtnBar.Align := TAlignLayout.Right; DelBtnBar.Width := 600; // DelBtnBar.Margins.Rect := RectF(8, 8, 16, 8); DelBtnBar.Margins.Rect := RectF(0, 8, 16, 0); DelBtnBar.Fill.Color := $FFFF5252; DelBtnBar.Stroke.Kind := TBrushKind.None; DelBtnBar.XRadius := 8; DelBtnBar.YRadius := 8; DelBtnBar.Cursor := crHandPoint; DelBtnBar.HitTest := True; DelBtnBar.OnClick := OnAutoRuleDelSelected; var LDl := BuildLabel(DelBtnBar, '🗑 ' + (if FIsKorean then '선택 삭제' else 'Del Selected'), 20, TAlignLayout.Client, claWhite, True); LDl.TextSettings.HorzAlign := TTextAlign.Center; LDl.HitTest := False; end; end; procedure TfrmMain.RefreshAutoUI; const RULE_PAD = 16; RULE_PAD_ = 16 + 300; RULE_GAP = 16; COND_ROW_H = 38; BASE_H = 360; // 280 + 80 COND_BTN_H = 38; ANDOR_H = 34; var i, j, CY, CardH: Integer; CC: Integer; Card, AddCondBtn, DelCondBtn: TRectangle; LAdd, LAddCond, LDelCond: TLabel; SW_En, SW_DO: TSwitch; EdStartHH, EdStartMM, EdEndHH, EdEndMM, EdCondVal, EdName: TEdit; CbDO, CbCondOp, CbCondNode, CbCondMode: TComboBox; AddBtnPnl, DelSelBtn: TRectangle; TopMenuLyt: TLayout; ChkDel: TCheckBox; {$IFDEF ANDROID} ChkBg: TRectangle; {$ENDIF} bIsDIDO: Boolean; CondY: Integer; RowY: Integer; CLR_CARD: TAlphaColor; SelIdx: Integer; LIcon, LEnLbl, LTimeLbl, LDoLbl, LDoState, LCondLbl, LEmpty: TLabel; Line1, Line2: TRectangle; LItem: TListBoxItem; begin if scrollAuto = nil then Exit; FRefreshingAutoUI := True; try scrollAuto.BeginUpdate; // 1) 폼 리프레시 시 발생할 수 있는 내부 포커싱 및 이벤트 체인 오류 방지 if Self.Focused <> nil then Self.Focused := nil; // 2) MacOS/Linux 환경의 Native controls 충돌 방지를 위해 // 이벤트 연결을 해제하고 시각적 트리에서 분리한 후 안전하게 메모리를 해제합니다. for i := scrollAuto.Content.ControlsCount - 1 downto 0 do begin var C := scrollAuto.Content.Controls[i]; if C is TEdit then (C as TEdit).OnExit := nil; if C is TComboBox then (C as TComboBox).OnChange := nil; if C is TSwitch then (C as TSwitch).OnSwitch := nil; if C is TCheckBox then (C as TCheckBox).OnChange := nil; C.Parent := nil; // FMX ScrollBox 오작동 및 Native 객체 Crash 방지 // C.DisposeOf; end; CY := RULE_GAP; // 규칙이 없을 때 if Length(FAutoRules) = 0 then begin LEmpty := BuildLabel(scrollAuto, if FIsKorean then '등록된 자동 제어 규칙이 없습니다.' else 'No auto control rules registered.', 18, TAlignLayout.Top, claLightgray, False); LEmpty.Margins.Top := 20; LEmpty.TextSettings.HorzAlign := TTextAlign.Center; Exit; end; // 각 규칙 카드 for i := 0 to High(FAutoRules) do begin CC := Length(FAutoRules[i].Conditions); CardH := BASE_H + CC * (COND_ROW_H + 4) + COND_BTN_H; if CC >= 2 then Inc(CardH, ANDOR_H + 4); if FAutoRules[i].Enabled then CLR_CARD := $FF1E293B else CLR_CARD := $FF333333; Card := TRectangle.Create(Self); Card.Parent := scrollAuto; Card.Align := TAlignLayout.Top; Card.Height := CardH; Card.Margins.Rect := RectF(RULE_GAP, RULE_GAP, RULE_GAP, 0); Card.Fill.Color := CLR_CARD; Card.Stroke.Kind := TBrushKind.None; Card.XRadius := 12; Card.YRadius := 12; Card.Tag := i; // ── 카드 헤더 줄: 체크박스 + 아이콘 + 이름 + 활성토글 ── {$IFDEF ANDROID} // 안드로이드에서 체크박스가 어두운 배경 위에서 잘 안보이므로 // 체크박스 뒤에 밝은 흰색 배경을 깔아줍니다. ChkBg := TRectangle.Create(Self); ChkBg.Parent := Card; ChkBg.Position.X := RULE_PAD - 4; ChkBg.Position.Y := 13; 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 := RULE_PAD-6; ChkDel.Position.Y := 10; ChkDel.Width := 24; ChkDel.Height := 24; ChkDel.IsChecked := FAutoRules[i].IsSelected; ChkDel.Tag := i; ChkDel.Scale.X := 1.5; ChkDel.Scale.Y := 1.5; ChkDel.OnChange := OnAutoRuleCheck; LIcon := BuildLabel(Card, '⚙️', 20, TAlignLayout.None, claWhite, False); LIcon.SetBounds(RULE_PAD + 34, 10, 36, 36); EdName := TEdit.Create(Self); EdName.Parent := Card; EdName.Position.X := RULE_PAD + 70; EdName.Position.Y := 10; EdName.Width := 300; EdName.Height := 36; EdName.TextSettings.Font.Size := 18; EdName.Text := FAutoRules[i].RuleName; EdName.StyledSettings := EdName.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; {$IFDEF ANDROID} EdName.TextSettings.FontColor := claWhite; {$ENDIF} EdName.Tag := i or (10 shl 8); EdName.OnExit := OnAutoRuleEdit; LEnLbl := BuildLabel(Card, if FIsKorean then '활성' else 'Enable', 16, TAlignLayout.None, claLightgray, False); LEnLbl.Position.X := Card.Width - RULE_PAD - 130; LEnLbl.Position.Y := 16; LEnLbl.Width := 50; LEnLbl.Height := 24; LEnLbl.Anchors := [TAnchorKind.akTop, TAnchorKind.akRight]; SW_En := TSwitch.Create(Self); SW_En.Parent := Card; SW_En.Position.X := Card.Width - RULE_PAD - 70; SW_En.Position.Y := 10; SW_En.Width := 70; {$IFDEF ANDROID} SW_En.Scale.X := 1.5; SW_En.Scale.Y := 1.5; {$ENDIF} SW_En.IsChecked := FAutoRules[i].Enabled; SW_En.Tag := i or (0 shl 8); SW_En.OnSwitch := OnAutoRuleEdit; SW_En.Anchors := [TAnchorKind.akTop, TAnchorKind.akRight]; // ── 구분선 ── Line1 := TRectangle.Create(Self); Line1.Parent := Card; Line1.Position.X := RULE_PAD; Line1.Position.Y := 56; Line1.Width := Card.Width - RULE_PAD*2; Line1.Height := 1; Line1.Fill.Color := $FF555555; Line1.Stroke.Kind := TBrushKind.None; Line1.Anchors := [TAnchorKind.akTop, TAnchorKind.akLeft, TAnchorKind.akRight]; // ── 동작 시간 설정 ── LTimeLbl := BuildLabel(Card, '⏰ ' + (if FIsKorean then '동작 시간' else 'Active Time'), 15, TAlignLayout.None, claLightgray, True); LTimeLbl.SetBounds(RULE_PAD, 66, 160, 24); BuildLabel(Card, if FIsKorean then '시작' else 'Start', 13, TAlignLayout.None, claLightgray, False).SetBounds(RULE_PAD_, 96, 50, 20); EdStartHH := TEdit.Create(Self); EdStartHH.Parent := Card; EdStartHH.Position.X := RULE_PAD_ + 50; EdStartHH.Position.Y := 92; EdStartHH.Width := 50; EdStartHH.Height := 32; EdStartHH.TextSettings.Font.Size := 18; EdStartHH.MaxLength := 2; EdStartHH.Text := Format('%.2d', [FAutoRules[i].StartHH]); EdStartHH.StyledSettings := EdStartHH.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdStartHH.FilterChar := '0123456789'; {$IFDEF ANDROID} EdStartHH.TextSettings.FontColor := claWhite; {$ENDIF} EdStartHH.Tag := i or (1 shl 8); EdStartHH.OnExit := OnAutoRuleEdit; BuildLabel(Card, ':', 18, TAlignLayout.None, claWhite, True).SetBounds(RULE_PAD_ + 104, 96, 12, 24); EdStartMM := TEdit.Create(Self); EdStartMM.Parent := Card; EdStartMM.Position.X := RULE_PAD_ + 118; EdStartMM.Position.Y := 92; EdStartMM.Width := 50; EdStartMM.Height := 32; EdStartMM.TextSettings.Font.Size := 18; EdStartMM.MaxLength := 2; EdStartMM.Text := Format('%.2d', [FAutoRules[i].StartMM]); EdStartMM.StyledSettings := EdStartMM.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdStartMM.FilterChar := '0123456789'; {$IFDEF ANDROID} EdStartMM.TextSettings.FontColor := claWhite; {$ENDIF} EdStartMM.Tag := i or (2 shl 8); EdStartMM.OnExit := OnAutoRuleEdit; BuildLabel(Card, if FIsKorean then '종료' else 'End', 13, TAlignLayout.None, claLightgray, False).SetBounds(RULE_PAD_ + 200, 96, 50, 20); EdEndHH := TEdit.Create(Self); EdEndHH.Parent := Card; EdEndHH.Position.X := RULE_PAD_ + 250; EdEndHH.Position.Y := 92; EdEndHH.Width := 50; EdEndHH.Height := 32; EdEndHH.TextSettings.Font.Size := 18; EdEndHH.MaxLength := 2; EdEndHH.Text := Format('%.2d', [FAutoRules[i].EndHH]); EdEndHH.StyledSettings := EdEndHH.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdEndHH.FilterChar := '0123456789'; {$IFDEF ANDROID} EdEndHH.TextSettings.FontColor := claWhite; {$ENDIF} EdEndHH.Tag := i or (3 shl 8); EdEndHH.OnExit := OnAutoRuleEdit; BuildLabel(Card, ':', 18, TAlignLayout.None, claWhite, True).SetBounds(RULE_PAD_ + 304, 96, 12, 24); EdEndMM := TEdit.Create(Self); EdEndMM.Parent := Card; EdEndMM.Position.X := RULE_PAD_ + 318; EdEndMM.Position.Y := 92; EdEndMM.Width := 50; EdEndMM.Height := 32; EdEndMM.TextSettings.Font.Size := 18; EdEndMM.MaxLength := 2; EdEndMM.Text := Format('%.2d', [FAutoRules[i].EndMM]); EdEndMM.StyledSettings := EdEndMM.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdEndMM.FilterChar := '0123456789'; {$IFDEF ANDROID} EdEndMM.TextSettings.FontColor := claWhite; {$ENDIF} EdEndMM.Tag := i or (4 shl 8); EdEndMM.OnExit := OnAutoRuleEdit; // ── 스케줄 반복 작동 ── Line2 := TRectangle.Create(Self); Line2.Parent := Card; Line2.Position.X := RULE_PAD; Line2.Position.Y := 134; Line2.Width := Card.Width - RULE_PAD*2; Line2.Height := 1; Line2.Fill.Color := $FF555555; Line2.Stroke.Kind := TBrushKind.None; Line2.Anchors := [TAnchorKind.akTop, TAnchorKind.akLeft, TAnchorKind.akRight]; BuildLabel(Card, '🔄 ' + (if FIsKorean then '스케줄 (반복 작동)' else 'Schedule (Repeat)'), 15, TAlignLayout.None, claLightgray, True).SetBounds(RULE_PAD, 142, 220, 24); BuildLabel(Card, if FIsKorean then '사용' else 'Use', 13, TAlignLayout.None, claLightgray, False).SetBounds(RULE_PAD_, 172, 40, 20); var SW_Sched := TSwitch.Create(Self); SW_Sched.Parent := Card; SW_Sched.Position.X := RULE_PAD_ + 40; SW_Sched.Position.Y := 166; SW_Sched.Width := 70; {$IFDEF ANDROID} SW_Sched.Scale.X := 1.5; SW_Sched.Scale.Y := 1.5; {$ENDIF} SW_Sched.IsChecked := FAutoRules[i].UseSchedule; SW_Sched.Tag := i or (12 shl 8); SW_Sched.OnSwitch := OnAutoRuleEdit; BuildLabel(Card, if FIsKorean then '작동(분)' else 'Work(m)', 13, TAlignLayout.None, claLightgray, False).SetBounds(RULE_PAD_ + 120, 172, 60, 20); var EdWork := TEdit.Create(Self); EdWork.Parent := Card; EdWork.Position.X := RULE_PAD_ + 180; EdWork.Position.Y := 168; EdWork.Width := 50; EdWork.Height := 30; EdWork.TextSettings.Font.Size := 18; EdWork.Text := IntToStr(FAutoRules[i].WorkMinutes); EdWork.StyledSettings := EdWork.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdWork.FilterChar := '0123456789'; {$IFDEF ANDROID} EdWork.TextSettings.FontColor := claWhite; {$ENDIF} EdWork.Tag := i or (13 shl 8); EdWork.OnExit := OnAutoRuleEdit; BuildLabel(Card, if FIsKorean then '휴식(분)' else 'Rest(m)', 13, TAlignLayout.None, claLightgray, False).SetBounds(RULE_PAD_ + 240, 172, 60, 20); var EdRest := TEdit.Create(Self); EdRest.Parent := Card; EdRest.Position.X := RULE_PAD_ + 300; EdRest.Position.Y := 168; EdRest.Width := 50; EdRest.Height := 30; EdRest.TextSettings.Font.Size := 18; EdRest.Text := IntToStr(FAutoRules[i].RestMinutes); EdRest.StyledSettings := EdRest.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdRest.FilterChar := '0123456789'; {$IFDEF ANDROID} EdRest.TextSettings.FontColor := claWhite; {$ENDIF} EdRest.Tag := i or (14 shl 8); EdRest.OnExit := OnAutoRuleEdit; // ── DO 출력 설정 ── var Line3 := TRectangle.Create(Self); Line3.Parent := Card; Line3.Position.X := RULE_PAD; Line3.Position.Y := 214; Line3.Width := Card.Width - RULE_PAD*2; Line3.Height := 1; Line3.Fill.Color := $FF555555; Line3.Stroke.Kind := TBrushKind.None; Line3.Anchors := [TAnchorKind.akTop, TAnchorKind.akLeft, TAnchorKind.akRight]; LDoLbl := BuildLabel(Card, '🎛️ ' + (if FIsKorean then '출력 (DO) 설정' else 'Output (DO) Setting'), 15, TAlignLayout.None, claLightgray, True); LDoLbl.SetBounds(RULE_PAD, 222, 220, 24); BuildLabel(Card, if FIsKorean then '채널' else 'Channel', 13, TAlignLayout.None, claLightgray, False).SetBounds(RULE_PAD_, 252, 60, 20); CbDO := TComboBox.Create(Self); CbDO.Parent := Card; CbDO.Position.X := RULE_PAD_ + 64; CbDO.Position.Y := 248; CbDO.Width := 100; CbDO.Height := 30; for j := 0 to 15 do begin LItem := TListBoxItem.Create(CbDO); LItem.Parent := CbDO; LItem.Text := 'DO_' + Format('%.2d', [j]); LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 18; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} CbDO.AddObject(LItem); end; CbDO.ItemIndex := FAutoRules[i].DOChannel; CbDO.Tag := i or (5 shl 8); CbDO.OnChange := OnAutoRuleEdit; BuildLabel(Card, if FIsKorean then '상태' else 'State', 13, TAlignLayout.None, claLightgray, False).SetBounds(RULE_PAD_ + 200, 252, 50, 20); SW_DO := TSwitch.Create(Self); SW_DO.Parent := Card; SW_DO.Position.X := RULE_PAD_ + 254; SW_DO.Position.Y := 246; SW_DO.Width := 70; {$IFDEF ANDROID} SW_DO.Scale.X := 1.5; SW_DO.Scale.Y := 1.5; {$ENDIF} SW_DO.IsChecked := FAutoRules[i].DOState; SW_DO.Tag := i or (6 shl 8); SW_DO.OnSwitch := OnAutoRuleEdit; LDoState := BuildLabel(Card, if FAutoRules[i].DOState then 'ON' else 'OFF', 14, TAlignLayout.None, if FAutoRules[i].DOState then $FF4CAF50 else $FFFF5252, True); LDoState.SetBounds(RULE_PAD_ + 336, 252, 40, 24); // ── 추가 조건 섹션 ────────────────────────────────────────────────────────── var Line4 := TRectangle.Create(Self); Line4.Parent := Card; Line4.Position.X := RULE_PAD; Line4.Position.Y := 290; Line4.Width := Card.Width - RULE_PAD*2; Line4.Height := 1; Line4.Fill.Color := $FF555555; Line4.Stroke.Kind := TBrushKind.None; Line4.Anchors := [TAnchorKind.akTop, TAnchorKind.akLeft, TAnchorKind.akRight]; LCondLbl := BuildLabel(Card, '📊 ' + (if FIsKorean then '추가 조건 (선택사항)' else 'Additional Conditions (optional)'), 15, TAlignLayout.None, claLightgray, True); LCondLbl.SetBounds(RULE_PAD, 298, 340, 24); CondY := 328; if CC >= 2 then begin BuildLabel(Card, if FIsKorean then '조건 결합' else 'Combine', 13, TAlignLayout.None, claLightgray, False).SetBounds(RULE_PAD_, CondY + 6, 80, 20); CbCondMode := TComboBox.Create(Self); CbCondMode.Parent := Card; CbCondMode.Position.X := RULE_PAD_ + 84; CbCondMode.Position.Y := CondY; CbCondMode.Width := 200; CbCondMode.Height := 30; for j := 0 to 1 do begin LItem := TListBoxItem.Create(CbCondMode); LItem.Parent := CbCondMode; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 18; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} if j=0 then LItem.Text := if FIsKorean then 'AND (모두 만족)' else 'AND (all match)' else LItem.Text := if FIsKorean then 'OR (하나 만족)' else 'OR (any match)'; CbCondMode.AddObject(LItem); end; CbCondMode.ItemIndex := Ord(FAutoRules[i].CondMode); CbCondMode.Tag := i or (11 shl 8); CbCondMode.OnChange := OnAutoRuleEdit; Inc(CondY, ANDOR_H + 4); end; for j := 0 to CC - 1 do begin RowY := CondY + j * (COND_ROW_H + 4); BuildLabel(Card, IntToStr(j+1) + '.', 12, TAlignLayout.None, claLightgray, False).SetBounds(RULE_PAD_, RowY + 8, 22, 22); CbCondNode := TComboBox.Create(Self); CbCondNode.Parent := Card; CbCondNode.Position.X := RULE_PAD_ + 26; CbCondNode.Position.Y := RowY; CbCondNode.Width := 250; CbCondNode.Height := 30; LItem := TListBoxItem.Create(CbCondNode); LItem.Parent := CbCondNode; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 18; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} LItem.Text := '--(' + (if FIsKorean then '노드 선택' else 'Select Node') + ')'; CbCondNode.AddObject(LItem); var k: Integer; for k := 0 to High(FNodeConfigs) do begin LItem := TListBoxItem.Create(CbCondNode); LItem.Parent := CbCondNode; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 18; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} LItem.Text := FNodeConfigs[k].ID + ' [' + ( if FNodeConfigs[k].CustomName <> '' then FNodeConfigs[k].CustomName else if FIsKorean then FNodeConfigs[k].TitleKO else FNodeConfigs[k].TitleEN) + ']'; CbCondNode.AddObject(LItem); end; SelIdx := 0; for k := 0 to CbCondNode.Items.Count-1 do if CbCondNode.Items[k].StartsWith(FAutoRules[i].Conditions[j].NodeID + ' ') or (CbCondNode.Items[k] = FAutoRules[i].Conditions[j].NodeID) then begin SelIdx := k; Break; end; CbCondNode.ItemIndex := SelIdx; CbCondNode.Tag := i or (20 shl 8) or (j shl 16); CbCondNode.OnChange := OnAutoRuleEdit; bIsDIDO := FAutoRules[i].Conditions[j].NodeID.StartsWith('DI_') or FAutoRules[i].Conditions[j].NodeID.StartsWith('DO_'); CbCondOp := TComboBox.Create(Self); CbCondOp.Parent := Card; CbCondOp.Position.X := RULE_PAD_ + 284; CbCondOp.Position.Y := RowY; CbCondOp.Width := 180; CbCondOp.Height := 30; if bIsDIDO then begin for k := 0 to 1 do begin LItem := TListBoxItem.Create(CbCondOp); LItem.Parent := CbCondOp; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 18; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} if k=0 then LItem.Text := if FIsKorean then 'ON (접점닫힘)' else 'ON (Closed)' else LItem.Text := if FIsKorean then 'OFF (접점열림)' else 'OFF (Open)'; CbCondOp.AddObject(LItem); end; if FAutoRules[i].Conditions[j].Op >= acoON then CbCondOp.ItemIndex := Ord(FAutoRules[i].Conditions[j].Op) - Ord(acoON) else CbCondOp.ItemIndex := 0; end else begin for k := 0 to 2 do begin LItem := TListBoxItem.Create(CbCondOp); LItem.Parent := CbCondOp; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 18; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} if k=0 then LItem.Text := '> (' + (if FIsKorean then '초과' else 'Greater') + ')' else if k=1 then LItem.Text := '< (' + (if FIsKorean then '미만' else 'Less') + ')' else LItem.Text := '= (' + (if FIsKorean then '같음' else 'Equal') + ')'; CbCondOp.AddObject(LItem); end; if FAutoRules[i].Conditions[j].Op <= acoEQ then CbCondOp.ItemIndex := Ord(FAutoRules[i].Conditions[j].Op) else CbCondOp.ItemIndex := 0; end; CbCondOp.Tag := i or (21 shl 8) or (j shl 16); CbCondOp.OnChange := OnAutoRuleEdit; EdCondVal := TEdit.Create(Self); EdCondVal.Parent := Card; EdCondVal.Position.X := RULE_PAD_ + 472; EdCondVal.Position.Y := RowY; EdCondVal.Width := 76; EdCondVal.Height := 30; EdCondVal.TextSettings.Font.Size := 18; EdCondVal.Enabled := not bIsDIDO; EdCondVal.Text := if bIsDIDO then '--' else Format('%.1f', [FAutoRules[i].Conditions[j].Value]); EdCondVal.StyledSettings := EdCondVal.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdCondVal.FilterChar := '0123456789.'; {$IFDEF ANDROID} EdCondVal.TextSettings.FontColor := claWhite; {$ENDIF} EdCondVal.Tag := i or (22 shl 8) or (j shl 16); EdCondVal.OnExit := OnAutoRuleEdit; DelCondBtn := TRectangle.Create(Self); DelCondBtn.Parent := Card; DelCondBtn.Position.X := RULE_PAD_ + 554; DelCondBtn.Position.Y := RowY; DelCondBtn.Width := 80; DelCondBtn.Height := 30; DelCondBtn.Fill.Color := $FFFF5252; DelCondBtn.Stroke.Kind := TBrushKind.None; DelCondBtn.XRadius := 4; DelCondBtn.YRadius := 4; DelCondBtn.Cursor := crHandPoint; DelCondBtn.Tag := i or (j shl 8); DelCondBtn.HitTest := True; DelCondBtn.OnClick := OnAutoCondDel; LDelCond := BuildLabel(DelCondBtn, '✕', 13, TAlignLayout.Client, claWhite, True); LDelCond.TextSettings.HorzAlign := TTextAlign.Center; LDelCond.HitTest := False; end; var AddCondY: Integer := CondY + CC * (COND_ROW_H + 4) + 4; AddCondBtn := TRectangle.Create(Self); AddCondBtn.Parent := Card; AddCondBtn.Position.X := RULE_PAD; AddCondBtn.Position.Y := AddCondY; AddCondBtn.Width := Card.Width - RULE_PAD * 2; AddCondBtn.Height := COND_BTN_H - 4 + 20; AddCondBtn.Fill.Color := $FF2F5E2F; AddCondBtn.Stroke.Kind := TBrushKind.None; AddCondBtn.XRadius := 4; AddCondBtn.YRadius := 4; AddCondBtn.Cursor := crHandPoint; AddCondBtn.Tag := i; AddCondBtn.HitTest := True; AddCondBtn.OnClick := OnAutoCondAdd; AddCondBtn.Anchors := [TAnchorKind.akTop, TAnchorKind.akLeft, TAnchorKind.akRight]; LAddCond := BuildLabel(AddCondBtn, '+ ' + (if FIsKorean then '조건 추가' else 'Add Condition'), 16, TAlignLayout.Client, claWhite, True); LAddCond.TextSettings.HorzAlign := TTextAlign.Center; LAddCond.HitTest := False; end; finally scrollAuto.EndUpdate; FRefreshingAutoUI := False; end; end; // ─── INI 저장/불러오기 ──────────────────────────────────────────────────────── procedure TfrmMain.LoadAutoRules; var Ini: TIniFile; SP: string; N, CC, i, c: Integer; begin SP := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'SmartFarmHMI_settings.ini'); Ini := TIniFile.Create(SP); try N := Ini.ReadInteger('AutoRules', 'Count', 0); SetLength(FAutoRules, N); FAutoMode := Ini.ReadBool('AutoRules', 'AutoMode', False); for i := 0 to N-1 do begin FAutoRules[i].Enabled := Ini.ReadBool ('AutoRules', 'R'+IntToStr(i)+'_En', True); FAutoRules[i].StartHH := Ini.ReadInteger ('AutoRules', 'R'+IntToStr(i)+'_SHH', 0); FAutoRules[i].StartMM := Ini.ReadInteger ('AutoRules', 'R'+IntToStr(i)+'_SMM', 0); FAutoRules[i].EndHH := Ini.ReadInteger ('AutoRules', 'R'+IntToStr(i)+'_EHH', 23); FAutoRules[i].EndMM := Ini.ReadInteger ('AutoRules', 'R'+IntToStr(i)+'_EMM', 59); FAutoRules[i].DOChannel := Ini.ReadInteger ('AutoRules', 'R'+IntToStr(i)+'_DOC', 0); FAutoRules[i].DOState := Ini.ReadBool ('AutoRules', 'R'+IntToStr(i)+'_DOS', True); FAutoRules[i].CondMode := TAutoCondMode(Ini.ReadInteger('AutoRules', 'R'+IntToStr(i)+'_CondMode', 0)); FAutoRules[i].RuleName := Ini.ReadString ('AutoRules', 'R'+IntToStr(i)+'_Name', if FIsKorean then '규칙 '+IntToStr(i+1) else 'Rule '+IntToStr(i+1)); FAutoRules[i].UseSchedule:= Ini.ReadBool ('AutoRules', 'R'+IntToStr(i)+'_UseSched', False); FAutoRules[i].WorkMinutes:= Ini.ReadInteger ('AutoRules', 'R'+IntToStr(i)+'_WorkM', 0); FAutoRules[i].RestMinutes:= Ini.ReadInteger ('AutoRules', 'R'+IntToStr(i)+'_RestM', 0); FAutoRules[i].WasActive := False; CC := Ini.ReadInteger('AutoRules', 'R'+IntToStr(i)+'_CC', 0); SetLength(FAutoRules[i].Conditions, CC); for c := 0 to CC-1 do begin FAutoRules[i].Conditions[c].NodeID := Ini.ReadString ('AutoRules', 'R'+IntToStr(i)+'_C'+IntToStr(c)+'_NID', ''); FAutoRules[i].Conditions[c].Op := TAutoCondOp(Ini.ReadInteger('AutoRules', 'R'+IntToStr(i)+'_C'+IntToStr(c)+'_COP', 0)); FAutoRules[i].Conditions[c].Value := Ini.ReadFloat ('AutoRules', 'R'+IntToStr(i)+'_C'+IntToStr(c)+'_CV', 0); end; end; finally Ini.Free; end; end; procedure TfrmMain.SaveAutoRules; var Ini: TIniFile; SP: string; i, c: Integer; begin SP := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'SmartFarmHMI_settings.ini'); Ini := TIniFile.Create(SP); try Ini.EraseSection('AutoRules'); Ini.WriteInteger('AutoRules', 'Count', Length(FAutoRules)); Ini.WriteBool ('AutoRules', 'AutoMode', FAutoMode); for i := 0 to High(FAutoRules) do begin Ini.WriteBool ('AutoRules', 'R'+IntToStr(i)+'_En', FAutoRules[i].Enabled); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_SHH', FAutoRules[i].StartHH); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_SMM', FAutoRules[i].StartMM); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_EHH', FAutoRules[i].EndHH); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_EMM', FAutoRules[i].EndMM); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_DOC', FAutoRules[i].DOChannel); Ini.WriteBool ('AutoRules', 'R'+IntToStr(i)+'_DOS', FAutoRules[i].DOState); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_CondMode', Ord(FAutoRules[i].CondMode)); Ini.WriteString ('AutoRules', 'R'+IntToStr(i)+'_Name', FAutoRules[i].RuleName); Ini.WriteBool ('AutoRules', 'R'+IntToStr(i)+'_UseSched', FAutoRules[i].UseSchedule); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_WorkM', FAutoRules[i].WorkMinutes); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_RestM', FAutoRules[i].RestMinutes); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_CC', Length(FAutoRules[i].Conditions)); for c := 0 to High(FAutoRules[i].Conditions) do begin Ini.WriteString ('AutoRules', 'R'+IntToStr(i)+'_C'+IntToStr(c)+'_NID', FAutoRules[i].Conditions[c].NodeID); Ini.WriteInteger('AutoRules', 'R'+IntToStr(i)+'_C'+IntToStr(c)+'_COP', Ord(FAutoRules[i].Conditions[c].Op)); Ini.WriteFloat ('AutoRules', 'R'+IntToStr(i)+'_C'+IntToStr(c)+'_CV', FAutoRules[i].Conditions[c].Value); end; end; finally Ini.Free; end; end; // ─── 자동운전 규칙 평가 ─────────────────────────────────────────────────────── function TfrmMain.GetCurrentSensorValue(const ANodeID: string): Double; var i: Integer; begin Result := 0; for i := 0 to High(FNodeConfigs) do if FNodeConfigs[i].ID = ANodeID then begin Result := FSensorValues[i]; Exit; end; end; function TfrmMain.EvalOneCondition(const ACond: TAutoCondItem): Boolean; var SVal: Double; begin if ACond.NodeID = '' then begin Result := True; Exit; end; if ACond.PerPulse > 0.0 then begin SVal := GetCurrentSensorValue(ACond.NodeID) * ACond.PerPulse; // 실시간 노드 값 (펄스일 경우 1회당 누적 펄스값) * 설정된 펄스당 리터 / Kg end else begin SVal := GetCurrentSensorValue(ACond.NodeID); // 실시간 노드 값 (펄스일 경우 1회당 누적 펄스값) end; case ACond.Op of acoGT: Result := SVal > ACond.Value; acoLT: Result := SVal < ACond.Value; acoEQ: Result := Abs(SVal - ACond.Value) < 0.001; acoON: Result := SVal > 0; // 접점 닫힘 ( 1 (HIGH) 입력 ) acoOFF: Result := SVal <= 0; // 접점 열림 ( 0 (LOW) 입력 ) else Result := True; end; end; function TfrmMain.GetCurrentSensorValueTotalFlow(const ANodeID: string): Double; var i: Integer; begin Result := 0; for i := 0 to High(FNodeConfigs) do if FNodeConfigs[i].ID = ANodeID then begin Result := FSensorValuesTotalFlow[i]; Exit; end; end; function TfrmMain.EvalOneConditionTotalFlow(const ACond: TAutoCondItem): Boolean; var SVal: Double; n: integer; strN: string; 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; begin if ACond.NodeID = '' then begin Result := True; Exit; end; SVal := GetCurrentSensorValue(ACond.NodeID); if ACond.NodeID.StartsWith('DI_') then begin n := GetNodeIndex(ACond.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 if ACond.PerPulse > 0.0 then begin SVal := GetCurrentSensorValueTotalFlow(ACond.NodeID) * ACond.PerPulse; // 실시간 노드 값 (펄스일 경우 구역 누적 펄스값) * 설정된 펄스당 리터 / Kg end else begin SVal := GetCurrentSensorValueTotalFlow(ACond.NodeID); // 실시간 노드 값 (펄스일 경우 구역 누적 펄스값) end; end; end; end; case ACond.Op of acoGT: Result := SVal > ACond.Value; acoLT: Result := SVal < ACond.Value; acoEQ: Result := Abs(SVal - ACond.Value) < 0.001; acoON: Result := SVal > 0; // 접점 닫힘 ( 1 (HIGH) 입력 ) acoOFF: Result := SVal <= 0; // 접점 열림 ( 0 (LOW) 입력 ) else Result := True; end; end; function TfrmMain.EvalAutoRule(var ARule: TAutoRule): Boolean; var NowH, NowM: Word; StartMin, EndMin, NowMin: Integer; c: Integer; CondResult: Boolean; CycleTime, ElapsedMins: Integer; begin Result := False; if not ARule.Enabled then Exit; NowH := HourOf(Now); NowM := MinuteOf(Now); StartMin := ARule.StartHH * 60 + ARule.StartMM; EndMin := ARule.EndHH * 60 + ARule.EndMM; NowMin := NowH * 60 + NowM; if EndMin >= StartMin then Result := (NowMin >= StartMin) and (NowMin < EndMin) else Result := (NowMin >= StartMin) or (NowMin < EndMin); if not Result then Exit; if Length(ARule.Conditions) > 0 then begin if ARule.CondMode = acmAND then begin CondResult := True; for c := 0 to High(ARule.Conditions) do if not EvalOneCondition(ARule.Conditions[c]) then begin CondResult := False; Break; end; end else begin CondResult := False; for c := 0 to High(ARule.Conditions) do if EvalOneCondition(ARule.Conditions[c]) then begin CondResult := True; Break; end; end; Result := CondResult; end; if not Result then Exit; // 스케줄 (반복 작동) 확인 if ARule.UseSchedule and (ARule.WorkMinutes > 0) and (ARule.RestMinutes >= 0) then begin CycleTime := ARule.WorkMinutes + ARule.RestMinutes; if CycleTime > 0 then begin if NowMin >= StartMin then ElapsedMins := NowMin - StartMin else ElapsedMins := (NowMin + 1440) - StartMin; if (ElapsedMins mod CycleTime) >= ARule.WorkMinutes then Result := False; end; end; end; procedure TfrmMain.CheckAutoRules; var i: Integer; Active: Boolean; begin for i := 0 to High(FAutoRules) do begin Active := EvalAutoRule(FAutoRules[i]); if Active <> FAutoRules[i].WasActive then begin FAutoRules[i].WasActive := Active; if Active then PublishControl(FAutoRules[i].DOChannel, FAutoRules[i].DOState) else PublishControl(FAutoRules[i].DOChannel, not FAutoRules[i].DOState); // Update UI toggle switch manually after auto publish if FControlPending[FAutoRules[i].DOChannel] = False then begin FControlPending[FAutoRules[i].DOChannel] := True; if Active then FControlDesired[FAutoRules[i].DOChannel] := FAutoRules[i].DOState else FControlDesired[FAutoRules[i].DOChannel] := not FAutoRules[i].DOState; end; end; end; end; // ─── 자동운전 타이머 ───────────────────────────────────────────────────────── procedure TfrmMain.TimerAutoTimer(Sender: TObject); begin TimerAuto.Enabled := False; try if FAutoMode then begin Glyph1.ImageIndex := (Glyph1.ImageIndex + 1) mod 4; CheckAutoRules; CheckIrrigationSchedules; end; // if TimerAuto.Interval = 1000 then TimerAuto.Interval := 3000; finally TimerAuto.Enabled := True; end; end;