unit UMain; interface uses System.SysUtils, System.Types, System.UITypes, System.UIConsts, System.Classes, System.Variants, System.Math, System.IniFiles, System.IOUtils, System.JSON, System.DateUtils, FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs, FMX.Ani, FMX.Objects, FMX.Layouts, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Effects, FMX.Edit, FMX.ListBox, FMX.Media, FMX.Pickers, {$IFDEF MSWINDOWS} Winapi.Windows, Winapi.ShellAPI, {$ENDIF} {$IFDEF ANDROID} Androidapi.JNI.GraphicsContentViewText, Androidapi.JNI.Net, Androidapi.JNI.JavaTypes, Androidapi.Helpers, Androidapi.JNIBridge, {$ENDIF} {$IFDEF MACOS} Macapi.AppKit, Macapi.Foundation, Macapi.ObjectiveC, Macapi.CocoaTypes, Posix.Stdlib, {$ENDIF} {$IFDEF LINUX} Posix.Stdlib, {$ENDIF} UMQTTClient, UAutoControl, FMX.ExtCtrls, System.ImageList, FMX.ImgList, FMX.Memo.Types, FMX.ScrollBox, FMX.Memo, FMX.DateTimeCtrls, FMX.EditBox; type TNodeConfig = record ID: string; // e.g. 'TM_00' NodeType: string; // e.g. 'TM' Index: Integer; // e.g. 0 TitleKO: string; TitleEN: string; CustomName: string; InUse: Boolean; SettingRect: TRectangle; SettingSwitch: TSwitch; SettingEdit: TEdit; CustomIcon: string; SettingIconCombo: TComboBox; end; TSensorWidget = class Background: TRectangle; IconLabel: TLabel; TitleLabel: TLabel; ValueLabel: TLabel; UnitLabel: TLabel; ProgressBarBg: TRectangle; ProgressBarFill: TRectangle; TitleKO, TitleEN: string; CustomName: string; NodeID: string; MaxValue: Double; procedure UpdateValue(Value: Double); end; TControlWidget = class Background: TRectangle; IconLabel: TLabel; TitleLabel: TLabel; StatusLabel: TLabel; ToggleSwitch: TSwitch; TitleKO, TitleEN: string; CustomName: string; NodeID: string; procedure UpdateStatus(IsOn: Boolean); end; TfrmMain = class(TForm) TimerTime: TTimer; MediaPlayer: TMediaPlayer; bgRect: TRectangle; lytSidebar: TRectangle; lytSidebarTop: TLayout; SidebarIcon: TLabel; lblAppTitle: TLabel; lytNavMenu: TLayout; btnNavDashboard: TRectangle; lblNavDashboardIcon: TLabel; lblNavDashboardText: TLabel; btnNavNodeSettings: TRectangle; lblNavNodeSettingsIcon: TLabel; lblNavNodeSettingsText: TLabel; btnNavCCTV: TRectangle; lblNavCCTVIcon: TLabel; lblNavCCTVText: TLabel; btnNavAUTO: TRectangle; lblNavAUTOIcon: TLabel; lblNavAUTOText: TLabel; pnlAutoModeBox: TRectangle; lblAutoModeTitle: TLabel; pnlAutoModeBtn: TRectangle; lblAutoModeStatus: TLabel; bgStatus: TRectangle; lblStatusTitle: TLabel; lblConnectionStatus: TLabel; lytMainContent: TLayout; lytHeader: TLayout; lblHeaderTitle: TLabel; LControls: TLayout; lblDateTime: TLabel; btnFullscreen: TRectangle; lblFullscreenText: TLabel; btnLang: TRectangle; lblLangText: TLabel; lytScreenDashboard: TLayout; scrollWidgets: TVertScrollBox; lytWidgets: TFlowLayout; lytScreenNodeSettings: TLayout; scrollNodeSettings: TVertScrollBox; lytNodesMain: TFlowLayout; lytScreenSettings: TLayout; lblSettingsTitle: TLabel; edtRTSPUrl: TEdit; btnCameraAdd: TRectangle; lblCameraAddText: TLabel; lstCameras: TListBox; btnCameraDel: TRectangle; lblCameraDelText: TLabel; lytScreenCCTV: TLayout; lytScreenAuto: TLayout; scrollAuto: TVertScrollBox; btnAutoRuleBar: TLayout; MediaControl: TMediaPlayerControl; lytCCTVContainer: TFlowLayout; rectModalOverlay: TRectangle; rectModalContent: TRectangle; ShadowEffect1: TShadowEffect; lblModalIcon: TLabel; lblModalTitle: TLabel; lblModalMessage: TLabel; btnModalClose: TRectangle; lblModalCloseText: TLabel; TimerAuto: TTimer; ScaledLayout1: TScaledLayout; TimerStartAutoCK: TTimer; ImageList1: TImageList; Glyph1: TGlyph; btnNavIrri: TRectangle; lblNavIrriIcon: TLabel; lblNavIrriText: TLabel; btnNavLogs: TRectangle; lblNavLogsIcon: TLabel; lblNavLogsText: TLabel; btnNavSettings: TRectangle; lblNavSettingsIcon: TLabel; lblNavSettingsText: TLabel; Label1: TLabel; Label2: TLabel; Label3: TLabel; procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); procedure FormResize(Sender: TObject); procedure TimerTimeTimer(Sender: TObject); procedure OnLangClick(Sender: TObject); procedure OnFullscreenClick(Sender: TObject); procedure OnModalCloseClick(Sender: TObject); procedure OnToggleChange(Sender: TObject); procedure OnNodeNameExit(Sender: TObject); procedure OnNavClick(Sender: TObject); procedure OnAddCameraClick(Sender: TObject); procedure OnDelCameraClick(Sender: TObject); procedure OnPlayCameraClick(Sender: TObject); procedure OnShowPathClick(Sender: TObject); // Auto Control (UAutoControl_Impl.inc) mapped events MUST be published (before private) procedure OnAutoModeToggle(Sender: TObject); procedure TimerAutoTimer(Sender: TObject); procedure OnAutoRuleAdd(Sender: TObject); procedure OnAutoRuleDel(Sender: TObject); procedure OnAutoCondAdd(Sender: TObject); procedure OnAutoCondDel(Sender: TObject); procedure OnAutoRuleEdit(Sender: TObject); procedure OnAutoRuleDelSelected(Sender: TObject); procedure OnAutoRuleCheck(Sender: TObject); procedure TimerStartAutoCKTimer(Sender: TObject); procedure bgStatusDblClick(Sender: TObject); private FMQTTGateWayID, FMQTTBrokerIP, FMQTTUserID, FMQTTUserPASS : string; FMQTTBrokerPORT : Integer; FMQTTTopicHEAD, FMQTTTopicTAIL : string; FIsKorean: Boolean; FNodeConfigs: TArray; Sensors: TArray; Controls: TArray; FRTSPApps: TStringList; // Store RTSP app package names FMQTTClient: TMQTTClient; FControlPending: array[0..30] of Boolean; // Increased for DO_00..DO_15 FControlDesired: array[0..30] of Boolean; FUpdatingControls: Boolean; // MQTT 업데이트 시 OnToggleChange 재귀 방지 FMQTTMessageRE: Boolean; // 초기 실행시 자동실행을 위한 MQTT(실시간 게이트웨이 현황) 적용 체크 FAutoRules: TArray; FAutoMode: Boolean; FRefreshingAutoUI: Boolean; FIrriSchedules: TArray; FSensorValues: array[0..199] of Double; FSensorValuesTotalFlow: array[0..199] of Double; btnShowPath: TRectangle; lytScreenLogs: TLayout; memoLogs: TMemo; dateLog: TDateEdit; lblLogDate: TLabel; btnRefreshLogs: TRectangle; lytScreenIrri: TLayout; lytIrriDashboard: TLayout; lytIrriSettings: TLayout; scrlIrriSettings: TScrollBox; lytIrriActionPopup: TLayout; scrlIrriAction: TScrollBox; lblIrriActionTitle: TLabel; FCurrentEditIrriSchedIdx: Integer; lytMQTTSetupPopup: TLayout; scrMQTTSetup: TScrollBox; lblMQTTSetupTitle: TLabel; procedure BuildLogUI; procedure OnRefreshLogsClick(Sender: TObject); procedure AddSystemLog(const AMessage: string); procedure BuildIrriUI; procedure BuildIrriActionPopupUI; procedure OnIrriNavClick(Sender: TObject); procedure BuildMQTTSetupPopupUI; procedure OnMQTTSetupEdit(Sender: TObject); procedure OnMQTTSetupSaveClose(Sender: TObject); procedure InitNodeConfigs; procedure BuildNodeSettingsUI; procedure RebuildDashboardUI; procedure OnNodeSettingToggle(Sender: TObject); procedure OnNodeIconChange(Sender: TObject); function BuildLabel(AParent: TFmxObject; AText: string; ASize: Single; AAlign: TAlignLayout; AColor: TAlphaColor; IsBold: Boolean = False): TLabel; function BuildDateEdit(AParent: TFmxObject; ADate: TDate; ASize: Single; AAlign: TAlignLayout; AColor: TAlphaColor; IsBold: Boolean = False): TDateEdit; function BuildWidgetBg(AParent: TFmxObject): TRectangle; function CreateCustomButton(AParent: TFmxObject; AText: string; AAlign: TAlignLayout; AWidth: Single; Event: TNotifyEvent): TRectangle; function AddSensor(const AConfig: TNodeConfig; AIcon, AUnit: string; AMax: Double): TSensorWidget; function AddControl(const AConfig: TNodeConfig; AIcon: string): TControlWidget; procedure ApplyLanguage; procedure LoadSettings; procedure BuildCCTVGrid; procedure SaveSettings; procedure SaveSettings_MQTT; procedure SaveSetting_each(ck,_section, _ident, _Value: string); procedure SwitchScreen(Index: Integer); procedure ShowCustomAlert(const ATitleKO, ATitleEN, AMessageKO, AMessageEN: string); function GetGroupName(const AType: string; IsKorean: Boolean): string; procedure UpdateAutoModeBtnUI; procedure BuildAutoUI; procedure RefreshAutoUI; procedure LoadAutoRules; procedure SaveAutoRules; function GetCurrentSensorValue(const ANodeID: string): Double; function GetCurrentSensorValueTotalFlow(const ANodeID: string): Double; procedure EvalIrriFlowInit(const AConds: array of TAutoCondItem); procedure EvalIrriFlowInitTotalFlow(const AConds: array of TAutoCondItem); function EvalOneCondition(const ACond: TAutoCondItem): Boolean; function EvalOneConditionTotalFlow(const ACond: TAutoCondItem): Boolean; function EvalAutoRule(var ARule: TAutoRule): Boolean; procedure CheckAutoRules; // Irrigation Schedule (UIrrigation_Impl.inc) procedure RefreshIrriUI; procedure LoadIrriSchedules; procedure SaveIrriSchedules; procedure CheckIrrigationSchedules; procedure UpdateIrriDashboardUI; procedure OnIrriScheduleAdd(Sender: TObject); procedure OnIrriScheduleDel(Sender: TObject); procedure OnIrriScheduleDelSelected(Sender: TObject); procedure OnIrriScheduleCheck(Sender: TObject); procedure OnIrriScheduleEdit(Sender: TObject); procedure RefreshIrriActionPopupUI(ASchedIdx: Integer); procedure OnIrriActionPopupClose(Sender: TObject); procedure OnIrriActionManageClick(Sender: TObject); procedure OnIrriActionCheck(Sender: TObject); procedure OnIrriActionDelSelected(Sender: TObject); procedure OnIrriActionEdit(Sender: TObject); procedure OnIrriActionAdd(Sender: TObject); procedure OnIrriActionDel(Sender: TObject); procedure OnIrriCondAdd(Sender: TObject); procedure OnIrriCondDel(Sender: TObject); procedure OnIrriCondEdit(Sender: TObject); procedure OnIrriManualStartStop(Sender: TObject); procedure OnMQTTMessage(const ATopic, APayload: string); procedure OnMQTTStatus(AConnected: Boolean); function ParsePipeValue(const AData: string; AIndex: Integer): Double; procedure PublishControl(AIndex: Integer; AOn: Boolean); function GetIconByIndex(AIndex: Integer): string; function GetIconIndex(const AIcon: string): Integer; public end; var frmMain: TfrmMain; Init_DI : Integer = 16; Init_DO : Integer = 16; Init_AI : Integer = 4; Init_HM : Integer = 5; Init_TM : Integer = 5; Init_CD : Integer = 5; Init_NT : Integer = 5; Init_PH : Integer = 5; Init_GT : Integer = 5; Init_GM : Integer = 5; Init_GNT : Integer = 5; Init_WP : Integer = 5; Init_GT2 : Integer = 5; implementation uses System.Generics.Collections; {$R *.fmx} {$I UAutoControl_Impl.inc} {$I UIrrigation_Impl.inc} { TSensorWidget } procedure TSensorWidget.UpdateValue(Value: Double); var TargetWidth: Single; begin if ValueLabel <> nil then ValueLabel.Text := Format('%.1f', [Value]); if (ProgressBarBg <> nil) and (ProgressBarFill <> nil) then begin TargetWidth := (Value / MaxValue) * ProgressBarBg.Width; if TargetWidth > ProgressBarBg.Width then TargetWidth := ProgressBarBg.Width; TAnimator.AnimateFloat(ProgressBarFill, 'Width', TargetWidth, 0.3); end; end; { TControlWidget } procedure TControlWidget.UpdateStatus(IsOn: Boolean); begin if ToggleSwitch <> nil then ToggleSwitch.IsChecked := IsOn; if StatusLabel <> nil then begin if IsOn then begin if frmMain.FIsKorean then StatusLabel.Text := '켜짐' else StatusLabel.Text := 'ON'; StatusLabel.TextSettings.FontColor := $FF4CAF50; end else begin if frmMain.FIsKorean then StatusLabel.Text := '꺼짐' else StatusLabel.Text := 'OFF'; StatusLabel.TextSettings.FontColor := $FFFF5252; end; end; end; { TfrmMain } procedure TfrmMain.FormCreate(Sender: TObject); var rectEditBg: TRectangle; begin FMQTTMessageRE := False; FIsKorean := True; // Self.FullScreen := True; TimerTime.Interval := 1000; FRTSPApps := TStringList.Create; InitNodeConfigs; LoadSettings; LoadAutoRules; // AUTO LOAD LoadIrriSchedules; // IRRI LOAD BuildNodeSettingsUI; RebuildDashboardUI; BuildAutoUI; // AUTO BUILD BuildLogUI; // LOG UI BuildIrriUI; // IRRI UI BuildIrriActionPopupUI; ApplyLanguage; BuildMQTTSetupPopupUI; // edtRTSPUrl 에디트 박스 배경을 흰색으로 변경하고 폰트를 검은색으로 설정 rectEditBg := TRectangle.Create(Self); rectEditBg.Parent := edtRTSPUrl.Parent; rectEditBg.Align := edtRTSPUrl.Align; rectEditBg.Margins := edtRTSPUrl.Margins; rectEditBg.Position := edtRTSPUrl.Position; rectEditBg.Size := edtRTSPUrl.Size; rectEditBg.Fill.Color := claWhite; rectEditBg.Stroke.Kind := TBrushKind.None; rectEditBg.XRadius := 8; rectEditBg.YRadius := 8; edtRTSPUrl.Align := TAlignLayout.Client; edtRTSPUrl.Margins.Rect := RectF(15, 0, 15, 0); // 좌우 여백 추가 edtRTSPUrl.Parent := rectEditBg; {$IFNDEF ANDROID} edtRTSPUrl.StyleLookup := 'transparentedit'; {$ENDIF} edtRTSPUrl.TextSettings.FontColor := claBlack; edtRTSPUrl.StyledSettings := edtRTSPUrl.StyledSettings - [TStyledSetting.FontColor]; {$IFDEF MACOS} // macOS: Force the application to accept first mouse clicks immediately. TNSApplication.Wrap(TNSApplication.OCClass.sharedApplication).activateIgnoringOtherApps(True); {$ENDIF} // Timer enable TimerTime.Enabled := True; // MQTT 클라이언트 초기화 및 연결 FMQTTClient := TMQTTClient.Create(FMQTTBrokerIP, FMQTTBrokerPORT, 'SmartFarmHMI'); FMQTTClient.OnMessage := OnMQTTMessage; FMQTTClient.OnStatus := OnMQTTStatus; FMQTTClient.Subscribe(FMQTTTopicHEAD+'PUB/'+FMQTTTopicTAIL+FMQTTGateWayID); FMQTTClient.Connect; // 파일 경로 표시 버튼 생성 btnShowPath := CreateCustomButton(lytScreenSettings, '저장 경로 확인', TAlignLayout.Bottom, 250, OnShowPathClick); btnShowPath.Height := 60; btnShowPath.Margins.Rect := RectF(40, 0, 20, 10); Self.FullScreen := True; end; procedure TfrmMain.FormDestroy(Sender: TObject); begin if FMQTTClient <> nil then begin FMQTTClient.Disconnect; FreeAndNil(FMQTTClient); end; if Assigned(FRTSPApps) then FreeAndNil(FRTSPApps); end; procedure TfrmMain.FormResize(Sender: TObject); var ItemW, AvailW: Single; SidebarW: Single; i: Integer; Cols, Rows, TotalItems: Integer; begin SidebarW := 350; // default // Apply sidebar width first so scrollWidgets.Width reflects the correct available area if lytSidebar <> nil then begin if ClientWidth < 1000 then SidebarW := 250 else SidebarW := 350; lytSidebar.Width := SidebarW; end; if (lytWidgets <> nil) and (scrollWidgets <> nil) then begin // Calculate available width from ClientWidth directly (avoids stale layout values) AvailW := ClientWidth - SidebarW - 80; if AvailW < 1 then AvailW := 1; // Each item slot = widget width (440) + FlowLayout HorizontalGap (40) ItemW := 440 + 40; Cols := Trunc(AvailW / ItemW); if Cols < 1 then Cols := 1; TotalItems := Length(Sensors) + Length(Controls); if TotalItems = 0 then TotalItems := 9; // Fallback during initial render Rows := Ceil(TotalItems / Cols); // Row height = widget height (270) + top margin (10) + bottom margin (10) + VerticalGap (20) = 310px // Add extra 80px bottom padding so the last row is never cut off by the scroll box lytWidgets.Height := Rows * 220 + 200; end; if (lytNodesMain <> nil) and (scrollNodeSettings <> nil) then begin {$IFNDEF ANDROID} AvailW := ClientWidth - SidebarW - 40; // Approx 40px for scrollbar/margins if AvailW < 1 then AvailW := 1; ItemW := 700; // Base card width if AvailW < ItemW then ItemW := AvailW - 20; Cols := Trunc(AvailW / (ItemW + 20)); // +20 for FlowLayout HorizontalGap if Cols < 1 then Cols := 1; Rows := 0; TotalItems := 0; for i := 0 to lytNodesMain.ControlsCount - 1 do begin if lytNodesMain.Controls[i].Tag = -2 then begin // TFlowLayoutBreak: It means the current line ended. if TotalItems > 0 then begin Inc(Rows); TotalItems := 0; end; end else if lytNodesMain.Controls[i].Tag = -1 then begin // Header lytNodesMain.Controls[i].Width := lytNodesMain.Width; // Span entire FlowLayout width if TotalItems > 0 then begin Inc(Rows); TotalItems := 0; end; Inc(Rows); // The header itself counts as one row end else begin // Normal card item lytNodesMain.Controls[i].Width := ItemW; // Ensure card dynamically fits! Inc(TotalItems); if TotalItems >= Cols then begin Inc(Rows); TotalItems := 0; end; end; end; if TotalItems > 0 then Inc(Rows); lytNodesMain.Height := Rows * 100 + 200; {$ENDIF} end; // Wait, if Android was skipped, how does lytNodesMain get its height for scrolling? // We MUST calculate height for Android too, or else the ScrollBox has no content height to scroll! if (lytNodesMain <> nil) and (scrollNodeSettings <> nil) then begin {$IFDEF ANDROID} // Calculate how many rows we have assuming 2 items per row TotalItems := 0; Rows := 0; for i := 0 to lytNodesMain.ControlsCount - 1 do begin if lytNodesMain.Controls[i].Tag = -1 then // Header begin if TotalItems > 0 then begin Inc(Rows); TotalItems := 0; end; Inc(Rows); end else if lytNodesMain.Controls[i].Tag <> -2 then // Normal item begin Inc(TotalItems); if TotalItems >= 2 then begin Inc(Rows); TotalItems := 0; end; end; end; if TotalItems > 0 then Inc(Rows); // Each row is ItemLyt.Height (100) + VerticalGap (maybe 20). lytNodesMain.Height := Rows * 120 + 80; {$ENDIF} end; end; function TfrmMain.BuildLabel(AParent: TFmxObject; AText: string; ASize: Single; AAlign: TAlignLayout; AColor: TAlphaColor; IsBold: Boolean): TLabel; begin Result := TLabel.Create(Self); Result.Parent := AParent; Result.Align := AAlign; Result.Text := AText; // Disable default styling to apply custom font size and color Result.StyledSettings := Result.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor, TStyledSetting.Style]; Result.TextSettings.Font.Size := ASize; if IsBold then Result.TextSettings.Font.Style := [TFontStyle.fsBold] else Result.TextSettings.Font.Style := []; Result.TextSettings.FontColor := AColor; Result.WordWrap := False; end; function TfrmMain.BuildDateEdit(AParent: TFmxObject; ADate: TDate; ASize: Single; AAlign: TAlignLayout; AColor: TAlphaColor; IsBold: Boolean): TDateEdit; begin Result := TDateEdit.Create(Self); Result.Parent := AParent; Result.Align := AAlign; Result.Date := ADate; // Disable default styling to apply custom font size and color Result.StyledSettings := Result.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor, TStyledSetting.Style]; Result.TextSettings.Font.Size := ASize; if IsBold then Result.TextSettings.Font.Style := [TFontStyle.fsBold] else Result.TextSettings.Font.Style := []; Result.TextSettings.FontColor := AColor; Result.WordWrap := False; end; function TfrmMain.BuildWidgetBg(AParent: TFmxObject): TRectangle; begin Result := TRectangle.Create(Self); Result.Parent := AParent; Result.Fill.Color := $20FFFFFF; Result.Stroke.Color := $30FFFFFF; Result.XRadius := 16; Result.YRadius := 16; Result.Margins.Rect := RectF(10, 10, 10, 10); end; function TfrmMain.CreateCustomButton(AParent: TFmxObject; AText: string; AAlign: TAlignLayout; AWidth: Single; Event: TNotifyEvent): TRectangle; var L: TLabel; begin Result := TRectangle.Create(Self); Result.Parent := AParent; Result.Align := AAlign; Result.Width := AWidth; Result.Fill.Color := $40FFFFFF; Result.Stroke.Color := $40FFFFFF; Result.XRadius := 8; Result.YRadius := 8; Result.Cursor := crHandPoint; Result.HitTest := True; Result.OnClick := Event; Result.Margins.Rect := RectF(10, 10, 10, 10); L := TLabel.Create(Self); L.Parent := Result; L.Align := TAlignLayout.Client; L.StyledSettings := L.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor, TStyledSetting.Style]; L.TextSettings.HorzAlign := TTextAlign.Center; L.TextSettings.VertAlign := TTextAlign.Center; L.TextSettings.Font.Size := 24; L.TextSettings.Font.Style := [TFontStyle.fsBold]; L.TextSettings.FontColor := claWhite; L.HitTest := False; L.Text := AText; end; function TfrmMain.GetGroupName(const AType: string; IsKorean: Boolean): string; begin if AType = 'DI' then begin if IsKorean then Result := '디지털 입력' else Result := 'Digital In'; end else if AType = 'DO' then begin if IsKorean then Result := '디지털 출력' else Result := 'Digital Out'; end else if AType = 'AI' then begin if IsKorean then Result := '아날로그 입력' else Result := 'Analog In'; end else if AType = 'HM' then begin if IsKorean then Result := '습도 센서' else Result := 'Humidity Sensor'; end else if AType = 'TM' then begin if IsKorean then Result := '온도 센서' else Result := 'Temp Sensor'; end else if AType = 'CD' then begin if IsKorean then Result := 'CO2 센서' else Result := 'CO2 Sensor'; end else if AType = 'NT' then begin if IsKorean then Result := 'EC 센서' else Result := 'EC Sensor'; end else if AType = 'PH' then begin if IsKorean then Result := 'pH 센서' else Result := 'pH Sensor'; end else if AType = 'GT' then begin if IsKorean then Result := '토양 온도 1' else Result := 'Soil Temp 1'; end else if AType = 'GM' then begin if IsKorean then Result := '토양 습도' else Result := 'Soil Moisture'; end else if AType = 'GNT' then begin if IsKorean then Result := '토양 EC' else Result := 'Soil EC'; end else if AType = 'WP' then begin if IsKorean then Result := '토양 pH' else Result := 'Soil pH'; end else if AType = 'GT2' then begin if IsKorean then Result := '토양 온도 2' else Result := 'Soil Temp 2'; end else begin if IsKorean then Result := '기타' else Result := 'Other'; end; end; function TfrmMain.AddSensor(const AConfig: TNodeConfig; AIcon, AUnit: string; AMax: Double): TSensorWidget; var W: TSensorWidget; TopLyt, ValLyt: TLayout; begin W := TSensorWidget.Create; W.TitleKO := AConfig.TitleKO; W.TitleEN := AConfig.TitleEN; W.CustomName := AConfig.CustomName; // Assign custom name W.NodeID := AConfig.ID; W.MaxValue := AMax; W.Background := BuildWidgetBg(lytWidgets); W.Background.Width := 440; // W.Background.Height := 270; W.Background.Height := 200; TopLyt := TLayout.Create(W.Background); TopLyt.Parent := W.Background; TopLyt.Align := TAlignLayout.Top; TopLyt.Height := 75; TopLyt.Margins.Rect := RectF(15, 15, 15, 0); W.IconLabel := BuildLabel(TopLyt, AIcon, 60, TAlignLayout.Left, claWhite, False); W.IconLabel.Width := 90; W.TitleLabel := BuildLabel(TopLyt, '', 36, TAlignLayout.Client, claWhite, True); W.TitleLabel.TextSettings.HorzAlign := TTextAlign.Leading; ValLyt := TLayout.Create(W.Background); ValLyt.Parent := W.Background; ValLyt.Align := TAlignLayout.Client; ValLyt.Margins.Rect := RectF(15, 15, 15, 15); // W.ValueLabel := BuildLabel(ValLyt, '--', 72, TAlignLayout.Client, claWhite, True); W.ValueLabel := BuildLabel(ValLyt, '--', 56, TAlignLayout.Client, claWhite, True); W.ValueLabel.TextSettings.HorzAlign := TTextAlign.Leading; W.UnitLabel := BuildLabel(ValLyt, AUnit, 36, TAlignLayout.Right, claLightgray, False); W.UnitLabel.Width := 90; W.ProgressBarBg := TRectangle.Create(W.Background); W.ProgressBarBg.Parent := W.Background; W.ProgressBarBg.Align := TAlignLayout.Bottom; W.ProgressBarBg.Height := 15; W.ProgressBarBg.Margins.Rect := RectF(15, 0, 15, 15); W.ProgressBarBg.Fill.Color := $40FFFFFF; W.ProgressBarBg.Stroke.Kind := TBrushKind.None; W.ProgressBarBg.XRadius := 7.5; W.ProgressBarBg.YRadius := 7.5; W.ProgressBarFill := TRectangle.Create(W.ProgressBarBg); W.ProgressBarFill.Parent := W.ProgressBarBg; W.ProgressBarFill.Align := TAlignLayout.Left; W.ProgressBarFill.Width := 0; W.ProgressBarFill.Fill.Color := $FF4CAF50; W.ProgressBarFill.Stroke.Kind := TBrushKind.None; W.ProgressBarFill.XRadius := 7.5; W.ProgressBarFill.YRadius := 7.5; SetLength(Sensors, Length(Sensors) + 1); Sensors[High(Sensors)] := W; Result := W; end; function TfrmMain.AddControl(const AConfig: TNodeConfig; AIcon: string): TControlWidget; var W: TControlWidget; TopLyt, ValLyt: TLayout; begin W := TControlWidget.Create; W.TitleKO := AConfig.TitleKO; W.TitleEN := AConfig.TitleEN; W.CustomName := AConfig.CustomName; // Assign custom name W.NodeID := AConfig.ID; W.Background := BuildWidgetBg(lytWidgets); W.Background.Width := 440; // W.Background.Height := 270; W.Background.Height := 200; TopLyt := TLayout.Create(W.Background); TopLyt.Parent := W.Background; TopLyt.Align := TAlignLayout.Top; TopLyt.Height := 75; TopLyt.Margins.Rect := RectF(15, 15, 15, 0); W.IconLabel := BuildLabel(TopLyt, AIcon, 60, TAlignLayout.Left, claWhite, False); W.IconLabel.Width := 90; W.TitleLabel := BuildLabel(TopLyt, '', 36, TAlignLayout.Client, claWhite, True); W.TitleLabel.TextSettings.HorzAlign := TTextAlign.Leading; ValLyt := TLayout.Create(W.Background); ValLyt.Parent := W.Background; ValLyt.Align := TAlignLayout.Client; ValLyt.Margins.Rect := RectF(15, 15, 15, 15); // W.StatusLabel := BuildLabel(ValLyt, 'OFF', 54, TAlignLayout.Client, $FFFF5252, True); W.StatusLabel := BuildLabel(ValLyt, 'OFF', 50, TAlignLayout.Client, $FFFF5252, True); W.StatusLabel.TextSettings.HorzAlign := TTextAlign.Leading; if AConfig.NodeType <> 'DI' then begin W.ToggleSwitch := TSwitch.Create(W.Background); W.ToggleSwitch.Parent := ValLyt; W.ToggleSwitch.Align := TAlignLayout.Right; W.ToggleSwitch.Width := 120; {$IFDEF ANDROID} W.ToggleSwitch.Scale.X := 3; W.ToggleSwitch.Scale.Y := 3; {$ENDIF} W.ToggleSwitch.OnSwitch := OnToggleChange; end else begin W.ToggleSwitch := nil; end; SetLength(Controls, Length(Controls) + 1); Controls[High(Controls)] := W; Result := W; end; procedure TfrmMain.ApplyLanguage; var i: Integer; begin if FIsKorean then begin lblAppTitle.Text := '스마트팜'#13#10'HMI 센터'; lblStatusTitle.Text := '연결 상태'; lblNavDashboardText.Text := '모니터링'; lblNavNodeSettingsText.Text := '노드 설정'; lblNavSettingsText.Text := 'CCTV설정'; lblNavCCTVText.Text := 'CCTV'; lblNavAUTOText.Text := '자동 설정'; if lblNavLogsText <> nil then lblNavLogsText.Text := '시스템 로그'; if lblNavIrriText <> nil then lblNavIrriText.Text := '관수 스케쥴'; lblAutoModeTitle.Text := '수동 / 자동 모드'; if FAutoMode then lblAutoModeStatus.Text := '🤖 자동 모드 ON' else lblAutoModeStatus.Text := '🖐 수동 모드'; lblSettingsTitle.Text := 'CCTV설정 (CCTV 주소 관리)'; if lblConnectionStatus.Tag = 0 then lblConnectionStatus.Text := '연결 중...'; if lblConnectionStatus.Tag = 1 then lblConnectionStatus.Text := '연결됨'; if lblConnectionStatus.Tag = 2 then lblConnectionStatus.Text := '연결 끊김'; if lytScreenDashboard.Visible then lblHeaderTitle.Text := '환경 모니터링' else if lytScreenNodeSettings.Visible then lblHeaderTitle.Text := '노드 설정' else if lytScreenSettings.Visible then lblHeaderTitle.Text := 'CCTV설정' else if lytScreenCCTV.Visible then lblHeaderTitle.Text := 'CCTV 보기' else if lytScreenAuto.Visible then lblHeaderTitle.Text := '자동 설정' else if (lytScreenLogs <> nil) and lytScreenLogs.Visible then lblHeaderTitle.Text := '시스템 로그' else if (lytScreenIrri <> nil) and lytScreenIrri.Visible then lblHeaderTitle.Text := '관수 스케쥴' else lblHeaderTitle.Text := '환경 모니터링'; lblLangText.Text := 'EN'; lblModalCloseText.Text := '확인'; lblModalTitle.Text := '연결 오류'; lblModalMessage.Text := '서버와의 연결이 끊어졌습니다.'; lblCameraAddText.Text := '주소 추가'; lblCameraDelText.Text := '선택한 주소 삭제'; edtRTSPUrl.TextPrompt := 'RTSP 스트리밍 주소를 입력하세요 (예: rtsp://192.168.0.10:554/stream1)'; if btnShowPath <> nil then TLabel(btnShowPath.Controls[0]).Text := '저장 경로 확인'; if lblLogDate <> nil then lblLogDate.Text := '로그 날짜 선택:'; if btnRefreshLogs <> nil then TLabel(btnRefreshLogs.Controls[0]).Text := '로그 불러오기 (Load)'; end else begin lblAppTitle.Text := 'SmartFarm'#13#10'HMI Center'; lblStatusTitle.Text := 'Connection Status'; lblNavDashboardText.Text := 'Dashboard'; lblNavNodeSettingsText.Text := 'Node Settings'; lblNavSettingsText.Text := 'Settings'; lblNavCCTVText.Text := 'CCTV'; lblNavAUTOText.Text := 'Auto Settings'; if lblNavLogsText <> nil then lblNavLogsText.Text := 'System Logs'; if lblNavIrriText <> nil then lblNavIrriText.Text := 'Irrigation'; lblAutoModeTitle.Text := 'Auto / Manual Mode'; if FAutoMode then lblAutoModeStatus.Text := '🤖 AUTO MODE ON' else lblAutoModeStatus.Text := '🖐 MANUAL MODE'; lblSettingsTitle.Text := 'CCTV Settings (Manage URLs)'; if lblConnectionStatus.Tag = 0 then lblConnectionStatus.Text := 'Connecting...'; if lblConnectionStatus.Tag = 1 then lblConnectionStatus.Text := 'Connected'; if lblConnectionStatus.Tag = 2 then lblConnectionStatus.Text := 'Disconnected'; if lytScreenDashboard.Visible then lblHeaderTitle.Text := 'Environment Overview' else if lytScreenNodeSettings.Visible then lblHeaderTitle.Text := 'Node Settings' else if lytScreenSettings.Visible then lblHeaderTitle.Text := 'CCTV Settings' else if lytScreenCCTV.Visible then lblHeaderTitle.Text := 'CCTV View' else if lytScreenAuto.Visible then lblHeaderTitle.Text := 'Auto Settings' else if (lytScreenLogs <> nil) and lytScreenLogs.Visible then lblHeaderTitle.Text := 'System Logs' else if (lytScreenIrri <> nil) and lytScreenIrri.Visible then lblHeaderTitle.Text := 'Irrigation Schedule' else lblHeaderTitle.Text := 'Environment Overview'; lblLangText.Text := 'KO'; lblModalCloseText.Text := 'OK'; lblModalTitle.Text := 'Connection Error'; lblModalMessage.Text := 'Connection to server was lost.'; lblCameraAddText.Text := 'Add Address'; lblCameraDelText.Text := 'Delete Selected'; edtRTSPUrl.TextPrompt := 'Enter RTSP Stream URL (e.g., rtsp://192.168.0.10:554/stream1)'; if btnShowPath <> nil then TLabel(btnShowPath.Controls[0]).Text := 'Show INI Path'; if lblLogDate <> nil then lblLogDate.Text := 'Select Log Date:'; if btnRefreshLogs <> nil then TLabel(btnRefreshLogs.Controls[0]).Text := 'Load Logs'; end; BuildCCTVGrid; // Reload CCTV button text based on new language // 관수 스케쥴 화면 텍스트 갱신 if (scrlIrriSettings <> nil) and (Length(FIrriSchedules) >= 0) then TThread.Queue(nil, RefreshIrriUI); // 자동설정 하단 버튼 텍스트 갱신 if btnAutoRuleBar <> nil then BuildAutoUI; for i := 0 to High(FNodeConfigs) do begin if (FNodeConfigs[i].SettingRect <> nil) and (FNodeConfigs[i].SettingRect.ControlsCount > 0) then begin if FNodeConfigs[i].SettingRect.Controls[0] is TLabel then begin if FIsKorean then TLabel(FNodeConfigs[i].SettingRect.Controls[0]).Text := FNodeConfigs[i].TitleKO + ' [' + FNodeConfigs[i].ID + ']' else TLabel(FNodeConfigs[i].SettingRect.Controls[0]).Text := FNodeConfigs[i].TitleEN + ' [' + FNodeConfigs[i].ID + ']'; end; end; end; for i := 0 to High(Sensors) do begin if Sensors[i].CustomName <> '' then Sensors[i].TitleLabel.Text := Sensors[i].CustomName else if FIsKorean then Sensors[i].TitleLabel.Text := Sensors[i].TitleKO else Sensors[i].TitleLabel.Text := Sensors[i].TitleEN; end; for i := 0 to High(Controls) do begin if Controls[i].CustomName <> '' then Controls[i].TitleLabel.Text := Controls[i].CustomName else if FIsKorean then Controls[i].TitleLabel.Text := Controls[i].TitleKO else Controls[i].TitleLabel.Text := Controls[i].TitleEN; if Controls[i].ToggleSwitch <> nil then Controls[i].UpdateStatus(Controls[i].ToggleSwitch.IsChecked) else if Controls[i].StatusLabel <> nil then Controls[i].UpdateStatus((Controls[i].StatusLabel.Text = '켜짐') or (Controls[i].StatusLabel.Text = 'ON')); end; // Translate Node Settings group headers if lytNodesMain <> nil then begin for i := 0 to lytNodesMain.ControlsCount - 1 do begin if lytNodesMain.Controls[i].Tag = -1 then // Is Header Layout begin if (lytNodesMain.Controls[i] as TLayout).ControlsCount > 0 then begin if (lytNodesMain.Controls[i] as TLayout).Controls[0] is TLabel then begin with TLabel((lytNodesMain.Controls[i] as TLayout).Controls[0]) do begin Text := GetGroupName(Hint, FIsKorean); end; end; end; end; end; end; end; procedure TfrmMain.bgStatusDblClick(Sender: TObject); begin if Sender is TRectangle then begin lytMQTTSetupPopup.Visible := True; lytMQTTSetupPopup.BringToFront; end; end; procedure TfrmMain.OnLangClick(Sender: TObject); begin FIsKorean := not FIsKorean; ApplyLanguage; SaveSetting_each('bool', 'SetupLang', 'IsKorean', BoolToStr(FIsKorean)); end; procedure TfrmMain.OnFullscreenClick(Sender: TObject); begin Self.FullScreen := not Self.FullScreen; end; procedure TfrmMain.OnModalCloseClick(Sender: TObject); begin rectModalOverlay.Visible := False; end; procedure TfrmMain.OnToggleChange(Sender: TObject); var SW: TSwitch; W: TControlWidget; i, j, NodeIdx: Integer; DesiredState: Boolean; begin // MQTT에서 업데이트 중이면 무시 (재귀 방지) if FUpdatingControls then Exit; SW := Sender as TSwitch; for i := 0 to High(Controls) do begin W := Controls[i]; if W.ToggleSwitch = SW then begin // 사용자가 원하는 상태 캐치 DesiredState := SW.IsChecked; // 토글을 원래 상태로 되돌림 (MQTT 확인 후에만 UI 변경) FUpdatingControls := True; try SW.IsChecked := not DesiredState; finally FUpdatingControls := False; end; // 설정된 노드 인덱스 찾기 NodeIdx := -1; for j := 0 to High(FNodeConfigs) do begin if FNodeConfigs[j].ID = W.NodeID then begin NodeIdx := FNodeConfigs[j].Index; Break; end; end; // MQTT Publish 및 Pending 설정 if (NodeIdx >= 0) and (NodeIdx <= High(FControlPending)) then begin FControlPending[NodeIdx] := True; FControlDesired[NodeIdx] := DesiredState; PublishControl(NodeIdx, DesiredState); end; Break; end; end; end; procedure TfrmMain.OnNavClick(Sender: TObject); var Btn: TRectangle; begin if Sender is TRectangle then begin Btn := TRectangle(Sender); SwitchScreen(Btn.Tag); end; end; procedure TfrmMain.SwitchScreen(Index: Integer); var PrevIndex: Integer; begin {$IFDEF ANDROID} if Index = 2 then begin ShowCustomAlert('CCTV 설정 정보', 'CCTV SETTINGS', '안드로이드 앱에서 설정가능합니다.', ''); Exit; end; {$ENDIF} // 현재 활성화된 화면 인덱스 저장 PrevIndex := 0; if lytScreenDashboard.Visible then PrevIndex := 0 else if lytScreenNodeSettings.Visible then PrevIndex := 1 else if lytScreenSettings.Visible then PrevIndex := 2 else if lytScreenCCTV.Visible then PrevIndex := 3 else if lytScreenAuto.Visible then PrevIndex := 4 else if (lytScreenLogs <> nil) and lytScreenLogs.Visible then PrevIndex := 5 else if (lytScreenIrri <> nil) and lytScreenIrri.Visible then PrevIndex := 6; // Stop media player if moving away from CCTV or just generally resetting state MediaPlayer.Stop; MediaControl.Visible := False; // Reset all button backgrounds btnNavDashboard.Fill.Kind := TBrushKind.None; btnNavSettings.Fill.Kind := TBrushKind.None; btnNavCCTV.Fill.Kind := TBrushKind.None; btnNavAUTO.Fill.Kind := TBrushKind.None; btnNavNodeSettings.Fill.Kind := TBrushKind.None; if btnNavLogs <> nil then btnNavLogs.Fill.Kind := TBrushKind.None; if btnNavIrri <> nil then btnNavIrri.Fill.Kind := TBrushKind.None; // 만약 CCTV(3)를 누른 경우, 화면 전환을 하지 않고 이전 화면 상태를 복구하기 위해서 임시 보관 // 하지만 먼저 모든 화면을 숨김 lytScreenDashboard.Visible := False; lytScreenSettings.Visible := False; lytScreenCCTV.Visible := False; lytScreenAuto.Visible := False; lytScreenNodeSettings.Visible := False; if lytScreenLogs <> nil then lytScreenLogs.Visible := False; if lytScreenIrri <> nil then lytScreenIrri.Visible := False; // Show active screen and set active button style case Index of 0: begin btnNavDashboard.Fill.Kind := TBrushKind.Solid; btnNavDashboard.Fill.Color := $404CAF50; lytScreenDashboard.Visible := True; if FIsKorean then lblHeaderTitle.Text := '환경 모니터링' else lblHeaderTitle.Text := 'Environment Overview'; end; 1: begin btnNavNodeSettings.Fill.Kind := TBrushKind.Solid; btnNavNodeSettings.Fill.Color := $404CAF50; lytScreenNodeSettings.Visible := True; if FIsKorean then lblHeaderTitle.Text := '노드 설정' else lblHeaderTitle.Text := 'Node Settings'; end; 2: begin btnNavSettings.Fill.Kind := TBrushKind.Solid; btnNavSettings.Fill.Color := $404CAF50; lytScreenSettings.Visible := True; if FIsKorean then lblHeaderTitle.Text := 'CCTV설정' else lblHeaderTitle.Text := 'CCTV Settings'; end; 3: begin // Windows와 Android: 화면 전환 없이 바로 첫 번째 카메라 외부 실행 // MacOS와 Linux: CCTV 화면으로 전환 {$IF DEFINED(MSWINDOWS) OR DEFINED(ANDROID)} if FRTSPApps.Count > 0 then begin var LParts := FRTSPApps[0].Split(['|']); var LAppPkg := ''; if Length(LParts) > 0 then LAppPkg := LParts[0]; {$IFDEF ANDROID} try var Intent := TAndroidHelper.Context.getPackageManager().getLaunchIntentForPackage(StringToJString(LAppPkg)); if Intent <> nil then TAndroidHelper.Activity.startActivity(Intent) else begin if FIsKorean then ShowCustomAlert('앱 실행 불가', 'Error', '패키지명(' + LAppPkg + ')을 가진 앱을 타겟 기기에서 찾을 수 없습니다.'#13#10'정확한 패키지명을 확인하거나 앱이 설치되었는지 확인해주세요.', '') else ShowCustomAlert('Launch Error', 'Error', '', 'Cannot find package (' + LAppPkg + ').'); end; except on E: Exception do begin if FIsKorean then ShowCustomAlert('앱 실행 예외', 'Launch Error', '앱 실행 중 오류가 발생했습니다.'#13#10 + E.Message, '') else ShowCustomAlert('Launch Error', 'Launch Error', '', 'Cannot launch RtspStreaming app.'#13#10'Error: ' + E.Message); end; end; {$ENDIF} {$IFDEF MSWINDOWS} if lstCameras.Count > 0 then begin var URL := lstCameras.Items[0]; if URL <> '' then begin var ExePath := ExtractFilePath(ParamStr(0)) + 'SmartFarmHMI_VCL_RTSP.exe'; ShellExecute(0, 'open', PChar(ExePath), PChar(URL), nil, SW_SHOWNORMAL); end; end else begin if FIsKorean then ShowCustomAlert('안내', 'Info', '등록된 CCTV 주소가 없습니다. CCTV 설정에서 추가해주세요.', '') else ShowCustomAlert('Info', 'Info', '', 'No CCTV URL is registered. Please add one in CCTV Settings.'); end; {$ENDIF} end else begin if FIsKorean then ShowCustomAlert('안내', 'Info', '등록된 CCTV 앱이 설정에 없습니다.', '') else ShowCustomAlert('Info', 'Info', '', 'No CCTV App is registered in settings.'); end; // 화면 이동 복원 if PrevIndex <> 3 then SwitchScreen(PrevIndex) else SwitchScreen(0); {$ELSE} // MacOS / Linux: 기존의 CCTV 화면 전환 동작 수행 btnNavCCTV.Fill.Kind := TBrushKind.Solid; btnNavCCTV.Fill.Color := $404CAF50; lytScreenCCTV.Visible := True; if FIsKorean then lblHeaderTitle.Text := 'CCTV 보기' else lblHeaderTitle.Text := 'CCTV View'; BuildCCTVGrid; // Rebuild camera buttons when entering CCTV tab {$ENDIF} end; 4: begin btnNavAUTO.Fill.Kind := TBrushKind.Solid; btnNavAUTO.Fill.Color := $404CAF50; lytScreenAuto.Visible := True; if FIsKorean then lblHeaderTitle.Text := '자동 설정' else lblHeaderTitle.Text := 'Auto Settings'; end; 5: begin if btnNavLogs <> nil then begin btnNavLogs.Fill.Kind := TBrushKind.Solid; btnNavLogs.Fill.Color := $404CAF50; end; if lytScreenLogs <> nil then lytScreenLogs.Visible := True; if FIsKorean then lblHeaderTitle.Text := '시스템 로그' else lblHeaderTitle.Text := 'System Logs'; OnRefreshLogsClick(nil); end; 6: begin if btnNavIrri <> nil then begin btnNavIrri.Fill.Kind := TBrushKind.Solid; btnNavIrri.Fill.Color := $404CAF50; end; if lytScreenIrri <> nil then lytScreenIrri.Visible := True; if FIsKorean then lblHeaderTitle.Text := '관수 스케쥴' else lblHeaderTitle.Text := 'Irrigation Schedule'; RefreshIrriUI; end; end; end; procedure TfrmMain.OnAddCameraClick(Sender: TObject); var LItem: TListBoxItem; begin if Trim(edtRTSPUrl.Text) <> '' then begin LItem := TListBoxItem.Create(lstCameras); LItem.Parent := lstCameras; LItem.Height := 80; LItem.Text := Trim(edtRTSPUrl.Text); // Apply custom font size on the individual item level LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size]; LItem.TextSettings.Font.Size := 24; lstCameras.AddObject(LItem); edtRTSPUrl.Text := ''; SaveSettings; end; end; procedure TfrmMain.OnDelCameraClick(Sender: TObject); begin if lstCameras.ItemIndex >= 0 then begin lstCameras.Items.Delete(lstCameras.ItemIndex); SaveSettings; end; end; procedure TfrmMain.LoadSettings; var Ini: TIniFile; i, Count: Integer; S, SettingsPath: string; LItem: TListBoxItem; begin lstCameras.Clear; FRTSPApps.Clear; SettingsPath := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'SmartFarmHMI_settings.ini'); Ini := TIniFile.Create(SettingsPath); try FMQTTGateWayID := Ini.ReadString( 'MQTT', 'GateWayID', '1000'); FMQTTBrokerIP := Ini.ReadString( 'MQTT', 'BrokerIP', '192.168.200.240'); FMQTTUserID := Ini.ReadString( 'MQTT', 'UserID', 'qsentech'); FMQTTUserPASS := Ini.ReadString( 'MQTT', 'UserPASS', 'qsentech!1233'); FMQTTBrokerPORT := Ini.ReadInteger('MQTT', 'BrokerPORT', 1883); FMQTTTopicHEAD := Ini.ReadString( 'MQTT', 'TopicHEAD', 'SMART-FARM/2/3/2/'); FMQTTTopicTAIL := Ini.ReadString( 'MQTT', 'TopicTAIL', '1/1/0/GTW/'); FIsKorean := Ini.ReadBool('SetupLang', 'IsKorean', True); Count := Ini.ReadInteger('CCTV', 'Count', 0); for i := 0 to Count - 1 do begin S := Ini.ReadString('CCTV', 'Cam' + IntToStr(i), ''); if S <> '' then begin LItem := TListBoxItem.Create(lstCameras); LItem.Parent := lstCameras; LItem.Height := 80; LItem.Text := S; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size]; LItem.TextSettings.Font.Size := 24; lstCameras.AddObject(LItem); end; end; // Load RTSP Apps Count := Ini.ReadInteger('RTSPApps', 'Count', 0); if Count > 0 then begin for i := 0 to Count - 1 do begin S := Ini.ReadString('RTSPApps', 'App' + IntToStr(i), ''); if S <> '' then FRTSPApps.Add(S); // Format: package_name|AppName (e.g. com.example.rtspstreaming|RtspStreaming) end; end else begin // Fallback default app if no apps are configured FRTSPApps.Add('com.example.rtspstreaming|RtspStreaming'); end; for i := 0 to High(FNodeConfigs) do begin FNodeConfigs[i].InUse := Ini.ReadBool('Nodes', FNodeConfigs[i].ID, False); FNodeConfigs[i].CustomName := Ini.ReadString('NodeNames', FNodeConfigs[i].ID, ''); // Read icon index (integer) and convert to emoji to avoid encoding issues FNodeConfigs[i].CustomIcon := GetIconByIndex(Ini.ReadInteger('NodeIcons', FNodeConfigs[i].ID, 0)); end; finally Ini.Free; end; end; procedure TfrmMain.SaveSettings; var Ini: TIniFile; i: Integer; SettingsPath: string; begin SettingsPath := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'SmartFarmHMI_settings.ini'); Ini := TIniFile.Create(SettingsPath); try Ini.EraseSection('CCTV'); Ini.WriteInteger('CCTV', 'Count', lstCameras.Count); for i := 0 to lstCameras.Count - 1 do Ini.WriteString('CCTV', 'Cam' + IntToStr(i), lstCameras.Items[i]); Ini.EraseSection('RTSPApps'); Ini.WriteInteger('RTSPApps', 'Count', FRTSPApps.Count); for i := 0 to FRTSPApps.Count - 1 do Ini.WriteString('RTSPApps', 'App' + IntToStr(i), FRTSPApps[i]); for i := 0 to High(FNodeConfigs) do begin Ini.WriteBool('Nodes', FNodeConfigs[i].ID, FNodeConfigs[i].InUse); Ini.WriteString('NodeNames', FNodeConfigs[i].ID, FNodeConfigs[i].CustomName); // Save icon as integer index instead of emoji to avoid encoding errors Ini.WriteInteger('NodeIcons', FNodeConfigs[i].ID, GetIconIndex(FNodeConfigs[i].CustomIcon)); end; finally Ini.Free; end; AddSystemLog('앱 공통 설정 및 CCTV 목록이 저장되었습니다.'); end; procedure TfrmMain.SaveSettings_MQTT; var Ini: TIniFile; i: Integer; SettingsPath: string; begin SettingsPath := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'SmartFarmHMI_settings.ini'); Ini := TIniFile.Create(SettingsPath); try Ini.EraseSection('MQTT'); Ini.WriteString( 'MQTT', 'GateWayID' , FMQTTGateWayID); Ini.WriteString( 'MQTT', 'BrokerIP' , FMQTTBrokerIP); Ini.WriteString( 'MQTT', 'UserID' , FMQTTUserID); Ini.WriteString( 'MQTT', 'UserPASS' , FMQTTUserPASS); Ini.WriteInteger('MQTT', 'BrokerPORT' , FMQTTBrokerPORT); Ini.WriteString( 'MQTT', 'TopicHEAD' , FMQTTTopicHEAD); Ini.WriteString( 'MQTT', 'TopicTAIL' , FMQTTTopicTAIL); finally Ini.Free; end; AddSystemLog('MQTT 접속정보 설정이 저장되었습니다.'); end; procedure TfrmMain.SaveSetting_each(ck,_section,_ident,_Value : string); var Ini: TIniFile; SP: string; begin SP := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'SmartFarmHMI_settings.ini'); Ini := TIniFile.Create(SP); try if ck = 'bool' then begin Ini.WriteBool(_section, _ident, StrToBool(_Value)); end else if ck = 'string' then begin Ini.WriteString(_section, _ident, _Value); end else if ck = 'integer' then begin Ini.WriteInteger(_section, _ident, StrToInt(_Value)); end; finally Ini.Free; end; end; function TfrmMain.GetIconByIndex(AIndex: Integer): string; begin case AIndex of 1: Result := '🎛️'; 2: Result := '🌡️'; 3: Result := '💧'; 4: Result := '💨'; 5: Result := '☀️'; 6: Result := '🌱'; 7: Result := '⚡'; 8: Result := '🧪'; 9: Result := '📊'; 10: Result := '📡'; 11: Result := '💡'; 12: Result := '🔔'; else Result := ''; // 0 = 기본 (default) end; end; function TfrmMain.GetIconIndex(const AIcon: string): Integer; begin if AIcon = '🎛️' then Result := 1 else if AIcon = '🌡️' then Result := 2 else if AIcon = '💧' then Result := 3 else if AIcon = '💨' then Result := 4 else if AIcon = '☀️' then Result := 5 else if AIcon = '🌱' then Result := 6 else if AIcon = '⚡' then Result := 7 else if AIcon = '🧪' then Result := 8 else if AIcon = '📊' then Result := 9 else if AIcon = '📡' then Result := 10 else if AIcon = '💡' then Result := 11 else if AIcon = '🔔' then Result := 12 else Result := 0; // 기본 end; procedure TfrmMain.BuildCCTVGrid; var i: Integer; Btn: TRectangle; LText, LAppName, LPackageName: string; Parts: TArray; begin // Clear previous buttons while lytCCTVContainer.ControlsCount > 0 do lytCCTVContainer.Controls[0].Free; {$IF DEFINED(MSWINDOWS) OR DEFINED(ANDROID)} // MSWINDOWS, ANDROID: Use registered RTSP Apps if FRTSPApps.Count = 0 then Exit; for i := 0 to FRTSPApps.Count - 1 do begin Parts := FRTSPApps[i].Split(['|']); if Length(Parts) > 0 then begin LPackageName := Parts[0]; if Length(Parts) > 1 then LAppName := Parts[1] else LAppName := LPackageName; Btn := CreateCustomButton(lytCCTVContainer, LAppName, TAlignLayout.Left, 250, OnPlayCameraClick); Btn.Hint := LPackageName; // Store App Package Name in Hint property end; end; {$ELSE} // MACOS, LINUX: Use registered camera URLs directly if lstCameras.Count = 0 then Exit; for i := 0 to lstCameras.Count - 1 do begin LText := 'Camera ' + IntToStr(i + 1); Btn := CreateCustomButton(lytCCTVContainer, LText, TAlignLayout.Left, 250, OnPlayCameraClick); Btn.Hint := lstCameras.Items[i]; // Store Camera URL in Hint property end; {$ENDIF} end; procedure TfrmMain.OnPlayCameraClick(Sender: TObject); var AppPackageName: string; i: Integer; {$IFDEF MSWINDOWS} ExePath: string; {$ENDIF} {$IFDEF ANDROID} Intent: JIntent; {$ENDIF} {$IFDEF MACOS} Cmd: string; {$ENDIF} {$IFDEF LINUX} Cmd: string; {$ENDIF} begin if Sender is TRectangle then begin AppPackageName := TRectangle(Sender).Hint; if AppPackageName <> '' then begin {$IF DEFINED(MSWINDOWS) OR DEFINED(ANDROID)} {$IFDEF MSWINDOWS} // 윈도우에서는 임시로 첫번째 CCTV 주소를 파라미터로 실행 유지 if lstCameras.Count > 0 then begin ExePath := ExtractFilePath(ParamStr(0)) + 'SmartFarmHMI_VCL_RTSP.exe'; ShellExecute(0, 'open', PChar(ExePath), PChar(lstCameras.Items[0]), nil, SW_SHOWNORMAL); end else ShowCustomAlert('안내', 'Info', '등록된 CCTV 주소가 없습니다.', 'No CCTV URL is registered.'); {$ENDIF} {$IFDEF ANDROID} try Intent := TAndroidHelper.Context.getPackageManager().getLaunchIntentForPackage(StringToJString(AppPackageName)); if Intent <> nil then begin TAndroidHelper.Activity.startActivity(Intent); end else begin if FIsKorean then ShowCustomAlert('앱 실행 불가', 'Error', '패키지명(' + AppPackageName + ')을 가진 앱을 스마트폰에서 찾을 수 없습니다.'#13#10'정확한 패키지명 확인 및 설치상태를 점검해주세요.', '') else ShowCustomAlert('Launch Error', 'Error', '', 'Cannot find package (' + AppPackageName + ').'); end; except on E: Exception do begin if FIsKorean then ShowCustomAlert('앱 실행 실패', 'Launch Error', '앱 실행 중 오류가 발생했습니다.'#13#10'오류: ' + E.Message, '') else ShowCustomAlert('Launch Error', 'Launch Error', '', 'Cannot launch app.'#13#10'Error: ' + E.Message); end; end; {$ENDIF} {$ELSE} // Mac/Linux는 버튼의 Hint 속성(AppPackageName 변수)에 저장된 URL을 VLC로 실행 {$IFDEF MACOS} Cmd := 'open -a VLC "' + AppPackageName + '"'; _system(PAnsiChar(UTF8Encode(Cmd))); {$ENDIF} {$IFDEF LINUX} Cmd := 'vlc "' + AppPackageName + '" &'; _system(PAnsiChar(UTF8Encode(Cmd))); {$ENDIF} {$ENDIF} end else begin if FIsKorean then ShowCustomAlert('오류', 'Error', '선택된 앱 정보가 유효하지 않습니다.', '') else ShowCustomAlert('오류', 'Error', '', 'Selected app info is invalid.'); end; end; end; procedure TfrmMain.ShowCustomAlert(const ATitleKO, ATitleEN, AMessageKO, AMessageEN: string); begin if FIsKorean then begin lblModalTitle.Text := ATitleKO; lblModalMessage.Text := AMessageKO; end else begin lblModalTitle.Text := ATitleEN; lblModalMessage.Text := AMessageEN; end; // 안드로이드에서 텍스트가 짤리거나 겹치는 현상을 방지하기 위해 명시적으로 설정 lblModalMessage.WordWrap := True; lblModalMessage.AutoSize := False; lblModalMessage.TextSettings.VertAlign := TTextAlign.Center; // 팝업창 크기 동적 조절 (글자 수에 비례하여 크기 증가) if lblModalMessage.Parent is TControl then begin var Panel := TControl(lblModalMessage.Parent); // 기본 크기 설정 Panel.Height := 250; lblModalMessage.Height := 120; // 긴 메세지일 경우 창 세로 길이를 더 늘려줌 if Length(lblModalMessage.Text) > 30 then begin Panel.Height := 350; lblModalMessage.Height := 200; end; {$IFDEF ANDROID} // 안드로이드에서는 텍스트 크기 스케일링으로 인해 잘릴 수 있으므로 높이를 크게 늘림 // 추가적인 여백 확보 Panel.Height := Panel.Height + 150; lblModalMessage.Height := lblModalMessage.Height + 150; {$ENDIF} end; rectModalOverlay.Visible := True; rectModalOverlay.BringToFront; end; procedure TfrmMain.OnShowPathClick(Sender: TObject); var S: string; begin S := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'SmartFarmHMI_settings.ini'); ShowCustomAlert('저장 경로 안내', 'INI File Path', 'INI 파일 저장 경로:'#13#10 + S, 'INI Settings Path:'#13#10 + S); end; procedure TfrmMain.AddSystemLog(const AMessage: string); var LogDir, LogFile: string; LogLine: string; begin LogDir := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'Logs'); if not DirectoryExists(LogDir) then ForceDirectories(LogDir); LogFile := System.IOUtils.TPath.Combine(LogDir, 'SmartFarmHMI_' + FormatDateTime('yyyymmdd', Now) + '.log'); LogLine := '[' + FormatDateTime('yyyy-mm-dd hh:nn:ss', Now) + '] ' + AMessage; try System.IOUtils.TFile.AppendAllText(LogFile, LogLine + sLineBreak, TEncoding.UTF8); except // 로그 파일 접근 예외 무시 end; end; procedure TfrmMain.OnRefreshLogsClick(Sender: TObject); var LogFile: string; LogDir: string; TempList: TStringList; i: Integer; TargetDate: TDateTime; begin if (memoLogs = nil) or (dateLog = nil) then Exit; memoLogs.Lines.Clear; TargetDate := dateLog.Date; LogDir := System.IOUtils.TPath.Combine(System.IOUtils.TPath.GetHomePath, 'Logs'); LogFile := System.IOUtils.TPath.Combine(LogDir, 'SmartFarmHMI_' + FormatDateTime('yyyymmdd', TargetDate) + '.log'); if FileExists(LogFile) then begin TempList := TStringList.Create; try try TempList.LoadFromFile(LogFile, TEncoding.UTF8); memoLogs.Lines.BeginUpdate; try for i := TempList.Count - 1 downto 0 do begin if Trim(TempList[i]) <> '' then memoLogs.Lines.Add(TempList[i]); end; finally memoLogs.Lines.EndUpdate; end; except memoLogs.Lines.Add('Failed to load log file.'); end; finally TempList.Free; end; end else begin if FIsKorean then memoLogs.Lines.Add(FormatDateTime('yyyy-mm-dd', TargetDate) + ' 날짜의 로그가 없습니다.') else memoLogs.Lines.Add('No logs available for ' + FormatDateTime('yyyy-mm-dd', TargetDate)); end; end; procedure TfrmMain.BuildLogUI; var lytLogHeader: TLayout; begin // 화면 레이아웃 lytScreenLogs := TLayout.Create(Self); lytScreenLogs.Parent := lytMainContent; lytScreenLogs.Align := TAlignLayout.Client; lytScreenLogs.Visible := False; // 상단 헤더 (날짜 선택 및 새로고침) lytLogHeader := TLayout.Create(Self); lytLogHeader.Parent := lytScreenLogs; lytLogHeader.Align := TAlignLayout.Top; lytLogHeader.Height := 70; lytLogHeader.Margins.Rect := RectF(0, 10, 0, 10); lblLogDate := BuildLabel(lytLogHeader, '로그 날짜 선택:', 26, TAlignLayout.None, claWhite, True); lblLogDate.SetBounds(0, 0, 240, 70); dateLog := BuildDateEdit(lytLogHeader, Now, 32, TAlignLayout.None, claBlack, False); dateLog.SetBounds(240, 10, 250, 50); {$IFDEF ANDROID} dateLog.TextSettings.FontColor := claWhite; {$ENDIF} dateLog.OnChange := OnRefreshLogsClick; // 날짜 변경 시 자동 새로고침 btnRefreshLogs := CreateCustomButton(lytLogHeader, '로그 불러오기 (Load)', TAlignLayout.None, 930, OnRefreshLogsClick); btnRefreshLogs.SetBounds(540, 10, 930, 50); TLabel(btnRefreshLogs.Controls[0]).TextSettings.Font.Size := 26; memoLogs := TMemo.Create(Self); memoLogs.Parent := lytScreenLogs; memoLogs.Align := TAlignLayout.Client; memoLogs.TextSettings.Font.Size := 16; memoLogs.ReadOnly := True; memoLogs.StyledSettings := memoLogs.StyledSettings - [TStyledSetting.FontColor, TStyledSetting.Size]; // memoLogs.WordWrap := True; // TMemo WordWrap depends on Delphi version, it's safe not to enforce it heavily if not needed for plain text end; procedure TfrmMain.TimerTimeTimer(Sender: TObject); begin lblDateTime.Text := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now); end; procedure TfrmMain.TimerStartAutoCKTimer(Sender: TObject); begin if FAutoMode then begin TimerStartAutoCK.Enabled := false; SaveAutoRules; LoadAutoRules; UpdateAutoModeBtnUI; TimerAuto.Enabled := FAutoMode; end else TimerStartAutoCK.Enabled := false; end; procedure TfrmMain.OnMQTTStatus(AConnected: Boolean); begin if AConnected then begin lblConnectionStatus.Tag := 1; lblConnectionStatus.TextSettings.FontColor := $FF4CAF50; if FIsKorean then lblConnectionStatus.Text := '연결됨' else lblConnectionStatus.Text := 'Connected'; AddSystemLog('시스템 설정 변경: MQTT Broker 접속정보 [ 연결됨 ] 수정됨'); TimerStartAutoCK.Enabled := True; end else begin FMQTTMessageRE := False; lblConnectionStatus.Tag := 2; lblConnectionStatus.TextSettings.FontColor := $FFFF5252; if FIsKorean then lblConnectionStatus.Text := '연결 끊김' else lblConnectionStatus.Text := 'Disconnected'; AddSystemLog('시스템 설정 변경: MQTT Broker 접속정보 [ 연결 끊김 ] 수정됨'); end; end; function TfrmMain.ParsePipeValue(const AData: string; AIndex: Integer): Double; var Parts: TArray; begin Result := 0; Parts := AData.Split(['|']); if (AIndex >= 0) and (AIndex < Length(Parts)) then begin if not TryStrToFloat(Parts[AIndex], Result) then Result := 0; end; Result := Result / 10.0; end; procedure TfrmMain.PublishControl(AIndex: Integer; AOn: Boolean); var DOKey, DOVal, MsgPayload: string; begin DOKey := Format('DO_%2.2d', [AIndex]); if AOn then DOVal := '1' else DOVal := '0'; MsgPayload := '{"' + DOKey + '":"' + DOVal + '"}'; if (FMQTTClient <> nil) and FMQTTClient.Connected then begin FMQTTClient.Publish(FMQTTTopicHEAD+'SUB/'+FMQTTTopicTAIL+FMQTTGateWayID, MsgPayload); AddSystemLog('제어 명령 전송 - ' + DOKey + ': ' + (if AOn then 'ON' else 'OFF')); end; end; procedure TfrmMain.OnMQTTMessage(const ATopic, APayload: string); var JObj: TJSONObject; Val, strN: string; i, j: Integer; begin try JObj := TJSONObject.ParseJSONValue(APayload) as TJSONObject; if JObj = nil then Exit; try // Apply updates matching node configurations dynamically for i := 0 to High(FNodeConfigs) do begin if not FNodeConfigs[i].InUse then Continue; if (FNodeConfigs[i].NodeType = 'DO') or (FNodeConfigs[i].NodeType = 'DI') then begin if JObj.TryGetValue(FNodeConfigs[i].NodeType, Val) then begin FUpdatingControls := True; try for j := 0 to High(Controls) do begin if Controls[j].NodeID = FNodeConfigs[i].ID then begin Controls[j].UpdateStatus((ParsePipeValue(Val, FNodeConfigs[i].Index) * 10) > 0); if FNodeConfigs[i].NodeType = 'DI' then begin strN := FNodeConfigs[i].CustomName + FNodeConfigs[i].TitleKO + FNodeConfigs[i].TitleEN; if (strN.Contains('유량')) or (strN.Contains('Flow')) then begin if (ParsePipeValue(Val, FNodeConfigs[i].Index) * 10) > 0 then begin FSensorValues[i] := FSensorValues[i] + (ParsePipeValue(Val, FNodeConfigs[i].Index) * 10); FSensorValuesTotalFlow[i] := FSensorValuesTotalFlow[i] + (ParsePipeValue(Val, FNodeConfigs[i].Index) * 10); AddSystemLog('유량계 (펄스입력) [' + FNodeConfigs[i].ID +'/'+FloatToStr(FSensorValues[i])+'/'+FloatToStr(FSensorValuesTotalFlow[i])+ '] -> ' + FloatToStr(ParsePipeValue(Val, FNodeConfigs[i].Index) * 10)); // 추후 삭제 (펄스값 확인용) if Label1.Visible then begin Label1.Text := FloatToStr(ParsePipeValue(Val, FNodeConfigs[i].Index) * 10); Label2.Text := FloatToStr(FSensorValues[i]); Label3.Text := FloatToStr(FSensorValuesTotalFlow[i]); end; end else begin // 추후 삭제 (펄스값 확인용) if Label1.Visible then begin Label1.Text := FloatToStr(ParsePipeValue(Val, FNodeConfigs[i].Index) * 10); Label2.Text := FloatToStr(FSensorValues[i]); Label3.Text := FloatToStr(FSensorValuesTotalFlow[i]); end; end; end; end; if FloatToStr(FSensorValues[i]) <> FloatToStr((ParsePipeValue(Val, FNodeConfigs[i].Index) * 10)) then begin if FNodeConfigs[i].NodeType = 'DO' then begin AddSystemLog('장비 상태 변경 [' + FNodeConfigs[i].ID +'/'+FloatToStr(FSensorValues[i])+'/'+FloatToStr(ParsePipeValue(Val, FNodeConfigs[i].Index) * 10)+ '] -> ' + (if (ParsePipeValue(Val, FNodeConfigs[i].Index) * 10) > 0 then 'ON' else 'OFF')); FSensorValues[i] := (ParsePipeValue(Val, FNodeConfigs[i].Index) * 10); end else if FNodeConfigs[i].NodeType = 'DI' then begin strN := FNodeConfigs[i].CustomName + FNodeConfigs[i].TitleKO + FNodeConfigs[i].TitleEN; if not ((strN.Contains('유량')) or (strN.Contains('Flow'))) then begin AddSystemLog('입력 상태 변경 [' + FNodeConfigs[i].ID +'/'+FloatToStr(FSensorValues[i])+'/'+FloatToStr(ParsePipeValue(Val, FNodeConfigs[i].Index) * 10)+ '] -> ' + (if (ParsePipeValue(Val, FNodeConfigs[i].Index) * 10) > 0 then 'ON' else 'OFF')); FSensorValues[i] := (ParsePipeValue(Val, FNodeConfigs[i].Index) * 10); end; end; end; // Update pending status check if FControlPending[FNodeConfigs[i].Index] then begin if ((ParsePipeValue(Val, FNodeConfigs[i].Index) * 10) > 0) = FControlDesired[FNodeConfigs[i].Index] then FControlPending[FNodeConfigs[i].Index] := False; end; Break; end; end; finally FUpdatingControls := False; end; end; end else begin if JObj.TryGetValue(FNodeConfigs[i].NodeType, Val) then begin for j := 0 to High(Sensors) do begin if Sensors[j].NodeID = FNodeConfigs[i].ID then begin if FSensorValues[i] <> ParsePipeValue(Val, FNodeConfigs[i].Index) then FSensorValues[i] := ParsePipeValue(Val, FNodeConfigs[i].Index); Sensors[j].UpdateValue(ParsePipeValue(Val, FNodeConfigs[i].Index)); Break; end; end; end; end; end; FMQTTMessageRE := True; finally JObj.Free; end; except end; end; procedure TfrmMain.InitNodeConfigs; var curIdx: Integer; procedure AddNodes(const AType, AKo, AEn: string; ACount: Integer; IsZeroBased: Boolean = True); var j, idxNum: Integer; begin for j := 0 to ACount - 1 do begin if IsZeroBased then idxNum := j else idxNum := j + 1; FNodeConfigs[curIdx].ID := Format('%s_%2.2d', [AType, idxNum]); FNodeConfigs[curIdx].NodeType := AType; FNodeConfigs[curIdx].Index := idxNum; FNodeConfigs[curIdx].TitleKO := Format('%s %d', [AKo, j + 1]); FNodeConfigs[curIdx].TitleEN := Format('%s %d', [AEn, j + 1]); FNodeConfigs[curIdx].CustomName := ''; FNodeConfigs[curIdx].InUse := False; Inc(curIdx); end; end; begin SetLength(FNodeConfigs, Init_DI + Init_DO + Init_AI + Init_HM + Init_TM + Init_CD + Init_NT + Init_PH + Init_GT + Init_GM + Init_GNT + Init_WP + Init_GT2); curIdx := 0; AddNodes('DI', '입력', 'Digital In', Init_DI, True); AddNodes('DO', '출력', 'Digital Out', Init_DO, True); AddNodes('AI', '아날로그', 'Analog In', Init_AI, True); AddNodes('HM', '습도', 'Humidity', Init_HM, True); AddNodes('TM', '온도', 'Temperature', Init_TM, True); AddNodes('CD', 'CO2', 'CO2', Init_CD, True); AddNodes('NT', 'EC', 'EC', Init_NT, True); AddNodes('PH', 'pH', 'pH', Init_PH, True); AddNodes('GT', '토양 온도 1', 'Soil Temp 1', Init_GT, True); AddNodes('GM', '토양 습도', 'Soil Moisture', Init_GM, True); AddNodes('GNT', '토양 EC', 'Soil EC', Init_GNT, True); AddNodes('WP', '토양 pH', 'Soil pH', Init_WP, True); AddNodes('GT2', '토양 온도 2', 'Soil Temp 2', Init_GT2, True); end; procedure TfrmMain.BuildNodeSettingsUI; var i, j: Integer; ItemLyt, HeaderLyt: TLayout; Lbl, HeaderLbl: TLabel; CurrentType: string; SelTxt: string; LItem: TListBoxItem; begin if lytNodesMain = nil then Exit; while lytNodesMain.ControlsCount > 0 do lytNodesMain.Controls[0].Free; CurrentType := ''; for i := 0 to High(FNodeConfigs) do begin if CurrentType <> FNodeConfigs[i].NodeType then begin CurrentType := FNodeConfigs[i].NodeType; // FMX FlowLayout Break before group var LBreakBefore := TFlowLayoutBreak.Create(lytNodesMain); LBreakBefore.Parent := lytNodesMain; LBreakBefore.Tag := -2; // Identifier for layour breaks // Inject Group Header HeaderLyt := TLayout.Create(lytNodesMain); HeaderLyt.Parent := lytNodesMain; HeaderLyt.Tag := -1; // Identifier for header layout HeaderLyt.Height := 50; HeaderLyt.Width := 1000; HeaderLyt.Margins.Rect := RectF(5, 20, 5, 20); // Spacing before and after new group HeaderLbl := TLabel.Create(HeaderLyt); HeaderLbl.Parent := HeaderLyt; HeaderLbl.Align := TAlignLayout.Client; HeaderLbl.TextSettings.Font.Size := 28; HeaderLbl.TextSettings.Font.Style := [TFontStyle.fsBold]; HeaderLbl.TextSettings.FontColor := $FF4CAF50; // Green emphasis HeaderLbl.StyledSettings := HeaderLbl.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor, TStyledSetting.Style]; HeaderLbl.TextSettings.HorzAlign := TTextAlign.Leading; HeaderLbl.TextSettings.VertAlign := TTextAlign.Trailing; // Align text to bottom of header HeaderLbl.Text := GetGroupName(CurrentType, FIsKorean); HeaderLbl.Hint := CurrentType; // Store node type for later translation var LBreakAfter := TFlowLayoutBreak.Create(lytNodesMain); LBreakAfter.Parent := lytNodesMain; LBreakAfter.Tag := -2; // identifier for layout break end; ItemLyt := TLayout.Create(lytNodesMain); ItemLyt.Parent := lytNodesMain; ItemLyt.Width := 700; ItemLyt.Height := 80; FNodeConfigs[i].SettingRect := BuildWidgetBg(ItemLyt); FNodeConfigs[i].SettingRect.Parent := ItemLyt; FNodeConfigs[i].SettingRect.Align := TAlignLayout.Client; FNodeConfigs[i].SettingRect.Margins.Rect := RectF(5, 5, 5, 5); Lbl := TLabel.Create(FNodeConfigs[i].SettingRect); Lbl.Parent := FNodeConfigs[i].SettingRect; Lbl.Align := TAlignLayout.Client; Lbl.Margins.Rect := RectF(15, 0, 10, 0); Lbl.StyledSettings := Lbl.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor, TStyledSetting.Style]; Lbl.TextSettings.Font.Size := 24; Lbl.TextSettings.FontColor := claWhite; Lbl.TextSettings.HorzAlign := TTextAlign.Leading; if FIsKorean then Lbl.Text := FNodeConfigs[i].TitleKO + ' [' + FNodeConfigs[i].ID + ']' else Lbl.Text := FNodeConfigs[i].TitleEN + ' [' + FNodeConfigs[i].ID + ']'; FNodeConfigs[i].SettingEdit := TEdit.Create(FNodeConfigs[i].SettingRect); FNodeConfigs[i].SettingEdit.Parent := FNodeConfigs[i].SettingRect; FNodeConfigs[i].SettingEdit.Align := TAlignLayout.Right; FNodeConfigs[i].SettingEdit.Margins.Rect := RectF(0, 15, 15, 15); FNodeConfigs[i].SettingEdit.Width := 170; FNodeConfigs[i].SettingEdit.Tag := i; FNodeConfigs[i].SettingEdit.Text := FNodeConfigs[i].CustomName; FNodeConfigs[i].SettingEdit.TextPrompt := '위젯명 (선택)'; FNodeConfigs[i].SettingEdit.StyledSettings := FNodeConfigs[i].SettingEdit.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; FNodeConfigs[i].SettingEdit.TextSettings.Font.Size := 24; {$IFDEF ANDROID} FNodeConfigs[i].SettingEdit.TextSettings.FontColor := claWhite; {$ENDIF} FNodeConfigs[i].SettingEdit.OnExit := OnNodeNameExit; FNodeConfigs[i].SettingIconCombo := TComboBox.Create(FNodeConfigs[i].SettingRect); FNodeConfigs[i].SettingIconCombo.Parent := FNodeConfigs[i].SettingRect; FNodeConfigs[i].SettingIconCombo.Align := TAlignLayout.Right; FNodeConfigs[i].SettingIconCombo.Margins.Rect := RectF(0, 10, 10, 10); FNodeConfigs[i].SettingIconCombo.Width := 110; FNodeConfigs[i].SettingIconCombo.Tag := i; FNodeConfigs[i].SettingIconCombo.ItemHeight := 24 * 1.8; {$IFNDEF ANDROID} FNodeConfigs[i].SettingIconCombo.DropDownKind := TDropDownKind.Custom; {$ENDIF} // Create Custom Styled Dropdown Items for j := 0 to 12 do begin case j of 0: SelTxt := '기본'; 1: SelTxt := '🎛️'; 2: SelTxt := '🌡️'; 3: SelTxt := '💧'; 4: SelTxt := '💨'; 5: SelTxt := '☀️'; 6: SelTxt := '🌱'; 7: SelTxt := '⚡'; 8: SelTxt := '🧪'; 9: SelTxt := '📊'; 10: SelTxt := '📡'; 11: SelTxt := '💡'; 12: SelTxt := '🔔'; end; LItem := TListBoxItem.Create(FNodeConfigs[i].SettingIconCombo); LItem.Parent := FNodeConfigs[i].SettingIconCombo; LItem.Text := SelTxt; LItem.Height := 24 * 1.8; LItem.StyledSettings := LItem.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; LItem.TextSettings.Font.Size := 24; {$IFDEF ANDROID} LItem.TextSettings.FontColor := claWhite; {$ENDIF} FNodeConfigs[i].SettingIconCombo.AddObject(LItem); end; if FNodeConfigs[i].CustomIcon = '' then FNodeConfigs[i].SettingIconCombo.ItemIndex := 0 else FNodeConfigs[i].SettingIconCombo.ItemIndex := FNodeConfigs[i].SettingIconCombo.Items.IndexOf(FNodeConfigs[i].CustomIcon); FNodeConfigs[i].SettingIconCombo.OnChange := OnNodeIconChange; FNodeConfigs[i].SettingSwitch := TSwitch.Create(FNodeConfigs[i].SettingRect); FNodeConfigs[i].SettingSwitch.Parent := FNodeConfigs[i].SettingRect; FNodeConfigs[i].SettingSwitch.Align := TAlignLayout.Right; FNodeConfigs[i].SettingSwitch.Margins.Rect := RectF(0, 10, 15, 10); FNodeConfigs[i].SettingSwitch.Width := 100; {$IFDEF ANDROID} FNodeConfigs[i].SettingSwitch.Scale.X := 1.5; FNodeConfigs[i].SettingSwitch.Scale.Y := 1.5; {$ENDIF} FNodeConfigs[i].SettingSwitch.Tag := i; FNodeConfigs[i].SettingSwitch.IsChecked := FNodeConfigs[i].InUse; FNodeConfigs[i].SettingSwitch.OnSwitch := OnNodeSettingToggle; end; end; procedure TfrmMain.RebuildDashboardUI; var i: Integer; DefIcon: string; begin if lytWidgets = nil then Exit; while lytWidgets.ControlsCount > 0 do lytWidgets.Controls[0].Free; SetLength(Sensors, 0); SetLength(Controls, 0); for i := 0 to High(FNodeConfigs) do begin if FNodeConfigs[i].InUse then begin DefIcon := '📡'; // Base fallback if (FNodeConfigs[i].NodeType = 'DO') or (FNodeConfigs[i].NodeType = 'DI') then DefIcon := '🎛️' else if FNodeConfigs[i].NodeType = 'TM' then DefIcon := '🌡️' else if FNodeConfigs[i].NodeType = 'HM' then DefIcon := '💧' else if FNodeConfigs[i].NodeType = 'CD' then DefIcon := '💨' else if (FNodeConfigs[i].NodeType = 'GT') or (FNodeConfigs[i].NodeType = 'GT2') then DefIcon := '🌡️' else if FNodeConfigs[i].NodeType = 'GM' then DefIcon := '🌱' else if (FNodeConfigs[i].NodeType = 'NT') or (FNodeConfigs[i].NodeType = 'GNT') then DefIcon := '⚡' else if (FNodeConfigs[i].NodeType = 'PH') or (FNodeConfigs[i].NodeType = 'WP') then DefIcon := '🧪' else if FNodeConfigs[i].NodeType = 'AI' then DefIcon := '📊'; // Override with custom icon if provided if FNodeConfigs[i].CustomIcon <> '' then DefIcon := FNodeConfigs[i].CustomIcon; if (FNodeConfigs[i].NodeType = 'DO') or (FNodeConfigs[i].NodeType = 'DI') then AddControl(FNodeConfigs[i], DefIcon) else begin if FNodeConfigs[i].NodeType = 'TM' then AddSensor(FNodeConfigs[i], DefIcon, '°C', 50.0) else if FNodeConfigs[i].NodeType = 'HM' then AddSensor(FNodeConfigs[i], DefIcon, '%', 100.0) else if FNodeConfigs[i].NodeType = 'CD' then AddSensor(FNodeConfigs[i], DefIcon, 'ppm', 2000.0) else if FNodeConfigs[i].NodeType = 'GT' then AddSensor(FNodeConfigs[i], DefIcon, '°C', 50.0) //°C else if FNodeConfigs[i].NodeType = 'GM' then AddSensor(FNodeConfigs[i], DefIcon, '%', 100.0) else if (FNodeConfigs[i].NodeType = 'NT') or (FNodeConfigs[i].NodeType = 'GNT') then AddSensor(FNodeConfigs[i], DefIcon, 'dS/m', 10.0) else if (FNodeConfigs[i].NodeType = 'PH') or (FNodeConfigs[i].NodeType = 'WP') then AddSensor(FNodeConfigs[i], DefIcon, 'pH', 14.0) else if FNodeConfigs[i].NodeType = 'AI' then AddSensor(FNodeConfigs[i], DefIcon, 'V', 10.0) else if FNodeConfigs[i].NodeType = 'GT2' then AddSensor(FNodeConfigs[i], DefIcon, '°C', 50.0) else AddSensor(FNodeConfigs[i], DefIcon, '', 100.0); end; end; end; FormResize(Self); ApplyLanguage; // Always update labels after rebuilding widgets end; procedure TfrmMain.OnNodeSettingToggle(Sender: TObject); var Idx: Integer; begin if (Sender is TSwitch) and not FUpdatingControls then begin Idx := TSwitch(Sender).Tag; if (Idx >= 0) and (Idx <= High(FNodeConfigs)) then begin FNodeConfigs[Idx].InUse := TSwitch(Sender).IsChecked; SaveSetting_each('bool', 'Nodes', FNodeConfigs[Idx].ID, BoolToStr(FNodeConfigs[Idx].InUse)); RebuildDashboardUI; // ApplyLanguage is called within RebuildDashboardUI AddSystemLog('시스템 설정 변경: 노드 사용설정 변경 [' + FNodeConfigs[Idx].ID +' / '+BoolToStr(FNodeConfigs[Idx].InUse)+ '] 사용 속성 수정됨'); end; end; end; procedure TfrmMain.OnNodeNameExit(Sender: TObject); var Idx: Integer; begin if Sender is TEdit then begin Idx := TEdit(Sender).Tag; if (Idx >= 0) and (Idx <= High(FNodeConfigs)) then begin if FNodeConfigs[Idx].CustomName <> Trim(TEdit(Sender).Text) then begin FNodeConfigs[Idx].CustomName := Trim(TEdit(Sender).Text); SaveSetting_each('string', 'NodeNames', FNodeConfigs[Idx].ID, FNodeConfigs[Idx].CustomName); RebuildDashboardUI; ApplyLanguage; // Update immediate UI texts AddSystemLog('시스템 설정 변경: 노드 위젯명 변경 [' + FNodeConfigs[Idx].ID +' / '+FNodeConfigs[Idx].CustomName+ '] 위젯명 수정됨'); end; end; end; end; procedure TfrmMain.OnNodeIconChange(Sender: TObject); var Idx: Integer; Cb: TComboBox; SelTxt: string; begin if Sender is TComboBox then begin Cb := TComboBox(Sender); Idx := Cb.Tag; if (Idx >= 0) and (Idx <= High(FNodeConfigs)) then begin if Cb.ItemIndex > 0 then SelTxt := Cb.Items[Cb.ItemIndex] else SelTxt := ''; // Default if FNodeConfigs[Idx].CustomIcon <> SelTxt then begin FNodeConfigs[Idx].CustomIcon := SelTxt; SaveSetting_each('integer', 'NodeIcons', FNodeConfigs[Idx].ID, IntToStr(GetIconIndex(FNodeConfigs[Idx].CustomIcon))); RebuildDashboardUI; // ApplyLanguage is called within RebuildDashboardUI AddSystemLog('시스템 설정 변경: 노드 아이콘 변경 [' + FNodeConfigs[Idx].ID +' / '+SelTxt+ '] 아이콘 변경됨'); end; end; end; end; procedure TfrmMain.BuildMQTTSetupPopupUI; var Bg, Dlg: TRectangle; BottomLyt: TLayout; BtnAddClose: TRectangle; EdGateWayID, EdBrokerIP, EdBrokerPORT, EdUserID, EdUserPASS, EdTopicHEAD, EdTopicTAIL: TEdit; begin lytMQTTSetupPopup := TLayout.Create(Self); lytMQTTSetupPopup.Parent := lytMainContent; lytMQTTSetupPopup.Align := TAlignLayout.Client; lytMQTTSetupPopup.Visible := False; Bg := TRectangle.Create(Self); Bg.Parent := lytMQTTSetupPopup; 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 := lytMQTTSetupPopup; Dlg.Align := TAlignLayout.Center; Dlg.Width := 750; Dlg.Height := 800; Dlg.Fill.Color := $FF222222; Dlg.Stroke.Color := $FF444444; Dlg.XRadius := 10; Dlg.YRadius := 10; lblMQTTSetupTitle := BuildLabel(Dlg, '연결 설정 [ MQTTBroker Setting ]', 24, TAlignLayout.Top, claWhite, True); lblMQTTSetupTitle.Height := 65; lblMQTTSetupTitle.Margins.Rect := RectF(16, 16, 16, 0); scrMQTTSetup := TScrollBox.Create(Self); scrMQTTSetup.Parent := Dlg; scrMQTTSetup.Align := TAlignLayout.Client; scrMQTTSetup.Margins.Rect := RectF(16, 16, 16, 16); // ── MQTTBroker Setting - GateWayID ────────────────────────────────────────────── BuildLabel(Dlg, 'GateWayID :', 22, TAlignLayout.None, claLightgray, False).SetBounds(50, 120, 150, 30); EdGateWayID := TEdit.Create(Self); EdGateWayID.Parent := Dlg; EdGateWayID.SetBounds(250, 120, 400, 30); EdGateWayID.Text := FMQTTGateWayID; EdGateWayID.Tag := 1; EdGateWayID.TagFloat := 1; EdGateWayID.StyledSettings := EdGateWayID.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdGateWayID.TextSettings.Font.Size := 22; {$IFDEF ANDROID} EdGateWayID.TextSettings.FontColor := claWhite; {$ENDIF} EdGateWayID.OnExit := OnMQTTSetupEdit; // ── MQTTBroker Setting - IP ────────────────────────────────────────────── BuildLabel(Dlg, 'IP :', 22, TAlignLayout.None, claLightgray, False).SetBounds(50, 190, 150, 30); EdBrokerIP := TEdit.Create(Self); EdBrokerIP.Parent := Dlg; EdBrokerIP.SetBounds(250, 190, 400, 30); EdBrokerIP.Text := FMQTTBrokerIP; EdBrokerIP.Tag := 2; EdBrokerIP.TagFloat := 2; EdBrokerIP.StyledSettings := EdBrokerIP.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdBrokerIP.TextSettings.Font.Size := 22; {$IFDEF ANDROID} EdBrokerIP.TextSettings.FontColor := claWhite; {$ENDIF} EdBrokerIP.OnExit := OnMQTTSetupEdit; // ── MQTTBroker Setting - PORT ────────────────────────────────────────────── BuildLabel(Dlg, 'PORT :', 22, TAlignLayout.None, claLightgray, False).SetBounds(50, 260, 150, 30); EdBrokerPORT := TEdit.Create(Self); EdBrokerPORT.Parent := Dlg; EdBrokerPORT.SetBounds(250, 260, 400, 30); EdBrokerPORT.Text := IntToStr(FMQTTBrokerPORT); EdBrokerPORT.Tag := 3; EdBrokerPORT.TagFloat := 3; EdBrokerPORT.StyledSettings := EdBrokerPORT.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdBrokerPORT.TextSettings.Font.Size := 22; {$IFDEF ANDROID} EdBrokerPORT.TextSettings.FontColor := claWhite; {$ENDIF} EdBrokerPORT.OnExit := OnMQTTSetupEdit; // ── MQTTBroker Setting - USERID ────────────────────────────────────────────── BuildLabel(Dlg, 'USERID :', 22, TAlignLayout.None, claLightgray, False).SetBounds(50, 330, 150, 30); EdUserID := TEdit.Create(Self); EdUserID.Parent := Dlg; EdUserID.SetBounds(250, 330, 400, 30); EdUserID.Text := FMQTTUserID; EdUserID.Tag := 4; EdUserID.TagFloat := 4; EdUserID.StyledSettings := EdUserID.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdUserID.TextSettings.Font.Size := 22; {$IFDEF ANDROID} EdUserID.TextSettings.FontColor := claWhite; {$ENDIF} EdUserID.OnExit := OnMQTTSetupEdit; // ── MQTTBroker Setting - USERPASS ────────────────────────────────────────────── BuildLabel(Dlg, 'USERPASS :', 22, TAlignLayout.None, claLightgray, False).SetBounds(50, 400, 150, 30); EdUserPASS := TEdit.Create(Self); EdUserPASS.Parent := Dlg; EdUserPASS.SetBounds(250, 400, 400, 30); EdUserPASS.Text := FMQTTUserPASS; EdUserPASS.PasswordChar := '*'; EdUserPASS.Password := True; EdUserPASS.Tag := 5; EdUserPASS.TagFloat := 5; EdUserPASS.StyledSettings := EdUserPASS.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdUserPASS.TextSettings.Font.Size := 22; {$IFDEF ANDROID} EdUserPASS.TextSettings.FontColor := claWhite; {$ENDIF} EdUserPASS.OnExit := OnMQTTSetupEdit; // ── MQTTBroker Setting - TopicHEAD ────────────────────────────────────────────── BuildLabel(Dlg, 'TopicHEAD :', 22, TAlignLayout.None, claLightgray, False).SetBounds(50, 470, 150, 30); EdTopicHEAD := TEdit.Create(Self); EdTopicHEAD.Parent := Dlg; EdTopicHEAD.SetBounds(250, 470, 400, 30); EdTopicHEAD.Text := FMQTTTopicHEAD; EdTopicHEAD.Tag := 6; EdTopicHEAD.TagFloat := 6; EdTopicHEAD.StyledSettings := EdTopicHEAD.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdTopicHEAD.TextSettings.Font.Size := 22; {$IFDEF ANDROID} EdTopicHEAD.TextSettings.FontColor := claWhite; {$ENDIF} EdTopicHEAD.OnExit := OnMQTTSetupEdit; // ── MQTTBroker Setting - TopicTAIL ────────────────────────────────────────────── BuildLabel(Dlg, 'TopicTAIL :', 22, TAlignLayout.None, claLightgray, False).SetBounds(50, 540, 150, 30); EdTopicTAIL := TEdit.Create(Self); EdTopicTAIL.Parent := Dlg; EdTopicTAIL.SetBounds(250, 540, 400, 30); EdTopicTAIL.Text := FMQTTTopicTAIL; EdTopicTAIL.Tag := 7; EdTopicTAIL.TagFloat := 7; EdTopicTAIL.StyledSettings := EdTopicTAIL.StyledSettings - [TStyledSetting.Size, TStyledSetting.FontColor]; EdTopicTAIL.TextSettings.Font.Size := 22; {$IFDEF ANDROID} EdTopicTAIL.TextSettings.FontColor := claWhite; {$ENDIF} EdTopicTAIL.OnExit := OnMQTTSetupEdit; BottomLyt := TLayout.Create(Self); BottomLyt.Parent := Dlg; BottomLyt.Align := TAlignLayout.Bottom; BottomLyt.Height := 80; BtnAddClose := CreateCustomButton(BottomLyt, '저장/닫기 [ Save/Close ]', TAlignLayout.Client, 664, OnMQTTSetupSaveClose); TLabel(BtnAddClose.Controls[0]).TextSettings.Font.Size := 20; BtnAddClose.Margins.Rect := RectF(16, 0, 16, 16); end; procedure TfrmMain.OnMQTTSetupEdit(Sender: TObject); var FieldID: Integer; begin // if FRefreshingAutoUI then Exit; FieldID := -1; if Sender is TEdit then begin FieldID := Trunc((Sender as TEdit).TagFloat); end; case FieldID of 1: if Sender is TEdit then FMQTTGateWayID := (Sender as TEdit).Text; 2: if Sender is TEdit then FMQTTBrokerIP := (Sender as TEdit).Text; 3: if Sender is TEdit then FMQTTBrokerPORT := StrToIntDef((Sender as TEdit).Text, 1883); 4: if Sender is TEdit then FMQTTUserID := (Sender as TEdit).Text; 5: if Sender is TEdit then FMQTTUserPASS := (Sender as TEdit).Text; 6: if Sender is TEdit then FMQTTTopicHEAD := (Sender as TEdit).Text; 7: if Sender is TEdit then FMQTTTopicTAIL := (Sender as TEdit).Text; end; SaveSettings_MQTT; AddSystemLog('시스템 설정 변경: MQTT Broker 정보 ['+(Sender as TEdit).Text+'] 수정됨'); end; procedure TfrmMain.OnMQTTSetupSaveClose(Sender: TObject); var Act: TIrriAction; begin // SaveSettings_MQTT; lytMQTTSetupPopup.Visible := False; if FMQTTClient <> nil then begin FMQTTClient.OnMessage := nil; try FMQTTClient.Disconnect; except end; Sleep(100); FMQTTClient.OnStatus := nil; FreeAndNil(FMQTTClient); // MQTT 클라이언트 초기화 및 연결 FMQTTClient := TMQTTClient.Create(FMQTTBrokerIP, FMQTTBrokerPORT, 'SmartFarmHMI'); FMQTTClient.OnMessage := OnMQTTMessage; FMQTTClient.OnStatus := OnMQTTStatus; FMQTTClient.Subscribe(FMQTTTopicHEAD+'PUB/'+FMQTTTopicTAIL+FMQTTGateWayID); if FIsKorean then lblConnectionStatus.Text := '연결중...' else lblConnectionStatus.Text := 'Connecting...'; FMQTTClient.Connect; AddSystemLog('시스템 MQTT 재연결 : MQTT Broker 정보 ['+FMQTTGateWayID+' / '+ FMQTTBrokerIP+' / '+ IntToStr(FMQTTBrokerPORT)+' / '+ FMQTTUserID+' / '+ FMQTTUserPASS+' / '+ FMQTTTopicHEAD+' / '+ FMQTTTopicTAIL+']'); end; end; end.