unit uMain; interface uses Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, UMQTTClient, Vcl.StdCtrls, System.JSON, System.StrUtils, Vcl.ExtCtrls, Vcl.Grids, Vcl.ComCtrls, Vcl.Mask, System.Win.ComObj, system.Generics.Collections, system.SyncObjs, VCLTee.TeEngine, VCLTee.Series, VCLTee.TeeProcs, VCLTee.Chart, System.Math, System.UITypes, FireDAC.Comp.Client, System.IniFiles, uNILMTypes, uNILMManager; const CLR_BG = TColor($002A170F); // #0F172A CLR_PANEL = TColor($0045342D); // #1E293B (alpha overlay over #0F172A) CLR_PANEL2 = TColor($0045342D); // consistent panel color CLR_GREEN = TColor($0050AF4C); // #4CAF50 CLR_RED = TColor($005252FF); // #FF5252 CLR_WHITE = TColor($00FFFFFF); CLR_GRAY = TColor($00B8A394); // #94A3B8 ITEMMAX = 20; ///////////////////////////////////////////// OPC 임시 코드 _ 20260514 VAL_CTRL_SPACE = 8; // VAL_CTRL_TOP = 192; VAL_CTRL_TOP = 40; VAL_CTRL_LEFT = 16; VAL_ITEMNAME_HEIGHT = 24; // VAL_ITEMNAME_WIDTH = 88; VAL_ITEMNAME_WIDTH = 200; VAL_VALUE_HEIGHT = 24; VAL_VALUE_WIDTH = 48; VAL_TIME_HEIGHT = 24; VAL_TIME_WIDTH = 128; VAL_QUALITY_HEIGHT = 24; VAL_QUALITY_WIDTH = 40; VAL_TYPE_HEIGHT = 24; VAL_TYPE_WIDTH = 56; // VarType 표시 칸 ///////////////////////////////////////////// OPC 임시 코드 _ 20260514 type TNILMItem = class public id : string; seq : string; PreSeq : string; COMM : string; pfA : string; voltageA : string; currentA : string; Var_A : string; Va_A : string; pfB : string; voltageB : string; currentB : string; Var_B : string; Va_B : string; mqttTopic : string; lastupdatetime : string; delaycount : string; nilmstatus : string; statusCheck_off : string; statusCheck_wait : string; statusCheck_drive : string; end; type TfMain = class(TForm) pcMain: TPageControl; tsSetting: TTabSheet; tsMonitoring: TTabSheet; tsAnalyze: TTabSheet; tsLog: TTabSheet; Panel2: TPanel; pcSetting: TPageControl; tsSettingMQTT: TTabSheet; tsSettingDB: TTabSheet; tsSettingOPC: TTabSheet; tsSettingNILM: TTabSheet; grdMQTTMonitor: TStringGrid; panMainTop: TPanel; btnMainStart: TButton; btnMainStop: TButton; btPublish: TButton; lbDateTime: TLabel; tmrTime: TTimer; cbAutoStart: TCheckBox; edtMQTTHost: TLabeledEdit; edtMQTTPort: TLabeledEdit; edtMQTTId: TLabeledEdit; edtMQTTUId: TLabeledEdit; edtMQTTUPass: TLabeledEdit; btnMqttConnect: TButton; btnMqttDisConnect: TButton; pnlMqttStatusBox: TPanel; lblMQTTConnectionStatus: TLabel; edtDBHost: TLabeledEdit; edtDBPort: TLabeledEdit; edtDBId: TLabeledEdit; edtDBPass: TLabeledEdit; edtDBName: TLabeledEdit; BtnDBConnect: TButton; BtnDBDisConnect: TButton; pnlDBStatusBox: TPanel; lblDBConnectionStatus: TLabel; reMainLog: TRichEdit; etdMaxRow: TLabeledEdit; etdMaxLength: TLabeledEdit; cbFullLengthSet: TCheckBox; edtOPCHost: TLabeledEdit; edtOPCServerName: TLabeledEdit; edtOPCUpdateRate: TLabeledEdit; btnOPCConnect: TButton; btnOPCRead: TButton; pnlOPCStatusBox: TPanel; lblOPCConnectionStatus: TLabel; btnOPCWrite: TButton; reMQTTLog: TRichEdit; reDBLog: TRichEdit; btnOPCDisConnect: TButton; Panel1: TPanel; sbOPCMainEtc: TScrollBox; Label5: TLabel; Label7: TLabel; Label8: TLabel; Label9: TLabel; Label10: TLabel; reOPCLog: TRichEdit; btnSubscribe: TButton; tmrdelaystop: TTimer; edtDBHostEtc: TLabeledEdit; edtDBPortEtc: TLabeledEdit; edtDBIdEtc: TLabeledEdit; edtDBPassEtc: TLabeledEdit; edtDBNameEtc: TLabeledEdit; Label1: TLabel; Label2: TLabel; pnlDBStatusBoxEtc: TPanel; lblDBConnectionStatusEtc: TLabel; BtnDBConnectEtc: TButton; BtnDBDisConnectEtc: TButton; pnlNILMTop: TPanel; btnNILMRefresh: TButton; btnNILMAdd: TButton; btnNILMEdit: TButton; btnNILMDelete: TButton; btnNILMExportJSON: TButton; btnNILMImportJSON: TButton; chkAutoSaveDB: TCheckBox; grdNILMDevice: TStringGrid; pcHistory: TPageControl; tsHistData: TTabSheet; pnlHistFilter: TPanel; Label3: TLabel; Label4: TLabel; Label6: TLabel; Label11: TLabel; lblHistCount: TLabel; cmbHistDevice: TComboBox; dtpHistFrom: TDateTimePicker; dtpHistTo: TDateTimePicker; cmbHistStatus: TComboBox; btnHistSearch: TButton; btnHistExportCSV: TButton; grdHistory: TStringGrid; tsEventLog: TTabSheet; pnlEvtFilter: TPanel; Label13: TLabel; Label14: TLabel; Label15: TLabel; Label16: TLabel; lblEvtCount: TLabel; cmbEvtDevice: TComboBox; dtpEvtFrom: TDateTimePicker; dtpEvtTo: TDateTimePicker; cmbEvtType: TComboBox; btnEvtSearch: TButton; grdEventLog: TStringGrid; tsTrendView: TTabSheet; Panel3: TPanel; btnNILMLoad: TButton; GroupBox2: TGroupBox; cbpf: TCheckBox; cbvoltage: TCheckBox; cbcurrent: TCheckBox; cbVar: TCheckBox; cbVa: TCheckBox; cbw: TCheckBox; Chart_Main: TChart; Series1: TLineSeries; Series2: TLineSeries; Series3: TLineSeries; Series4: TLineSeries; Series5: TLineSeries; Series6: TLineSeries; Label17: TLabel; Label18: TLabel; Label19: TLabel; cmbTrendDevice: TComboBox; dtpTrendFrom: TDateTimePicker; dtpTrendTo: TDateTimePicker; lblTrendCount: TLabel; edtHistMaxRows: TEdit; Label12: TLabel; rgCHSelect: TRadioGroup; reTrendCheckLog: TRichEdit; Splitter1: TSplitter; Panel4: TPanel; btnOPCWriteONE: TButton; Edit1: TEdit; Edit2: TEdit; cbLogView: TCheckBox; cbLogSave: TCheckBox; btnNILMInitDB: TButton; cbMqttLogVIew: TCheckBox; cbDbLogVIew: TCheckBox; cbOPCLogVIew: TCheckBox; cbDataLogVIew: TCheckBox; procedure btPublishClick(Sender: TObject); procedure FormDestroy(Sender: TObject); procedure tmrTimeTimer(Sender: TObject); procedure btnMqttConnectClick(Sender: TObject); procedure btnMqttDisConnectClick(Sender: TObject); procedure BtnDBConnectClick(Sender: TObject); procedure BtnDBDisConnectClick(Sender: TObject); procedure btnOPCConnectClick(Sender: TObject); procedure btnOPCDisConnectClick(Sender: TObject); procedure btnOPCReadClick(Sender: TObject); procedure FormCreate(Sender: TObject); procedure btnOPCWriteClick(Sender: TObject); procedure btnSubscribeClick(Sender: TObject); procedure btnMainStartClick(Sender: TObject); procedure tmrdelaystopTimer(Sender: TObject); procedure btnMainStopClick(Sender: TObject); procedure BtnDBConnectEtcClick(Sender: TObject); procedure BtnDBDisConnectEtcClick(Sender: TObject); procedure btnNILMLoadClick(Sender: TObject); procedure btnNILMInitDBClick(Sender: TObject); procedure btnNILMRefreshClick(Sender: TObject); procedure btnNILMAddClick(Sender: TObject); procedure btnNILMEditClick(Sender: TObject); procedure btnNILMDeleteClick(Sender: TObject); procedure btnNILMExportJSONClick(Sender: TObject); procedure btnNILMImportJSONClick(Sender: TObject); procedure btnHistSearchClick(Sender: TObject); procedure btnHistExportCSVClick(Sender: TObject); procedure btnEvtSearchClick(Sender: TObject); procedure Chart_MainClickSeries(Sender: TCustomChart; Series: TChartSeries; ValueIndex: Integer; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); procedure btnOPCWriteONEClick(Sender: TObject); private txtItemName: array[0..ITEMMAX-1] of TEdit; txtValue: array[0..ITEMMAX-1] of TEdit; txtTime: array[0..ITEMMAX-1] of TEdit; txtQuality: array[0..ITEMMAX-1] of TEdit; txtType: array[0..ITEMMAX-1] of TEdit; // VarType 표시 칸 OPCServer: OLEVariant; OPCGroup: OLEVariant; OPCItemObjects: array[1..ITEMMAX] of OLEVariant; sH: array[1..ITEMMAX] of Integer; FItemVarTypes: array[1..ITEMMAX] of Integer; // Read 시 저장한 각 아이템의 실제 VarType bConnect: Boolean; FMQTTClient: TMQTTClient; FNILMManager : TNILMManager; FDeviceList : TList; FHistoryQuery : TFDQuery; // 조회 결과 캐시 유지 FEventLogQuery : TFDQuery; FPreviousOPCValues: TDictionary; FDynamicOPCItems: TDictionary; FLastOPCRetryTime: TDateTime; FLastEnergyUpdate: TDictionary; FDayEnergyValue: TDictionary; FLastDailyEnergySyncDate: TDate; FLastRecvTime: TDictionary; FDeviceTimeoutHandled: TDictionary; procedure CheckDeviceTimeouts; procedure HandleDeviceTimeout(ADeviceID: Integer; const ADeviceName: string); function WriteOPCValueIfChanged(const ATagName: string; const AValue: string): Integer; procedure ClearFailedOPCTags; procedure LoadSettings; procedure SaveSettings; procedure NILMErrorHandler(const AMsg: string); procedure OnMQTTMessage(const ATopic, APayload: string); procedure OnMQTTStatus(AConnected: Boolean); procedure ProcessIncomingMQTTMessage(const ATopic, APayload: string); procedure ProcessGatewayMessage(const ATopic, APayload: string); procedure ProcessNodeStatusMessage(const ATopic, APayload: string); procedure ProcessEventMessage(const ATopic, APayload: string); // NILM 설정 헬퍼 procedure NILM_InitGrid; procedure NILM_LoadGrid; procedure NILM_ClearDeviceList; function NILM_SelectedDeviceID: Integer; // 히스토리 헬퍼 procedure Hist_InitGrid; procedure Hist_LoadDeviceCombo(ACombo: TComboBox; TotalUse:Boolean = True); procedure NILM_SaveMQTTData(const ATopic, APayload: string); procedure Hist_InitEventGrid; procedure Hist_ExportGridToCSV(AGrid: TStringGrid; const ATitle: string); public BaseList_NILMItem: TObjectList; NILMItem: TNILMItem; // Helper Functions procedure Log(Target: TRichEdit; LogType: string; Msg: string); procedure addSubscribe(topic:string); procedure SetupUI; function gridmonitorRefresh_main(ADeviceID, ASeq, APreSeq, ACommStatus: Integer; const APayloadJSON, AOpStatus: string; AData: TJSONObject; ASensorList: TNILMSensorConfigList): Boolean; procedure LoadLatestMonitorDataFromDB; function PG_Connect(x : Integer):Boolean; procedure chartrefresh; // function ConvertOPCValue(const S: string; VarType: Integer): Variant; end; var fMain: TfMain; implementation {$R *.dfm} uses U_DM, uLogManagerThread, uNILMDeviceForm, Vcl.FileCtrl; procedure TfMain.LoadSettings; var Ini: TIniFile; i: Integer; begin Ini := TIniFile.Create(ExtractFilePath(ParamStr(0)) + 'settings.ini'); try edtMQTTHost.Text := Ini.ReadString('MQTT', 'Host', edtMQTTHost.Text); edtMQTTPort.Text := Ini.ReadString('MQTT', 'Port', edtMQTTPort.Text); edtMQTTId.Text := Ini.ReadString('MQTT', 'ID', edtMQTTId.Text); edtMQTTUId.Text := Ini.ReadString('MQTT', 'UserID', edtMQTTUId.Text); edtMQTTUPass.Text := Ini.ReadString('MQTT', 'Password', edtMQTTUPass.Text); edtDBHost.Text := Ini.ReadString('DB_NILM', 'Host', edtDBHost.Text); edtDBPort.Text := Ini.ReadString('DB_NILM', 'Port', edtDBPort.Text); edtDBId.Text := Ini.ReadString('DB_NILM', 'ID', edtDBId.Text); edtDBPass.Text := Ini.ReadString('DB_NILM', 'Password', edtDBPass.Text); edtDBName.Text := Ini.ReadString('DB_NILM', 'Name', edtDBName.Text); edtDBHostEtc.Text := Ini.ReadString('DB_ETC', 'Host', edtDBHostEtc.Text); edtDBPortEtc.Text := Ini.ReadString('DB_ETC', 'Port', edtDBPortEtc.Text); edtDBIdEtc.Text := Ini.ReadString('DB_ETC', 'ID', edtDBIdEtc.Text); edtDBPassEtc.Text := Ini.ReadString('DB_ETC', 'Password', edtDBPassEtc.Text); edtDBNameEtc.Text := Ini.ReadString('DB_ETC', 'Name', edtDBNameEtc.Text); edtOPCHost.Text := Ini.ReadString('OPC', 'Host', edtOPCHost.Text); edtOPCServerName.Text := Ini.ReadString('OPC', 'ServerName', edtOPCServerName.Text); edtOPCUpdateRate.Text := Ini.ReadString('OPC', 'UpdateRate', edtOPCUpdateRate.Text); for i := 0 to ITEMMAX - 1 do begin if Assigned(txtItemName[i]) then txtItemName[i].Text := Ini.ReadString('OPC_ITEMS', 'Item' + IntToStr(i), txtItemName[i].Text); end; cbLogView.Checked := Ini.ReadBool('SETTINGS', 'LogView', cbLogView.Checked); cbLogSave.Checked := Ini.ReadBool('SETTINGS', 'LogSave', cbLogSave.Checked); cbAutoStart.Checked := Ini.ReadBool('SETTINGS', 'AutoStart', cbAutoStart.Checked); cbFullLengthSet.Checked := Ini.ReadBool('SETTINGS', 'FullLengthSet', cbFullLengthSet.Checked); etdMaxRow.Text := Ini.ReadString('SETTINGS', 'MaxRow', etdMaxRow.Text); etdMaxLength.Text := Ini.ReadString('SETTINGS', 'MaxLength', etdMaxLength.Text); cbpf.Checked := Ini.ReadBool('DISPLAY', 'pf', cbpf.Checked); cbvoltage.Checked := Ini.ReadBool('DISPLAY', 'voltage', cbvoltage.Checked); cbcurrent.Checked := Ini.ReadBool('DISPLAY', 'current', cbcurrent.Checked); cbVar.Checked := Ini.ReadBool('DISPLAY', 'Var', cbVar.Checked); cbVa.Checked := Ini.ReadBool('DISPLAY', 'Va', cbVa.Checked); cbw.Checked := Ini.ReadBool('DISPLAY', 'W', cbw.Checked); chkAutoSaveDB.Checked := Ini.ReadBool('NILM', 'AutoSaveDB', chkAutoSaveDB.Checked); edtHistMaxRows.Text := Ini.ReadString('NILM', 'HistMaxRows', edtHistMaxRows.Text); finally Ini.Free; end; end; procedure TfMain.SaveSettings; var Ini: TIniFile; i: Integer; begin Ini := TIniFile.Create(ExtractFilePath(ParamStr(0)) + 'settings.ini'); try Ini.WriteString('MQTT', 'Host', edtMQTTHost.Text); Ini.WriteString('MQTT', 'Port', edtMQTTPort.Text); Ini.WriteString('MQTT', 'ID', edtMQTTId.Text); Ini.WriteString('MQTT', 'UserID', edtMQTTUId.Text); Ini.WriteString('MQTT', 'Password', edtMQTTUPass.Text); Ini.WriteString('DB_NILM', 'Host', edtDBHost.Text); Ini.WriteString('DB_NILM', 'Port', edtDBPort.Text); Ini.WriteString('DB_NILM', 'ID', edtDBId.Text); Ini.WriteString('DB_NILM', 'Password', edtDBPass.Text); Ini.WriteString('DB_NILM', 'Name', edtDBName.Text); Ini.WriteString('DB_ETC', 'Host', edtDBHostEtc.Text); Ini.WriteString('DB_ETC', 'Port', edtDBPortEtc.Text); Ini.WriteString('DB_ETC', 'ID', edtDBIdEtc.Text); Ini.WriteString('DB_ETC', 'Password', edtDBPassEtc.Text); Ini.WriteString('DB_ETC', 'Name', edtDBNameEtc.Text); Ini.WriteString('OPC', 'Host', edtOPCHost.Text); Ini.WriteString('OPC', 'ServerName', edtOPCServerName.Text); Ini.WriteString('OPC', 'UpdateRate', edtOPCUpdateRate.Text); for i := 0 to ITEMMAX - 1 do begin if Assigned(txtItemName[i]) then Ini.WriteString('OPC_ITEMS', 'Item' + IntToStr(i), txtItemName[i].Text); end; Ini.WriteBool('SETTINGS', 'LogView', cbLogView.Checked); Ini.WriteBool('SETTINGS', 'LogSave', cbLogSave.Checked); Ini.WriteBool('SETTINGS', 'AutoStart', cbAutoStart.Checked); Ini.WriteBool('SETTINGS', 'FullLengthSet', cbFullLengthSet.Checked); Ini.WriteString('SETTINGS', 'MaxRow', etdMaxRow.Text); Ini.WriteString('SETTINGS', 'MaxLength', etdMaxLength.Text); Ini.WriteBool('DISPLAY', 'pf', cbpf.Checked); Ini.WriteBool('DISPLAY', 'voltage', cbvoltage.Checked); Ini.WriteBool('DISPLAY', 'current', cbcurrent.Checked); Ini.WriteBool('DISPLAY', 'Var', cbVar.Checked); Ini.WriteBool('DISPLAY', 'Va', cbVa.Checked); Ini.WriteBool('DISPLAY', 'W', cbw.Checked); Ini.WriteBool('NILM', 'AutoSaveDB', chkAutoSaveDB.Checked); Ini.WriteString('NILM', 'HistMaxRows', edtHistMaxRows.Text); finally Ini.Free; end; end; procedure TfMain.SetupUI; var i: Integer; begin for i := 0 to ITEMMAX - 1 do begin txtItemName[i] := TEdit.Create(Self); txtItemName[i].Parent := sbOPCMainEtc; txtItemName[i].Top := VAL_CTRL_TOP + (VAL_ITEMNAME_HEIGHT + VAL_CTRL_SPACE) * i; txtItemName[i].Left := VAL_CTRL_LEFT; txtItemName[i].Width := VAL_ITEMNAME_WIDTH; txtItemName[i].Height := VAL_ITEMNAME_HEIGHT; if i <= 7 then begin txtItemName[i].Text := 'SYSTEM.Tag'+ RightStr('000'+IntToStr(i+2),3); end else begin txtItemName[i].Text := ''; end; txtValue[i] := TEdit.Create(Self); txtValue[i].Parent := sbOPCMainEtc; txtValue[i].Top := VAL_CTRL_TOP + (VAL_VALUE_HEIGHT + VAL_CTRL_SPACE) * i; txtValue[i].Left := VAL_CTRL_LEFT + VAL_ITEMNAME_WIDTH + VAL_CTRL_SPACE; txtValue[i].Width := VAL_VALUE_WIDTH; txtValue[i].Height := VAL_VALUE_HEIGHT; txtTime[i] := TEdit.Create(Self); txtTime[i].Parent := sbOPCMainEtc; txtTime[i].Top := VAL_CTRL_TOP + (VAL_TIME_HEIGHT + VAL_CTRL_SPACE) * i; txtTime[i].Left := txtValue[i].Left + VAL_VALUE_WIDTH + VAL_CTRL_SPACE; txtTime[i].Width := VAL_TIME_WIDTH; txtTime[i].Height := VAL_TIME_HEIGHT; txtQuality[i] := TEdit.Create(Self); txtQuality[i].Parent := sbOPCMainEtc; txtQuality[i].Top := VAL_CTRL_TOP + (VAL_QUALITY_HEIGHT + VAL_CTRL_SPACE) * i; txtQuality[i].Left := txtTime[i].Left + VAL_TIME_WIDTH + VAL_CTRL_SPACE; txtQuality[i].Width := VAL_QUALITY_WIDTH; txtQuality[i].Height := VAL_QUALITY_HEIGHT; txtQuality[i].ReadOnly := True; txtType[i] := TEdit.Create(Self); txtType[i].Parent := sbOPCMainEtc; txtType[i].Top := VAL_CTRL_TOP + (VAL_TYPE_HEIGHT + VAL_CTRL_SPACE) * i; txtType[i].Left := txtQuality[i].Left + VAL_QUALITY_WIDTH + VAL_CTRL_SPACE; txtType[i].Width := VAL_TYPE_WIDTH; txtType[i].Height := VAL_TYPE_HEIGHT; txtType[i].ReadOnly := True; txtType[i].Color := $00F0F0F0; // 연회색 배경 end; end; procedure TfMain.btPublishClick(Sender: TObject); var Topic, Payload: string; begin Topic := 'QST/SHAREDEV/GWTP/AMC/1'; Payload := '{"DO_' + Format('%.2d', [1]) + '":"' + '1' + '"}'; if FMQTTClient <> nil then FMQTTClient.Publish(Topic, Payload); end; procedure TfMain.btnOPCWriteONEClick(Sender: TObject); begin WriteOPCValueIfChanged('SYSTEM.'+edit1.Text,edit2.Text); end; procedure TfMain.addSubscribe(topic:string); begin if FMQTTClient <> nil then begin FMQTTClient.Subscribe(topic); Log(reMQTTLog,'process','Subscribe('+topic+')'); end; end; procedure TfMain.btnSubscribeClick(Sender: TObject); var Q: TFDQuery; Topic, BaseGWTopic: string; SubscribedGWs: TList; GWIdx: Integer; begin grdMQTTMonitor.RowCount := 2; grdMQTTMonitor.ColCount := 28; grdMQTTMonitor.Cells[0, 0] := 'id'; grdMQTTMonitor.Cells[1, 0] := 'LastUpdate'; grdMQTTMonitor.Cells[2, 0] := 'seq'; grdMQTTMonitor.Cells[3, 0] := 'wire'; grdMQTTMonitor.Cells[4, 0] := 'Status'; grdMQTTMonitor.Cells[5, 0] := 'RSSI'; grdMQTTMonitor.Cells[6, 0] := 'Total_W'; grdMQTTMonitor.Cells[7, 0] := 'Total_PF'; grdMQTTMonitor.Cells[8, 0] := 'pf_a'; grdMQTTMonitor.Cells[9, 0] := 'vrms_a'; grdMQTTMonitor.Cells[10, 0] := 'irms_a'; grdMQTTMonitor.Cells[11, 0] := 'w_a'; grdMQTTMonitor.Cells[12, 0] := 'var_a'; grdMQTTMonitor.Cells[13, 0] := 'va_a'; grdMQTTMonitor.Cells[14, 0] := 'pf_b'; grdMQTTMonitor.Cells[15, 0] := 'vrms_b'; grdMQTTMonitor.Cells[16, 0] := 'irms_b'; grdMQTTMonitor.Cells[17, 0] := 'w_b'; grdMQTTMonitor.Cells[18, 0] := 'var_b'; grdMQTTMonitor.Cells[19, 0] := 'va_b'; grdMQTTMonitor.Cells[20, 0] := 'pf_c'; grdMQTTMonitor.Cells[21, 0] := 'vrms_c'; grdMQTTMonitor.Cells[22, 0] := 'irms_c'; grdMQTTMonitor.Cells[23, 0] := 'w_c'; grdMQTTMonitor.Cells[24, 0] := 'var_c'; grdMQTTMonitor.Cells[25, 0] := 'va_c'; grdMQTTMonitor.Cells[26, 0] := '누적전력량(wh)'; grdMQTTMonitor.Cells[27, 0] := 'Energy_Day(Wh)'; if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; SubscribedGWs := TList.Create; try Q := FNILMManager.LoadDevicesSimple; try while not Q.EOF do begin Topic := Trim(Q.FieldByName('mqtt_topic').AsString); if Topic <> '' then begin // 측정값 토픽 구독 addSubscribe(Topic); // 노드 상태 토픽 구독 (/{NDID}/status) addSubscribe(Topic + '/status'); // 장비 이벤트 토픽 구독 (/{NDID}/event) addSubscribe(Topic + '/event'); // 게이트웨이 자체 상태 토픽 구독 (/{GWID}/gateway) GWIdx := LastDelimiter('/', Topic); if GWIdx > 0 then begin BaseGWTopic := Copy(Topic, 1, GWIdx - 1) + '/gateway'; if SubscribedGWs.IndexOf(BaseGWTopic) < 0 then begin SubscribedGWs.Add(BaseGWTopic); addSubscribe(BaseGWTopic); end; end; end; Q.Next; end; finally Q.Free; end; except on E: Exception do Log(reDBLog, 'error', 'DB Subscribe load error: ' + E.Message); end; SubscribedGWs.Free; // DB에 저장된 마지막 최신 측정 데이터를 그리드에 즉시 로드 LoadLatestMonitorDataFromDB; end; procedure TfMain.LoadLatestMonitorDataFromDB; var Q: TFDQuery; did, seq: Integer; wireVal, statusStr, opStatus: string; row_ck: Integer; is3P3W: Boolean; begin if not Assigned(DM) or not DM.fdConnNilm.Connected then Exit; Q := TFDQuery.Create(nil); try Q.Connection := DM.fdConnNilm; Q.SQL.Text := 'SELECT device_id, seq, comm_status, received_at, op_status, wire, rssi, wh, ' + ' total_p, total_pf, ' + ' power_factor_a, vrms_a, irms_a, active_power_a, reactive_power_a, apparent_power_a, ' + ' power_factor_b, vrms_b, irms_b, active_power_b, reactive_power_b, apparent_power_b, ' + ' power_factor_c, vrms_c, irms_c, active_power_c, reactive_power_c, apparent_power_c ' + 'FROM nilm_data ORDER BY device_id'; try Q.Open; except Exit; end; while not Q.EOF do begin did := Q.FieldByName('device_id').AsInteger; seq := Q.FieldByName('seq').AsInteger; wireVal := Q.FieldByName('wire').AsString; if wireVal = '' then wireVal := PHASE_3P4W; is3P3W := SameText(wireVal, PHASE_3P3W); opStatus:= Q.FieldByName('op_status').AsString; var recvTime := Q.FieldByName('received_at').AsDateTime; if Assigned(FLastRecvTime) then FLastRecvTime.AddOrSetValue(did, recvTime); if Assigned(FDeviceTimeoutHandled) then FDeviceTimeoutHandled.AddOrSetValue(did, False); // row 찾기 row_ck := 0; for var r := 1 to grdMQTTMonitor.RowCount - 1 do begin if grdMQTTMonitor.Cells[0, r] = did.ToString then begin row_ck := r; Break; end; end; // 없으면 신규 row 추가 if row_ck = 0 then begin if Trim(grdMQTTMonitor.Cells[0, 1]) = '' then row_ck := 1 else begin row_ck := grdMQTTMonitor.RowCount; grdMQTTMonitor.RowCount := grdMQTTMonitor.RowCount + 1; end; end; grdMQTTMonitor.Cells[ 0, row_ck] := did.ToString; grdMQTTMonitor.Cells[ 1, row_ck] := FormatDateTime('yyyymmdd hhnnss', Q.FieldByName('received_at').AsDateTime); grdMQTTMonitor.Cells[ 2, row_ck] := seq.ToString; grdMQTTMonitor.Cells[ 3, row_ck] := wireVal; if opStatus = '2' then statusStr := '가동(2)' else if opStatus = '1' then statusStr := '미가동(1)' else statusStr := '전원꺼짐(0)'; grdMQTTMonitor.Cells[ 4, row_ck] := statusStr; grdMQTTMonitor.Cells[ 5, row_ck] := Format('%ddBm', [Q.FieldByName('rssi').AsInteger]); grdMQTTMonitor.Cells[ 6, row_ck] := FormatFloat('#,##0.0', Q.FieldByName('total_p').AsFloat); grdMQTTMonitor.Cells[ 7, row_ck] := FormatFloat('0.000', Q.FieldByName('total_pf').AsFloat); // A상 grdMQTTMonitor.Cells[ 8, row_ck] := FormatFloat('0.000', Q.FieldByName('power_factor_a').AsFloat); grdMQTTMonitor.Cells[ 9, row_ck] := FormatFloat('0.0', Q.FieldByName('vrms_a').AsFloat); grdMQTTMonitor.Cells[10, row_ck] := FormatFloat('0.00', Q.FieldByName('irms_a').AsFloat); grdMQTTMonitor.Cells[11, row_ck] := FormatFloat('0.0', Q.FieldByName('active_power_a').AsFloat); grdMQTTMonitor.Cells[12, row_ck] := FormatFloat('0.0', Q.FieldByName('reactive_power_a').AsFloat); grdMQTTMonitor.Cells[13, row_ck] := FormatFloat('0.0', Q.FieldByName('apparent_power_a').AsFloat); // B상 if is3P3W then begin grdMQTTMonitor.Cells[14, row_ck] := '-'; grdMQTTMonitor.Cells[15, row_ck] := '-'; grdMQTTMonitor.Cells[16, row_ck] := '-'; grdMQTTMonitor.Cells[17, row_ck] := '-'; grdMQTTMonitor.Cells[18, row_ck] := '-'; grdMQTTMonitor.Cells[19, row_ck] := '-'; end else begin grdMQTTMonitor.Cells[14, row_ck] := FormatFloat('0.000', Q.FieldByName('power_factor_b').AsFloat); grdMQTTMonitor.Cells[15, row_ck] := FormatFloat('0.0', Q.FieldByName('vrms_b').AsFloat); grdMQTTMonitor.Cells[16, row_ck] := FormatFloat('0.00', Q.FieldByName('irms_b').AsFloat); grdMQTTMonitor.Cells[17, row_ck] := FormatFloat('0.0', Q.FieldByName('active_power_b').AsFloat); grdMQTTMonitor.Cells[18, row_ck] := FormatFloat('0.0', Q.FieldByName('reactive_power_b').AsFloat); grdMQTTMonitor.Cells[19, row_ck] := FormatFloat('0.0', Q.FieldByName('apparent_power_b').AsFloat); end; // C상 grdMQTTMonitor.Cells[20, row_ck] := FormatFloat('0.000', Q.FieldByName('power_factor_c').AsFloat); grdMQTTMonitor.Cells[21, row_ck] := FormatFloat('0.0', Q.FieldByName('vrms_c').AsFloat); grdMQTTMonitor.Cells[22, row_ck] := FormatFloat('0.00', Q.FieldByName('irms_c').AsFloat); grdMQTTMonitor.Cells[23, row_ck] := FormatFloat('0.0', Q.FieldByName('active_power_c').AsFloat); grdMQTTMonitor.Cells[24, row_ck] := FormatFloat('0.0', Q.FieldByName('reactive_power_c').AsFloat); grdMQTTMonitor.Cells[25, row_ck] := FormatFloat('0.0', Q.FieldByName('apparent_power_c').AsFloat); // Wh grdMQTTMonitor.Cells[26, row_ck] := FormatFloat('#,##0', Q.FieldByName('wh').AsLargeInt); grdMQTTMonitor.Cells[27, row_ck] := '-'; Q.Next; end; finally Q.Free; end; end; procedure TfMain.BtnDBConnectClick(Sender: TObject); begin if not PG_Connect(0) then Exit; end; procedure TfMain.BtnDBConnectEtcClick(Sender: TObject); begin if not PG_Connect(1) then Exit; end; function TfMain.PG_Connect(x : Integer):Boolean; begin Result := False; DM.FDPhysPgDriverLink.VendorLib := '.\libpq-10.dll'; case x of 0 : begin DM.fdConnNilm.Connected := False; DM.fdConnNilm.Params.Clear; DM.fdConnNilm.Params.DriverID := 'PG'; DM.fdConnNilm.Params.Database := Trim(edtDBName.Text); DM.fdConnNilm.Params.UserName := Trim(edtDBId.Text); DM.fdConnNilm.Params.Password := Trim(edtDBPass.Text); DM.fdConnNilm.Params.Add('Server=' + Trim(edtDBHost.Text)); DM.fdConnNilm.Params.Add('Port=' + Trim(edtDBPort.Text)); DM.fdConnNilm.Params.Add('CharacterSet=UTF8'); try DM.fdConnNilm.Connected := True; Result := True; Log(reDBLog,'process','DB (NILM) Connected'); lblDBConnectionStatus.Caption := 'Connected'; lblDBConnectionStatus.Font.Color := CLR_GREEN; lblDBConnectionStatus.Tag := 1; btnNILMInitDB.Enabled := True; LoadLatestMonitorDataFromDB; except on e: Exception do begin Log(reDBLog,'error','DB (NILM) Connect fail :'+e.Message); Result := False; end; end; end; 1 : begin DM.fdConnEtc.Connected := False; DM.fdConnEtc.Params.Clear; DM.fdConnEtc.Params.DriverID := 'PG'; DM.fdConnEtc.Params.Database := Trim(edtDBName.Text); DM.fdConnEtc.Params.UserName := Trim(edtDBId.Text); DM.fdConnEtc.Params.Password := Trim(edtDBPass.Text); DM.fdConnEtc.Params.Add('Server=' + Trim(edtDBHost.Text)); DM.fdConnEtc.Params.Add('Port=' + Trim(edtDBPort.Text)); DM.fdConnEtc.Params.Add('CharacterSet=UTF8'); try DM.fdConnEtc.Connected := True; Result := True; Log(reDBLog,'process','DB (ETC) Connected'); lblDBConnectionStatusEtc.Caption := 'Connected'; lblDBConnectionStatusEtc.Font.Color := CLR_GREEN; lblDBConnectionStatusEtc.Tag := 1; except on e: Exception do begin Log(reDBLog,'error','DB (ETC) Connect fail :'+e.Message); Result := False; end; end; end; end; end; procedure TfMain.BtnDBDisConnectClick(Sender: TObject); begin if DM.fdConnNilm.Connected then begin DM.fdConnNilm.Connected := False; Log(reDBLog,'process','DB (NILM) DisConnected'); lblDBConnectionStatus.Caption := 'DisConnected'; lblDBConnectionStatus.Font.Color := CLR_RED; lblDBConnectionStatus.Tag := 2; end; if not btnMainStart.Enabled then btnMainStart.Enabled := true; end; procedure TfMain.BtnDBDisConnectEtcClick(Sender: TObject); begin if DM.fdConnEtc.Connected then begin DM.fdConnEtc.Connected := False; Log(reDBLog,'process','DB (ETC) DisConnected'); lblDBConnectionStatusEtc.Caption := 'DisConnected'; lblDBConnectionStatusEtc.Font.Color := CLR_RED; lblDBConnectionStatusEtc.Tag := 2; end; if not btnMainStart.Enabled then btnMainStart.Enabled := true; end; procedure TfMain.btnEvtSearchClick(Sender: TObject); var DevID : Integer; EvtType: string; MaxR : Integer; Row : Integer; Q : TFDQuery; begin if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; DevID := -1; if Assigned(cmbEvtDevice) and (cmbEvtDevice.ItemIndex > 0) then begin var S := cmbEvtDevice.Items[cmbEvtDevice.ItemIndex]; var P1 := Pos('[', S); var P2 := Pos(']', S); if (P1 > 0) and (P2 > P1) then DevID := StrToIntDef(Copy(S, P1 + 1, P2 - P1 - 1), -1); end; EvtType := ''; if Assigned(cmbEvtType) and (cmbEvtType.ItemIndex > 0) then EvtType := cmbEvtType.Items[cmbEvtType.ItemIndex]; MaxR := 500; FreeAndNil(FEventLogQuery); try Hist_InitEventGrid; Q := FNILMManager.QueryEventLog( DevID, dtpEvtFrom.DateTime, dtpEvtTo.DateTime + 1, EvtType, MaxR); FEventLogQuery := Q; grdEventLog.RowCount := Max(2, Q.RecordCount + 1); Row := 1; while not Q.EOF do begin grdEventLog.Cells[0, Row] := FormatDateTime('yyyy-mm-dd hh:nn:ss', Q.FieldByName('event_time').AsDateTime); grdEventLog.Cells[1, Row] := Q.FieldByName('event_type').AsString; grdEventLog.Cells[2, Row] := Q.FieldByName('device_id').AsString; grdEventLog.Cells[3, Row] := Q.FieldByName('message').AsString; grdEventLog.Cells[4, Row] := Q.FieldByName('detail').AsString; Inc(Row); Q.Next; end; if Assigned(lblEvtCount) then lblEvtCount.Caption := Format('조회 결과: %d 건', [Q.RecordCount]); except on E: Exception do Log(reDBLog, 'error', '[이벤트로그] 조회 오류: ' + E.Message); end; end; procedure TfMain.btnHistExportCSVClick(Sender: TObject); begin if Assigned(grdHistory) then Hist_ExportGridToCSV(grdHistory, 'NILM_History'); end; procedure TfMain.btnHistSearchClick(Sender: TObject); var DevID : Integer; CommFlt: Integer; MaxR : Integer; Row : Integer; wireVal: string; Q : TFDQuery; begin if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; // 장비 ID DevID := -1; if Assigned(cmbHistDevice) and (cmbHistDevice.ItemIndex > 0) then begin var S := cmbHistDevice.Items[cmbHistDevice.ItemIndex]; var P1 := Pos('[', S); var P2 := Pos(']', S); if (P1 > 0) and (P2 > P1) then DevID := StrToIntDef(Copy(S, P1 + 1, P2 - P1 - 1), -1); end; // COMM 상태 필터 CommFlt := -1; if Assigned(cmbHistStatus) and (cmbHistStatus.ItemIndex > 0) then CommFlt := cmbHistStatus.ItemIndex - 1; MaxR := StrToIntDef(edtHistMaxRows.Text, 1000); FreeAndNil(FHistoryQuery); try Hist_InitGrid; Q := FNILMManager.QueryNILMHistory( DevID, dtpHistFrom.DateTime, dtpHistTo.DateTime + 1, CommFlt, MaxR); FHistoryQuery := Q; grdHistory.RowCount := Max(2, Q.RecordCount + 1); Row := 1; while not Q.EOF do begin grdHistory.Cells[0, Row] := FormatDateTime('yyyy-mm-dd hh:nn:ss', Q.FieldByName('received_at').AsDateTime); grdHistory.Cells[1, Row] := Q.FieldByName('device_id').AsString; grdHistory.Cells[2, Row] := Q.FieldByName('seq').AsString; grdHistory.Cells[3, Row] := Q.FieldByName('pre_seq').AsString; grdHistory.Cells[4, Row] := Q.FieldByName('comm_status').AsString; wireVal := Q.FieldByName('wire').AsString; if wireVal = '' then wireVal := PHASE_3P4W; grdHistory.Cells[5, Row] := wireVal; grdHistory.Cells[6, Row] := Q.FieldByName('rssi').AsString; grdHistory.Cells[7, Row] := FormatFloat('#,##0.0', Q.FieldByName('total_p').AsFloat); grdHistory.Cells[8, Row] := FormatFloat('0.000', Q.FieldByName('total_pf').AsFloat); // A상 grdHistory.Cells[9, Row] := FormatFloat('0.000', Q.FieldByName('power_factor_a').AsFloat); grdHistory.Cells[10, Row] := FormatFloat('0.0', Q.FieldByName('vrms_a').AsFloat); grdHistory.Cells[11, Row] := FormatFloat('0.00', Q.FieldByName('irms_a').AsFloat); grdHistory.Cells[12, Row] := FormatFloat('0.0', Q.FieldByName('active_power_a').AsFloat); grdHistory.Cells[13, Row] := FormatFloat('0.0', Q.FieldByName('reactive_power_a').AsFloat); grdHistory.Cells[14, Row] := FormatFloat('0.0', Q.FieldByName('apparent_power_a').AsFloat); // B상 (3P3W일 경우 미사용 표시) if SameText(wireVal, PHASE_3P3W) then begin grdHistory.Cells[15, Row] := '-'; grdHistory.Cells[16, Row] := '-'; grdHistory.Cells[17, Row] := '-'; grdHistory.Cells[18, Row] := '-'; grdHistory.Cells[19, Row] := '-'; grdHistory.Cells[20, Row] := '-'; end else begin grdHistory.Cells[15, Row] := FormatFloat('0.000', Q.FieldByName('power_factor_b').AsFloat); grdHistory.Cells[16, Row] := FormatFloat('0.0', Q.FieldByName('vrms_b').AsFloat); grdHistory.Cells[17, Row] := FormatFloat('0.00', Q.FieldByName('irms_b').AsFloat); grdHistory.Cells[18, Row] := FormatFloat('0.0', Q.FieldByName('active_power_b').AsFloat); grdHistory.Cells[19, Row] := FormatFloat('0.0', Q.FieldByName('reactive_power_b').AsFloat); grdHistory.Cells[20, Row] := FormatFloat('0.0', Q.FieldByName('apparent_power_b').AsFloat); end; // C상 grdHistory.Cells[21, Row] := FormatFloat('0.000', Q.FieldByName('power_factor_c').AsFloat); grdHistory.Cells[22, Row] := FormatFloat('0.0', Q.FieldByName('vrms_c').AsFloat); grdHistory.Cells[23, Row] := FormatFloat('0.00', Q.FieldByName('irms_c').AsFloat); grdHistory.Cells[24, Row] := FormatFloat('0.0', Q.FieldByName('active_power_c').AsFloat); grdHistory.Cells[25, Row] := FormatFloat('0.0', Q.FieldByName('reactive_power_c').AsFloat); grdHistory.Cells[26, Row] := FormatFloat('0.0', Q.FieldByName('apparent_power_c').AsFloat); // 누적전력량 및 기타 센서값 grdHistory.Cells[27, Row] := FormatFloat('#,##0', Q.FieldByName('wh').AsLargeInt); grdHistory.Cells[28, Row] := FormatFloat('0.0', Q.FieldByName('temperature').AsFloat); grdHistory.Cells[29, Row] := FormatFloat('0.0', Q.FieldByName('temp_ext').AsFloat); grdHistory.Cells[30, Row] := FormatFloat('0.0', Q.FieldByName('vibration').AsFloat); grdHistory.Cells[31, Row] := FormatFloat('0.0', Q.FieldByName('tilt_angle').AsFloat); Inc(Row); Q.Next; end; if Assigned(lblHistCount) then lblHistCount.Caption := Format('조회 결과: %d 건', [Q.RecordCount]); Log(reDBLog, 'complete', Format('[히스토리] %d 건 조회 완료', [Q.RecordCount])); except on E: Exception do begin Log(reDBLog, 'error', '[히스토리] 조회 오류: ' + E.Message); if Assigned(lblHistCount) then lblHistCount.Caption := '조회 실패'; end; end; end; procedure TfMain.btnMainStartClick(Sender: TObject); begin btnMqttConnectClick(Sender); BtnDBConnectClick(Sender); BtnDBConnectEtcClick(Sender); btnOPCConnectClick(Sender); btnNILMRefreshClick(Sender); btnMainStart.Enabled := False; end; procedure TfMain.btnMainStopClick(Sender: TObject); begin Log(reOPCLog, 'process', '--- STOP CLICKED ---'); Log(reOPCLog, 'process', '1. MQTT Disconnect Start'); btnMqttDisConnectClick(Sender); Log(reOPCLog, 'process', '1. MQTT Disconnect End'); Log(reOPCLog, 'process', '2. DB Disconnect Start'); BtnDBDisConnectClick(Sender); Log(reOPCLog, 'process', '2. DB Disconnect End'); Log(reOPCLog, 'process', '3. DB ETC Disconnect Start'); BtnDBDisConnectEtcClick(Sender); Log(reOPCLog, 'process', '3. DB ETC Disconnect End'); Log(reOPCLog, 'process', '4. OPC Disconnect Start'); btnOPCDisConnectClick(Sender); Log(reOPCLog, 'process', '4. OPC Disconnect End'); btnMainStart.Enabled := true; Log(reOPCLog, 'process', '--- STOP FINISHED ---'); end; procedure TfMain.btnMqttConnectClick(Sender: TObject); begin // Self.Enabled := False; if FMQTTClient <> nil then begin FMQTTClient.OnMessage := nil; FMQTTClient.OnStatus := nil; FMQTTClient.Disconnect; FreeAndNil(FMQTTClient); end; Log(reMQTTLog,'process','MqttConnectClick('+edtMQTTHost.Text +' - '+ edtMQTTPort.Text +' - '+ edtMQTTId.Text+')'); FMQTTClient := TMQTTClient.Create(edtMQTTHost.Text, StrToInt(edtMQTTPort.Text), edtMQTTId.Text, edtMQTTUId.Text, edtMQTTUPass.Text); FMQTTClient.OnMessage := OnMQTTMessage; FMQTTClient.OnStatus := OnMQTTStatus; FMQTTClient.Connect; // tmrdelaystop.Enabled := True; end; procedure TfMain.btnMqttDisConnectClick(Sender: TObject); begin Log(reMQTTLog,'process','MqttDisConnectClick'); if FMQTTClient <> nil then begin FMQTTClient.OnMessage := nil; FMQTTClient.OnStatus := nil; FMQTTClient.Disconnect; FreeAndNil(FMQTTClient); Log(reMQTTLog,'process','MQTT DisConnected.'); lblMQTTConnectionStatus.Caption := 'DisConnected'; lblMQTTConnectionStatus.Font.Color := CLR_RED; lblMQTTConnectionStatus.Tag := 2; end; if not btnMainStart.Enabled then btnMainStart.Enabled := true; end; // ─── 장비 추가 ──────────────────────────────────────────────────────────── procedure TfMain.btnNILMAddClick(Sender: TObject); var Result : TNILMDevice; Dlg : TfNILMDevice; begin if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; Dlg := TfNILMDevice.Create(Self); try Dlg.InitForAdd; if Dlg.Execute(Result) then begin try if FNILMManager.SaveDevice(Result) then begin Log(reDBLog, 'complete', Format('[NILM] 장비 %d (%s) 추가 완료', [Result.DeviceID, Result.DeviceName])); NILM_LoadGrid; end else Log(reDBLog, 'error', Format('[NILM] 장비 %d (%s) 추가 실패', [Result.DeviceID, Result.DeviceName])); except on E: Exception do Log(reDBLog, 'error', '[NILM] 장비 추가 오류: ' + E.Message); end; if Assigned(Result.SensorList) then FreeAndNil(Result.SensorList); end; finally Dlg.Free; end; end; // ─── 장비 삭제 ──────────────────────────────────────────────────────────── procedure TfMain.btnNILMDeleteClick(Sender: TObject); var DevID: Integer; Row : Integer; begin DevID := NILM_SelectedDeviceID; if DevID < 0 then begin MessageDlg('삭제할 장비를 선택해주세요.', mtInformation, [mbOK], 0); Exit; end; Row := grdNILMDevice.Row; if MessageDlg(Format('장비 번호 %d [%s] 를 삭제하시겠습니까?' + sLineBreak + '연관된 센서 설정도 함께 삭제됩니다.', [DevID, grdNILMDevice.Cells[1, Row]]), mtConfirmation, [mbYes, mbNo], 0) = mrYes then begin if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; try FNILMManager.DeleteDevice(DevID); Log(reDBLog, 'complete', Format('[NILM] 장비 %d 삭제 완료', [DevID])); NILM_LoadGrid; except on E: Exception do Log(reDBLog, 'error', '[NILM] 장비 삭제 오류: ' + E.Message); end; end; end; // ─── 장비 수정 ──────────────────────────────────────────────────────────── procedure TfMain.btnNILMEditClick(Sender: TObject); var DevID : Integer; DevRec : TNILMDevice; EditRes: TNILMDevice; Dlg : TfNILMDevice; begin DevID := NILM_SelectedDeviceID; if DevID < 0 then begin MessageDlg('수정할 장비를 선택해주세요.', mtInformation, [mbOK], 0); Exit; end; if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; try DevRec := FNILMManager.LoadDevice(DevID); except on E: Exception do begin Log(reDBLog, 'error', '[NILM] 장비 로드 오류: ' + E.Message); Exit; end; end; try Dlg := TfNILMDevice.Create(Self); try Dlg.InitForEdit(DevRec); if Dlg.Execute(EditRes) then begin try if FNILMManager.SaveDevice(EditRes) then begin Log(reDBLog, 'complete', Format('[NILM] 장비 %d 수정 완료', [EditRes.DeviceID])); NILM_LoadGrid; if DevRec.MqttTopic <> EditRes.MqttTopic then begin if Assigned(FMQTTClient) and FMQTTClient.Connected then begin if DevRec.MqttTopic <> '' then begin FMQTTClient.Unsubscribe(DevRec.MqttTopic); Log(reMQTTLog, 'process', 'Unsubscribe(' + DevRec.MqttTopic + ')'); end; if EditRes.MqttTopic <> '' then addSubscribe(EditRes.MqttTopic); end; end; end else Log(reDBLog, 'error', Format('[NILM] 장비 %d 수정 실패', [EditRes.DeviceID])); except on E: Exception do Log(reDBLog, 'error', '[NILM] 장비 수정 오류: ' + E.Message); end; if Assigned(EditRes.SensorList) then FreeAndNil(EditRes.SensorList); end; finally Dlg.Free; end; finally if Assigned(DevRec.SensorList) then FreeAndNil(DevRec.SensorList); end; end; // ─── JSON 내보내기 ──────────────────────────────────────────────────────── procedure TfMain.btnNILMExportJSONClick(Sender: TObject); var Dlg : TSaveDialog; FilePath: string; begin if not Assigned(FDeviceList) or (FDeviceList.Count = 0) then begin MessageDlg('내보낼 장비 정보가 없습니다. 먼저 목록을 새로고침해주세요.', mtInformation, [mbOK], 0); Exit; end; Dlg := TSaveDialog.Create(nil); try Dlg.Title := 'NILM 설정 내보내기'; Dlg.Filter := 'JSON 파일 (*.json)|*.json'; Dlg.DefaultExt := 'json'; Dlg.FileName := 'nilm_config_' + FormatDateTime('yyyymmdd_hhnn', Now) + '.json'; if Dlg.Execute then begin FilePath := Dlg.FileName; if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; try FNILMManager.ExportToJSON(FilePath, FDeviceList); Log(reDBLog, 'complete', '[NILM] JSON 내보내기 완료: ' + FilePath); MessageDlg('JSON 내보내기가 완료되었습니다.' + sLineBreak + FilePath, mtInformation, [mbOK], 0); except on E: Exception do Log(reDBLog, 'error', '[NILM] JSON 내보내기 오류: ' + E.Message); end; end; finally Dlg.Free; end; end; // ─── JSON 가져오기 ──────────────────────────────────────────────────────── procedure TfMain.btnNILMImportJSONClick(Sender: TObject); var Dlg : TOpenDialog; FilePath : string; ImportList : TList; D : TNILMDevice; OkCnt, ErrCnt: Integer; begin Dlg := TOpenDialog.Create(nil); try Dlg.Title := 'NILM 설정 가져오기'; Dlg.Filter := 'JSON 파일 (*.json)|*.json'; if not Dlg.Execute then Exit; FilePath := Dlg.FileName; finally Dlg.Free; end; if MessageDlg('JSON 파일을 가져오면 기존 DB 데이터와 병합(UPSERT)됩니다.' + sLineBreak + '계속하시겠습니까?', mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; ImportList := nil; try ImportList := FNILMManager.ImportFromJSON(FilePath); OkCnt := 0; ErrCnt := 0; for D in ImportList do begin try var DTmp: TNILMDevice := D; if FNILMManager.SaveDevice(DTmp) then Inc(OkCnt) else Inc(ErrCnt);; except Inc(ErrCnt); end; end; Log(reDBLog, 'complete', Format('[NILM] JSON 가져오기 완료: 성공 %d, 실패 %d', [OkCnt, ErrCnt])); NILM_LoadGrid; MessageDlg(Format('JSON 가져오기가 완료되었습니다.' + sLineBreak + '성공: %d건, 실패: %d건', [OkCnt, ErrCnt]), mtInformation, [mbOK], 0); except on E: Exception do Log(reDBLog, 'error', '[NILM] JSON 가져오기 오류: ' + E.Message); end; if Assigned(ImportList) then begin for D in ImportList do if Assigned(D.SensorList) then D.SensorList.Free; ImportList.Free; end; end; procedure TfMain.btnNILMInitDBClick(Sender: TObject); begin if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; try FNILMManager.EnsureTables; Log(reDBLog, 'complete', '[NILM] DB 테이블 생성/확인 완료 (nilm_device, nilm_sensor_config)'); MessageDlg('DB 테이블 초기화가 완료되었습니다.', mtInformation, [mbOK], 0); except on E: Exception do begin Log(reDBLog, 'error', '[NILM] DB 테이블 초기화 오류: ' + E.Message); MessageDlg('오류: ' + E.Message, mtError, [mbOK], 0); end; end; end; procedure TfMain.btnNILMLoadClick(Sender: TObject); begin chartrefresh; end; procedure TfMain.btnNILMRefreshClick(Sender: TObject); begin NILM_InitGrid; NILM_LoadGrid; end; procedure TfMain.chartrefresh; var DevID : Integer; MaxR : Integer; Q : TFDQuery; datetime_str : string; begin if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; DevID := 0; if Assigned(cmbTrendDevice) and (cmbTrendDevice.ItemIndex > -1) then begin var S := cmbTrendDevice.Items[cmbTrendDevice.ItemIndex]; DevID := StrToIntDef(Copy(S, 2, Pos(']', S) - 2), 0); end; MaxR := StrToIntDef(edtHistMaxRows.Text, 500); FreeAndNil(FEventLogQuery); try Series1.Clear; Series2.Clear; Series3.Clear; Series4.Clear; Series5.Clear; // 히스토리 테이블 직접 조회 Q := FNILMManager.QueryNILMHistory( DevID, dtpTrendFrom.DateTime, dtpTrendTo.DateTime + 1, -1, MaxR); FEventLogQuery := Q; // 메모리 해제를 위해 기존 변수 재활용 또는 FHistoryQuery 할당 가능 // 차트 데이터가 있으면 역순으로 정렬되어 나오므로, // 최신 값이 오른쪽에 그려지게 하려면 배열에 넣거나 순서를 뒤집어야 할 수도 있지만 // 기존 로직을 그대로 유지합니다. while not Q.EOF do begin datetime_str := FormatDateTime('yy-mm-dd hh:nn:ss', Q.FieldByName('received_at').AsDateTime); case rgCHSelect.ItemIndex of 0 : begin // L1 if cbpf.Checked then Series1.Add(Q.FieldByName('power_factor_a').AsFloat, datetime_str, $000080FF); if cbvoltage.Checked then Series2.Add(Q.FieldByName('vrms_a').AsFloat, datetime_str, clRed); if cbcurrent.Checked then Series3.Add(Q.FieldByName('irms_a').AsFloat, datetime_str, clGreen); if cbVar.Checked then Series4.Add(Q.FieldByName('reactive_power_a').AsFloat, datetime_str, $00FF0080); if cbVa.Checked then Series5.Add(Q.FieldByName('apparent_power_a').AsFloat, datetime_str, $00FF8000); if cbw.Checked then Series6.Add(Q.FieldByName('active_power_a').AsFloat, datetime_str, clBlue); end; 1 : begin // L2 if cbpf.Checked then Series1.Add(Q.FieldByName('power_factor_b').AsFloat, datetime_str, $000080FF); if cbvoltage.Checked then Series2.Add(Q.FieldByName('vrms_b').AsFloat, datetime_str, clRed); if cbcurrent.Checked then Series3.Add(Q.FieldByName('irms_b').AsFloat, datetime_str, clGreen); if cbVar.Checked then Series4.Add(Q.FieldByName('reactive_power_b').AsFloat, datetime_str, $00FF0080); if cbVa.Checked then Series5.Add(Q.FieldByName('apparent_power_b').AsFloat, datetime_str, $00FF8000); if cbw.Checked then Series6.Add(Q.FieldByName('active_power_b').AsFloat, datetime_str, clBlue); end; 2 : begin // L3 if cbpf.Checked then Series1.Add(Q.FieldByName('power_factor_c').AsFloat, datetime_str, $000080FF); if cbvoltage.Checked then Series2.Add(Q.FieldByName('vrms_c').AsFloat, datetime_str, clRed); if cbcurrent.Checked then Series3.Add(Q.FieldByName('irms_c').AsFloat, datetime_str, clGreen); if cbVar.Checked then Series4.Add(Q.FieldByName('reactive_power_c').AsFloat, datetime_str, $00FF0080); if cbVa.Checked then Series5.Add(Q.FieldByName('apparent_power_c').AsFloat, datetime_str, $00FF8000); if cbw.Checked then Series6.Add(Q.FieldByName('active_power_c').AsFloat, datetime_str, clBlue); end; end; Q.Next; end; if cbpf.Checked then Series1.Visible := True; if cbvoltage.Checked then Series2.Visible := True; if cbcurrent.Checked then Series3.Visible := True; if cbVar.Checked then Series4.Visible := True; if cbVa.Checked then Series5.Visible := True; if cbw.Checked then Series6.Visible := True; lblTrendCount.Caption := Format('트렌드 로그: %d 건', [Q.RecordCount]); except on E: Exception do Log(reDBLog, 'error', '[트렌드 로그] 조회 오류: ' + E.Message); end; end; procedure TfMain.Chart_MainClickSeries(Sender: TCustomChart; Series: TChartSeries; ValueIndex: Integer; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); var ckSTR : string; begin ckSTR := '['+ 'NILM: '+cmbTrendDevice.Text+','+ 'CH: '+rgCHSelect.Items[rgCHSelect.ItemIndex]+','+ 'DateTime:'+'20'+Series.XLabel[ValueIndex]; if (Assigned(Series1) and Series1.Visible and (Series1.YValues.Count > 0)) then ckSTR := ckSTR +','+'PF:'+ Series1.YValue[ValueIndex].ToString else ckSTR := ckSTR +','+'PF:'+ '---'; if (Assigned(Series2) and Series2.Visible and (Series2.YValues.Count > 0)) then ckSTR := ckSTR +','+'Volt:'+ Series2.YValue[ValueIndex].ToString else ckSTR := ckSTR +','+'Volt:'+ '---'; if (Assigned(Series3) and Series3.Visible and (Series3.YValues.Count > 0)) then ckSTR := ckSTR +','+'CUR:'+ Series3.YValue[ValueIndex].ToString else ckSTR := ckSTR +','+'CUR:'+ '---'; if (Assigned(Series4) and Series4.Visible and (Series4.YValues.Count > 0)) then ckSTR := ckSTR +','+'Var:'+ Series4.YValue[ValueIndex].ToString else ckSTR := ckSTR +','+'Var:'+ '---'; if (Assigned(Series5) and Series5.Visible and (Series5.YValues.Count > 0)) then ckSTR := ckSTR +','+'VA:'+ Series5.YValue[ValueIndex].ToString else ckSTR := ckSTR +','+'VA:'+ '---'; ckSTR := ckSTR +']'; reTrendCheckLog.Lines.add( ckSTR ); end; { var i : integer; ck, ck_min, ck_max, ck_sum : Double; ck1, ck_min1, ck_max1, ck_sum1 : Double; ck2, ck_min2, ck_max2, ck_sum2 : Double; ck3, ck_min3, ck_max3, ck_sum3 : Double; ck4, ck_min4, ck_max4, ck_sum4 : Double; begin Series1.Clear; Series2.Clear; Series3.Clear; Series4.Clear; Series5.Clear; ck_min :=0.0; ck_max := 0.0; ck_sum :=0; ck_min1:=0.0; ck_max1 := 0.0; ck_sum1:=0; ck_min2:=0.0; ck_max2 := 0.0; ck_sum2:=0; ck_min3:=0.0; ck_max3 := 0.0; ck_sum3:=0; ck_min4:=0.0; ck_max4 := 0.0; ck_sum4:=0; for I := 0 to 100 do begin if cbpf.Checked then Series1.Add(I*1, IntToStr(I), $000080FF); if cbvoltage.Checked then Series2.Add(I*10, IntToStr(I), clRed); if cbcurrent.Checked then Series3.Add(I*100, IntToStr(I), clGreen); if cbVar.Checked then Series4.Add(I*1000, IntToStr(I), $00FF0080); if cbVa.Checked then Series5.Add(I*10000, IntToStr(I), $00FF8000); if cbw.Checked then Series6.Add(I*100000, IntToStr(I), clBlue); end; if cbpf.Checked then Series1.Visible := True; if cbvoltage.Checked then Series2.Visible := True; if cbcurrent.Checked then Series3.Visible := True; if cbVar.Checked then Series4.Visible := True; if cbVa.Checked then Series5.Visible := True; if cbw.Checked then Series6.Visible := True; for I := 1 to AdvColumnGrid1.RowCount-1 do begin ck := AdvColumnGrid1.Floats[2,i]; if (ck_min=0) or (ck_min > ck) then ck_min := ck; if (ck_max=0) or (ck_max < ck) then ck_max := ck; ck_sum := ck_sum + ck; ck1 := AdvColumnGrid1.Floats[3,i]; if (ck_min1=0) or (ck_min1 > ck1) then ck_min1 := ck1; if (ck_max1=0) or (ck_max1 < ck1) then ck_max1 := ck1; ck_sum1 := ck_sum1 + ck1; ck2 := AdvColumnGrid1.Floats[4,i]; if (ck_min2=0) or (ck_min2 > ck2) then ck_min2 := ck2; if (ck_max2=0) or (ck_max2 < ck2) then ck_max2 := ck2; ck_sum2 := ck_sum2 + ck2; ck3 := AdvColumnGrid1.Floats[5,i]; if (ck_min3=0) or (ck_min3 > ck3) then ck_min3 := ck3; if (ck_max3=0) or (ck_max3 < ck3) then ck_max3 := ck3; ck_sum3 := ck_sum3 + ck3; ck4 := AdvColumnGrid1.Floats[6,i]; if (ck_min4=0) or (ck_min4 > ck4) then ck_min4 := ck4; if (ck_max4=0) or (ck_max4 < ck4) then ck_max4 := ck4; ck_sum4 := ck_sum4 + ck4; if CheckBox1.Checked then Series1.Add(ck*1000000, AdvColumnGrid1.Cells[0,i], $000080FF); if CheckBox2.Checked then Series2.Add(ck1*1000, AdvColumnGrid1.Cells[0,i], clRed); if CheckBox3.Checked then Series3.Add(ck2*100000, AdvColumnGrid1.Cells[0,i], clGreen); if CheckBox4.Checked then Series4.Add(ck3*1000, AdvColumnGrid1.Cells[0,i], $00FF0080); if CheckBox5.Checked then Series5.Add(ck4*1000, AdvColumnGrid1.Cells[0,i], $00FF8000); end; ListBox2.Clear; ListBox2.Items.Add('최소값 : '+Format('%s / %s / %s / %s / %s', [LeftStr( FloatToStr(ck_min)+' ',30), LeftStr( FloatToStr(ck_min1)+' ',30), LeftStr( FloatToStr(ck_min2)+' ',30), LeftStr( FloatToStr(ck_min3)+' ',30), LeftStr( FloatToStr(ck_min4)+' ',30)])); ListBox2.Items.Add('최대값 : '+Format('%s / %s / %s / %s / %s', [LeftStr( FloatToStr(ck_max)+' ',30), LeftStr( FloatToStr(ck_max1)+' ',30), LeftStr( FloatToStr(ck_max2)+' ',30), LeftStr( FloatToStr(ck_max3)+' ',30), LeftStr( FloatToStr(ck_max4)+' ',30)])); ListBox2.Items.Add('평균값 : '+Format('%s / %s / %s / %s / %s', [LeftStr( FloatToStr(ck_sum / (AdvColumnGrid1.RowCount-1))+' ',20), LeftStr( FloatToStr(ck_sum1 / (AdvColumnGrid1.RowCount-1))+' ',20), LeftStr( FloatToStr(ck_sum2 / (AdvColumnGrid1.RowCount-1))+' ',20), LeftStr( FloatToStr(ck_sum3 / (AdvColumnGrid1.RowCount-1))+' ',20), LeftStr( FloatToStr(ck_sum4 / (AdvColumnGrid1.RowCount-1))+' ',20)])); if CheckBox1.Checked then Series1.Visible := True; if CheckBox2.Checked then Series2.Visible := True; if CheckBox3.Checked then Series3.Visible := True; if CheckBox4.Checked then Series4.Visible := True; if CheckBox5.Checked then Series5.Visible := True; end; } procedure TfMain.btnOPCConnectClick(Sender: TObject); var i: Integer; StepMsg: string; begin if Trim(edtOPCServerName.Text) = '' then begin Log(reOPCLog,'error','OPC Server Name is not registered.'); Exit; end; if bConnect then begin try if not VarIsEmpty(OPCGroup) then OPCServer.OPCGroups.RemoveAll; if not VarIsEmpty(OPCServer) then OPCServer.Disconnect; except end; for i := 1 to ITEMMAX do OPCItemObjects[i] := Unassigned; OPCGroup := Unassigned; OPCServer := Unassigned; bConnect := False; Log(reOPCLog,'process','OPC DisConnected.'); lblOPCConnectionStatus.Caption := 'DisConnected'; lblOPCConnectionStatus.Font.Color := CLR_RED; lblOPCConnectionStatus.Tag := 2; btnOPCRead.Enabled := False; btnOPCWrite.Enabled := False; edtOPCServerName.Enabled := True; // btnAdvise.Enabled := False; for i := 1 to ITEMMAX do txtItemName[i - 1].Enabled := True; end; try StepMsg := 'CreateOleObject'; // Create OPC Server using Late Binding OPCServer := CreateOleObject('OPC.Automation.1'); StepMsg := 'OPCServer.Connect'; OPCServer.Connect(edtOPCServerName.Text, EmptyParam); StepMsg := 'OPCGroups.Add'; OPCGroup := OPCServer.OPCGroups.Add('Group1'); StepMsg := 'Set UpdateRate'; OPCGroup.UpdateRate := StrToIntDef(edtOPCUpdateRate.Text, 1000); StepMsg := 'Set IsActive'; OPCGroup.IsActive := True; StepMsg := 'Set IsSubscribed'; OPCGroup.IsSubscribed := False; StepMsg := 'AddItem (Loop)'; bConnect := True; // 20260624 btnOPCRead.Enabled := True; // 20260624 btnOPCWrite.Enabled := True; edtOPCServerName.Enabled := False; // btnAdvise.Enabled := True; lblOPCConnectionStatus.Caption := 'Connected'; lblOPCConnectionStatus.Font.Color := CLR_GREEN; lblOPCConnectionStatus.Tag := 1; Log(reOPCLog,'process','OPC Connected.'); // 20260624 btnOPCReadClick(Sender); except on E: Exception do begin Log(reOPCLog,'error','OPC Connection failed at [' + StepMsg + ']: ' + E.Message); Exit; end; end; end; procedure TfMain.btnOPCDisConnectClick(Sender: TObject); var i : integer; begin if bConnect then begin try Log(reOPCLog, 'process', ' OPC Step: Unassign OPCItemObjects'); for i := 1 to ITEMMAX do OPCItemObjects[i] := Unassigned; Log(reOPCLog, 'process', ' OPC Step: FDynamicOPCItems.Clear'); if FDynamicOPCItems <> nil then FDynamicOPCItems.Clear; Log(reOPCLog, 'process', ' OPC Step: Unassign OPCGroup'); OPCGroup := Unassigned; if not VarIsEmpty(OPCServer) then begin Log(reOPCLog, 'process', ' OPC Step: Skipping RemoveAll and Disconnect to prevent DLL crash'); // try OPCServer.OPCGroups.RemoveAll; except end; // try OPCServer.Disconnect; except end; end; except on E: Exception do Log(reOPCLog, 'error', 'Ignored COM Error during OPC disconnect: ' + E.Message); end; Log(reOPCLog, 'process', ' OPC Step: Unassign OPCServer'); try OPCServer := Unassigned; except end; Log(reOPCLog, 'process', ' OPC Step: Clearing collections'); FPreviousOPCValues.Clear; bConnect := False; Log(reOPCLog,'process','OPC DisConnected.'); Log(reOPCLog,'process',' OPC Step: Update UI (Status)'); lblOPCConnectionStatus.Caption := 'DisConnected'; lblOPCConnectionStatus.Font.Color := CLR_RED; lblOPCConnectionStatus.Tag := 2; Log(reOPCLog,'process',' OPC Step: Update UI (ServerName)'); edtOPCServerName.Enabled := True; Log(reOPCLog,'process',' OPC Step: Update UI (txtItemName)'); try for i := 1 to ITEMMAX do begin if Assigned(txtItemName[i - 1]) then txtItemName[i - 1].Enabled := True; end; except on E: Exception do Log(reOPCLog, 'error', ' Error updating txtItemName: ' + E.Message); end; Log(reOPCLog,'process',' OPC Step: Update UI (btnMainStart)'); try if not btnMainStart.Enabled then btnMainStart.Enabled := true; except end; Log(reOPCLog,'process',' OPC Step: btnOPCDisConnectClick Returning'); end; end; procedure TfMain.btnOPCReadClick(Sender: TObject); var i: Integer; ItemValue: OLEVariant; Quality: OLEVariant; TimeStamp: OLEVariant; begin if not bConnect then Exit; try for i := 1 to ITEMMAX do begin // 빈 슬롯 스킵 if VarIsEmpty(OPCItemObjects[i]) or VarIsNull(OPCItemObjects[i]) then Continue; OPCItemObjects[i].Read(Integer(1), ItemValue, Quality, TimeStamp); // CanonicalDataType: 서버가 선언한 태그의 원본 타입 (VarType(ItemValue)와 다를 수 있음) // Write 시 반드시 이 타입을 사용해야 E_INVALIDARG를 피할 수 있음 FItemVarTypes[i] := OPCItemObjects[i].CanonicalDataType; case FItemVarTypes[i] of varSmallInt: txtType[i-1].Text := 'Short'; varInteger: txtType[i-1].Text := 'Int'; varSingle: txtType[i-1].Text := 'Float'; varDouble: txtType[i-1].Text := 'Double'; varBoolean: txtType[i-1].Text := 'Bool'; varOleStr: txtType[i-1].Text := 'String'; varByte: txtType[i-1].Text := 'Byte'; varWord: txtType[i-1].Text := 'Word'; varLongWord: txtType[i-1].Text := 'DWord'; varInt64: txtType[i-1].Text := 'Int64'; else txtType[i-1].Text := 'CDT' + IntToStr(FItemVarTypes[i]); end; txtValue[i - 1].Text := VarToStr(ItemValue); if not VarIsEmpty(TimeStamp) and not VarIsNull(TimeStamp) then txtTime[i - 1].Text := DateTimeToStr(VarToDateTime(TimeStamp)); if not VarIsEmpty(Quality) and not VarIsNull(Quality) then txtQuality[i - 1].Text := VarToStr(Quality); end; except on E: Exception do Log(reOPCLog,'error','OPC Read Error: ' + E.Message); end; end; procedure TfMain.btnOPCWriteClick(Sender: TObject); var i, cnt: Integer; ServerHandles: OLEVariant; ItemValues: OLEVariant; Errors: OLEVariant; targetVT: Integer; inputText: string; begin if not bConnect then Exit; // 유효한 아이템 수 집계 cnt := 0; for i := 1 to ITEMMAX do if not VarIsEmpty(OPCItemObjects[i]) and not VarIsNull(OPCItemObjects[i]) then Inc(cnt); if cnt = 0 then Exit; ServerHandles := VarArrayCreate([0, cnt - 1], varInteger); ItemValues := VarArrayCreate([0, cnt - 1], varVariant); Errors := VarArrayCreate([0, cnt - 1], varInteger); cnt := -1; for i := 1 to ITEMMAX do begin if VarIsEmpty(OPCItemObjects[i]) or VarIsNull(OPCItemObjects[i]) then Continue; inputText := Trim(txtValue[i - 1].Text); if inputText = '' then Continue; Inc(cnt); ServerHandles[cnt] := Integer(sH[i]); // CanonicalDataType 기반으로 올바른 타입의 Variant 생성 // ※ VarAsType() 대신 Delphi 직접 캐스팅 사용: // varVariant 배열 요소에 VarAsType 결과를 넣으면 VT_VARIANT|VT_I2 로 이중 래핑되어 // SyncWrite에서 DISP_E_TYPEMISMATCH가 발생할 수 있음 targetVT := FItemVarTypes[i]; if targetVT = 0 then targetVT := varSmallInt; case targetVT of varSmallInt, varInteger, varByte, varWord: ItemValues[cnt] := Integer(StrToIntDef(inputText, 0)); varLongWord: ItemValues[cnt] := Integer(StrToInt64Def(inputText, 0)); varInt64: ItemValues[cnt] := StrToInt64Def(inputText, 0); varSingle, varDouble: ItemValues[cnt] := Double(StrToFloatDef(inputText, 0.0)); varBoolean: ItemValues[cnt] := OleVariant(Boolean( (inputText <> '0') and (inputText <> '') )); varOleStr: ItemValues[cnt] := OleVariant(inputText); else ItemValues[cnt] := OleVariant(inputText); end; try OPCItemObjects[i].Write(ItemValues[cnt]); Log(reOPCLog,'process','['+IntToStr(i)+'/'+inputText+'] Write 완료!'); except on E: EOleException do Log(reOPCLog,'error', 'Write Error [0x' + IntToHex(E.ErrorCode, 8) + ']: ' + E.Message + ' / ' + 'CanonicalDataType: ' + IntToStr(targetVT) + ' / ' + '입력값: ' + inputText); on E: Exception do Log(reOPCLog,'error', 'Write Error [' + E.ClassName + ']: ' + E.Message); end; end; end; { procedure TfMain.btnOPCWriteClick(Sender: TObject); var i, cnt: Integer; ServerHandles: OLEVariant; ItemValues: OLEVariant; Errors: OLEVariant; targetVT: Integer; inputText: string; ItemValue: OLEVariant; Quality: OLEVariant; TimeStamp: OLEVariant; begin if not bConnect then Exit; // 유효한 아이템 수 집계 cnt := 0; for i := 1 to ITEMMAX do if not VarIsEmpty(OPCItemObjects[i]) and not VarIsNull(OPCItemObjects[i]) then Inc(cnt); if cnt = 0 then Exit; ServerHandles := VarArrayCreate([0, cnt - 1], varInteger); ItemValues := VarArrayCreate([0, cnt - 1], varVariant); Errors := VarArrayCreate([0, cnt - 1], varInteger); cnt := -1; for i := 1 to ITEMMAX do begin if VarIsEmpty(OPCItemObjects[i]) or VarIsNull(OPCItemObjects[i]) then Continue; inputText := Trim(txtValue[i - 1].Text); if inputText = '' then Continue; Inc(cnt); ServerHandles[cnt] := Integer(sH[i]); // CanonicalDataType 기반으로 올바른 타입의 Variant 생성 // ※ VarAsType() 대신 Delphi 직접 캐스팅 사용: // varVariant 배열 요소에 VarAsType 결과를 넣으면 VT_VARIANT|VT_I2 로 이중 래핑되어 // SyncWrite에서 DISP_E_TYPEMISMATCH가 발생할 수 있음 targetVT := FItemVarTypes[i]; if targetVT = 0 then targetVT := varSmallInt; case targetVT of varSmallInt, varInteger, varByte, varWord: ItemValues[cnt] := Integer(StrToIntDef(inputText, 0)); varLongWord: ItemValues[cnt] := Integer(StrToInt64Def(inputText, 0)); varInt64: ItemValues[cnt] := StrToInt64Def(inputText, 0); varSingle, varDouble: ItemValues[cnt] := Double(StrToFloatDef(inputText, 0.0)); varBoolean: ItemValues[cnt] := OleVariant(Boolean( (inputText <> '0') and (inputText <> '') )); varOleStr: ItemValues[cnt] := OleVariant(inputText); else ItemValues[cnt] := OleVariant(inputText); end; try OPCItemObjects[i].Write(ItemValues[cnt]); Log(reOPCLog,'process','['+IntToStr(i)+'/'+inputText+'] Write 완료!'); except on E: EOleException do Log(reOPCLog,'error', 'Write Error [0x' + IntToHex(E.ErrorCode, 8) + ']: ' + E.Message + ' / ' + 'CanonicalDataType: ' + IntToStr(targetVT) + ' / ' + '입력값: ' + inputText); on E: Exception do Log(reOPCLog,'error', 'Write Error [' + E.ClassName + ']: ' + E.Message); end; end; end; } procedure TfMain.FormCreate(Sender: TObject); begin pcMain.ActivePageIndex := 0; pcSetting.ActivePageIndex := 0; pcHistory.ActivePageIndex := 0; // BaseList_NILMItem := TList.Create; BaseList_NILMItem := TObjectList.Create(True); NILMItem := TNILMItem.Create; FPreviousOPCValues := TDictionary.Create; FDynamicOPCItems := TDictionary.Create; FLastEnergyUpdate := TDictionary.Create; FDayEnergyValue := TDictionary.Create; FLastDailyEnergySyncDate := Date; FLastRecvTime := TDictionary.Create; FDeviceTimeoutHandled := TDictionary.Create; if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; FNILMManager.OnError := NILMErrorHandler; dtpHistFrom.DateTime := now; dtpHistTo.DateTime := now; dtpEvtFrom.DateTime := now; dtpEvtTo.DateTime := now; dtpTrendFrom.DateTime := now; dtpTrendTo.DateTime := now; InitLogger('.\Logs', 'log', rtHourly); // SetupUI; LoadSettings; FLastOPCRetryTime := Now; if cbAutoStart.Checked then begin TThread.CreateAnonymousThread( procedure begin Sleep(500); // 폼과 데이터모듈(DM)이 완전히 생성될 때까지 충분히 대기 TThread.ForceQueue(nil, procedure begin if btnMainStart.Enabled then btnMainStart.Click; end); end).Start; end; end; procedure TfMain.FormDestroy(Sender: TObject); begin SaveSettings; if FMQTTClient <> nil then begin FMQTTClient.Disconnect; FreeAndNil(FMQTTClient); end; BaseList_NILMItem.Free; NILMItem.Free; FPreviousOPCValues.Free; FDynamicOPCItems.Free; FLastEnergyUpdate.Free; FDayEnergyValue.Free; FLastRecvTime.Free; FDeviceTimeoutHandled.Free; end; // ─── MQTT ──────────────────────────────────────────────────────────────────── procedure TfMain.OnMQTTStatus(AConnected: Boolean); begin if AConnected then begin lblMQTTConnectionStatus.Caption := 'Connected'; lblMQTTConnectionStatus.Font.Color := CLR_GREEN; lblMQTTConnectionStatus.Tag := 1; btnSubscribe.Enabled := True; btnSubscribe.Click; Log(reMQTTLog,'process','MQTT Connected.'); end else begin lblMQTTConnectionStatus.Caption := 'DisConnected'; lblMQTTConnectionStatus.Font.Color := CLR_RED; lblMQTTConnectionStatus.Tag := 2; Log(reMQTTLog,'process','MQTT Connection Error - Connection to server was lost. Auto reconnecting in background...'); btnSubscribe.Enabled := False; end; end; procedure TfMain.tmrdelaystopTimer(Sender: TObject); begin // tmrdelaystop.Enabled := False; // Self.Enabled := True; end; procedure TfMain.tmrTimeTimer(Sender: TObject); begin lbDateTime.Caption := FormatDateTime('yyyy-mm-dd hh:nn:ss',now); // 1분(60초) 이상 통신 두절된 장비 감시 및 전원꺼짐(0) / COMM=255 자동 처리 CheckDeviceTimeouts; // 10분(10.0 / 1440.0)마다 실패했던 태그들의 이력을 지워서 다시 시도하도록 함 if (Now - FLastOPCRetryTime) >= (10.0 / 1440.0) then begin FLastOPCRetryTime := Now; ClearFailedOPCTags; end; // 자정(00시 00분) 일일 전력량 외부 DB 동기화 (하루 1회) if (Date > FLastDailyEnergySyncDate) and (FormatDateTime('hhnn', Now) = '0000') then begin FLastDailyEnergySyncDate := Date; if Assigned(FDeviceList) and Assigned(FNILMManager) then begin var D: TNILMDevice; for D in FDeviceList do begin var EnergyWh: Double := 0; if FDayEnergyValue.TryGetValue(D.DeviceID, EnergyWh) then begin // 자정에 전송하는 데이터는 "어제(Date - 1)" 기준 var devName := Trim(D.DeviceName); if devName = '' then devName := IntToStr(D.DeviceID); FNILMManager.SaveExternalDailyEnergy(devName, Date - 1, EnergyWh); end; // 다음 날을 위해 현재 장비 누적 전력량 초기화 FDayEnergyValue.AddOrSetValue(D.DeviceID, 0); end; end; end; end; procedure TfMain.CheckDeviceTimeouts; var currTime: TDateTime; D: TNILMDevice; did: Integer; lastTime: TDateTime; bHandled: Boolean; devName: string; begin currTime := Now; // 1. 등록된 장비 목록 확인 if Assigned(FDeviceList) then begin for D in FDeviceList do begin did := D.DeviceID; devName := D.DeviceName; if FLastRecvTime.TryGetValue(did, lastTime) and (lastTime > 0) then begin // 1분(60초) 이상 통신 패킷이 들어오지 않은 경우 if (currTime - lastTime) >= (60.0 / 86400.0) then begin bHandled := False; FDeviceTimeoutHandled.TryGetValue(did, bHandled); if not bHandled then HandleDeviceTimeout(did, devName); end; end; end; end; // 2. 그리드에 올라와 있는 장비도 FLastRecvTime 기반으로 감시 for var r := 1 to grdMQTTMonitor.RowCount - 1 do begin did := StrToIntDef(grdMQTTMonitor.Cells[0, r], 0); if did > 0 then begin if FLastRecvTime.TryGetValue(did, lastTime) and (lastTime > 0) then begin if (currTime - lastTime) >= (60.0 / 86400.0) then begin bHandled := False; FDeviceTimeoutHandled.TryGetValue(did, bHandled); if not bHandled then begin devName := IntToStr(did); if Assigned(FDeviceList) then for D in FDeviceList do if D.DeviceID = did then begin devName := D.DeviceName; Break; end; HandleDeviceTimeout(did, devName); end; end; end; end; end; end; procedure TfMain.HandleDeviceTimeout(ADeviceID: Integer; const ADeviceName: string); var NilmName: string; row_ck: Integer; Q: TFDQuery; begin FDeviceTimeoutHandled.AddOrSetValue(ADeviceID, True); NilmName := Trim(ADeviceName); if NilmName = '' then NilmName := IntToStr(ADeviceID); // 1. 화면 모니터링 그리드 (grdMQTTMonitor) 갱신 row_ck := 0; for var r := 1 to grdMQTTMonitor.RowCount - 1 do begin if grdMQTTMonitor.Cells[0, r] = ADeviceID.ToString then begin row_ck := r; Break; end; end; if row_ck > 0 then begin grdMQTTMonitor.Cells[4, row_ck] := '전원꺼짐(0)'; end; // 2. OPC 서버 쓰기 (COMM=255 통신두절, OpStatus=0 전원꺼짐) WriteOPCValueIfChanged('SYSTEM.' + IntToStr(ADeviceID) + '/COMM', '255'); WriteOPCValueIfChanged('SYSTEM.' + IntToStr(ADeviceID) + '/OpStatus', '0'); // 3. 외부 타사 DB (inf_operation_active / inf_operation_history) 상태 '0'(전원꺼짐) 연동 if Assigned(FNILMManager) then begin try FNILMManager.SaveExternalOpStatus(NilmName, 0); except on E: Exception do Log(reDBLog, 'error', Format('[타임아웃] 외부 DB 상태 연동 오류 (%s): %s', [NilmName, E.Message])); end; end; // 4. 내부 DB nilm_data 테이블 최신 상태 '0' 및 통신 255로 동기화 if Assigned(DM) and DM.fdConnNilm.Connected then begin Q := TFDQuery.Create(nil); try Q.Connection := DM.fdConnNilm; Q.SQL.Text := 'UPDATE nilm_data SET op_status = ''0'', comm_status = 255 WHERE device_id = :did'; Q.ParamByName('did').AsInteger := ADeviceID; Q.ExecSQL; except end; Q.Free; end; Log(reMainLog, 'data', Format('[장비 %d (%s)] 1분 이상 통신 단절 감지 -> 전원꺼짐(0) / COMM=255 자동 전환', [ADeviceID, NilmName])); end; function TfMain.gridmonitorRefresh_main(ADeviceID, ASeq, APreSeq, ACommStatus: Integer; const APayloadJSON, AOpStatus: string; AData: TJSONObject; ASensorList: TNILMSensorConfigList): Boolean; function GetNum(const AKey: string): Double; var JV: TJSONValue; begin Result := 0; JV := AData.GetValue(AKey); if Assigned(JV) then Result := StrToFloatDef(JV.Value, 0); end; function GetStr(const AKey, ADefault: string): string; var JV: TJSONValue; begin Result := ADefault; JV := AData.GetValue(AKey); if Assigned(JV) then Result := JV.Value; end; function GetJsonKey(const AFieldKey, ADefault: string): string; var SC: TNILMSensorConfig; begin Result := ADefault; if Assigned(ASensorList) then for SC in ASensorList do if SameText(SC.FieldKey, AFieldKey) then begin if SC.JsonKey <> '' then Result := SC.JsonKey; Exit; end; end; var row_ck : Integer; wireVal: string; is3P3W : Boolean; begin Result := False; // row 찾기 row_ck := 0; for var r := 1 to grdMQTTMonitor.RowCount - 1 do begin if grdMQTTMonitor.Cells[0, r] = ADeviceID.ToString then begin row_ck := r; Break; end; end; // 없으면 신규 row 추가 if row_ck = 0 then begin if Trim(grdMQTTMonitor.Cells[ 0, 1])='' then begin row_ck := 1; end else begin row_ck := grdMQTTMonitor.RowCount; grdMQTTMonitor.RowCount := grdMQTTMonitor.RowCount + 1; end; grdMQTTMonitor.Cells[ 0, row_ck] := ADeviceID.ToString; end; wireVal := GetStr('wire', PHASE_3P4W); is3P3W := SameText(wireVal, PHASE_3P3W); var bSeqChanged: Boolean := (grdMQTTMonitor.Cells[ 2, row_ck] <> ASeq.ToString) or (ASeq = 0); Result := bSeqChanged; grdMQTTMonitor.Cells[ 0, row_ck] := ADeviceID.ToString; grdMQTTMonitor.Cells[ 1, row_ck] := FormatDateTime('yyyymmdd hhnnss', now); grdMQTTMonitor.Cells[ 2, row_ck] := ASeq.ToString; grdMQTTMonitor.Cells[ 3, row_ck] := wireVal; var statusStr: string := '???'; if AOpStatus = '2' then statusStr := '가동(2)' else if AOpStatus = '1' then statusStr := '미가동(1)' else if AOpStatus = '0' then statusStr := '전원꺼짐(0)'; grdMQTTMonitor.Cells[ 4, row_ck] := statusStr; // RSSI grdMQTTMonitor.Cells[ 5, row_ck] := Format('%ddBm', [Trunc(GetNum('rssi'))]); // Total W, Total PF grdMQTTMonitor.Cells[ 6, row_ck] := FormatFloat('#,##0.0', GetNum('total_p')); grdMQTTMonitor.Cells[ 7, row_ck] := FormatFloat('0.000', GetNum('total_pf')); // A상 (ch[0]) grdMQTTMonitor.Cells[ 8, row_ck] := FormatFloat('0.000', GetNum('power_factor_a')); grdMQTTMonitor.Cells[ 9, row_ck] := FormatFloat('0.0', GetNum('vrms_a')); grdMQTTMonitor.Cells[10, row_ck] := FormatFloat('0.00', GetNum('irms_a')); grdMQTTMonitor.Cells[11, row_ck] := FormatFloat('0.0', GetNum('active_power_a')); grdMQTTMonitor.Cells[12, row_ck] := FormatFloat('0.0', GetNum('reactive_power_a')); grdMQTTMonitor.Cells[13, row_ck] := FormatFloat('0.0', GetNum('apparent_power_a')); // B상 (ch[1]) - 3P3W일 경우 미사용 표시 if is3P3W then begin grdMQTTMonitor.Cells[14, row_ck] := '-'; grdMQTTMonitor.Cells[15, row_ck] := '-'; grdMQTTMonitor.Cells[16, row_ck] := '-'; grdMQTTMonitor.Cells[17, row_ck] := '-'; grdMQTTMonitor.Cells[18, row_ck] := '-'; grdMQTTMonitor.Cells[19, row_ck] := '-'; end else begin grdMQTTMonitor.Cells[14, row_ck] := FormatFloat('0.000', GetNum('power_factor_b')); grdMQTTMonitor.Cells[15, row_ck] := FormatFloat('0.0', GetNum('vrms_b')); grdMQTTMonitor.Cells[16, row_ck] := FormatFloat('0.00', GetNum('irms_b')); grdMQTTMonitor.Cells[17, row_ck] := FormatFloat('0.0', GetNum('active_power_b')); grdMQTTMonitor.Cells[18, row_ck] := FormatFloat('0.0', GetNum('reactive_power_b')); grdMQTTMonitor.Cells[19, row_ck] := FormatFloat('0.0', GetNum('apparent_power_b')); end; // C상 (ch[2]) grdMQTTMonitor.Cells[20, row_ck] := FormatFloat('0.000', GetNum('power_factor_c')); grdMQTTMonitor.Cells[21, row_ck] := FormatFloat('0.0', GetNum('vrms_c')); grdMQTTMonitor.Cells[22, row_ck] := FormatFloat('0.00', GetNum('irms_c')); grdMQTTMonitor.Cells[23, row_ck] := FormatFloat('0.0', GetNum('active_power_c')); grdMQTTMonitor.Cells[24, row_ck] := FormatFloat('0.0', GetNum('reactive_power_c')); grdMQTTMonitor.Cells[25, row_ck] := FormatFloat('0.0', GetNum('apparent_power_c')); // 누적 전력량 (wh) grdMQTTMonitor.Cells[26, row_ck] := FormatFloat('#,##0', GetNum('wh')); // 당일 누적 전력량 갱신 (1분마다 1번씩만 DB 쿼리하여 부하 방지) var LastUpdate: TDateTime; if not FLastEnergyUpdate.TryGetValue(ADeviceID, LastUpdate) or (Now - LastUpdate >= 1.0 / 1440.0) then begin var E1, E2, E3: Double; FNILMManager.CalculateEnergyConsumption(ADeviceID, Date, Now, E1, E2, E3); var EnergyWh: Double := E1 + E2 + E3; FDayEnergyValue.AddOrSetValue(ADeviceID, EnergyWh); FLastEnergyUpdate.AddOrSetValue(ADeviceID, Now); end; if FDayEnergyValue.ContainsKey(ADeviceID) then grdMQTTMonitor.Cells[27, row_ck] := FormatFloat('#,##0.0', FDayEnergyValue[ADeviceID]) else grdMQTTMonitor.Cells[27, row_ck] := '-'; end; { procedure TfMain.gridmonitorRefresh_main(const S: string); var row_ck, i, x : Integer; JSONObject : TJSONObject; JSONArray : TJSONArray; ItemObj : TJSONObject; Item_NewCk, Item_UpdateCk : Boolean; Item_idindex : integer; CK_id : string; NILMItemTemp : TNILMItem; begin try JSONObject := TJSONObject.ParseJSONValue(S) as TJSONObject; try // fMain.grdMQTTMonitor.BeginUpdate; if JSONObject.GetValue('slave_data') <> nil then begin JSONArray := JSONObject.GetValue('slave_data'); for i := 0 to JSONArray.Count - 1 do begin ItemObj := JSONArray.Items[i] as TJSONObject; Item_NewCk := True; Item_UpdateCk := True; Item_idindex := -1; CK_id := ItemObj.GetValue('id').ToString; if BaseList_NILMItem.Count > 0 then begin for x := 0 to (BaseList_NILMItem.Count-1) do begin if Trim(BaseList_NILMItem[x].id) = trim(CK_id) then begin Item_idindex := x; Item_NewCk := False; if BaseList_NILMItem[x].seq = ItemObj.GetValue('seq').ToString then begin // BaseList_NILMItem[x].lastupdatetime := FormatDateTime('yyyymmdd hhnnss',now); Item_UpdateCk := False; end else Begin BaseList_NILMItem[x].seq := ItemObj.GetValue('seq').ToString; BaseList_NILMItem[x].PreSeq := ItemObj.GetValue('PreSeq').ToString; BaseList_NILMItem[x].COMM := ItemObj.GetValue('COMM').ToString; BaseList_NILMItem[x].pfA := FormatFloat('0.0', ItemObj.GetValue('pfA')); BaseList_NILMItem[x].voltageA := FormatFloat('0.0', ItemObj.GetValue('voltageA')); BaseList_NILMItem[x].currentA := FormatFloat('0.0', ItemObj.GetValue('currentA')); BaseList_NILMItem[x].Var_A := FormatFloat('0.0', ItemObj.GetValue('Var_A')); BaseList_NILMItem[x].Va_A := FormatFloat('0.0', ItemObj.GetValue('Va_A')); BaseList_NILMItem[x].pfB := FormatFloat('0.0', ItemObj.GetValue('pfB')); BaseList_NILMItem[x].voltageB := FormatFloat('0.0', ItemObj.GetValue('voltageB')); BaseList_NILMItem[x].currentB := FormatFloat('0.0', ItemObj.GetValue('currentB')); BaseList_NILMItem[x].Var_B := FormatFloat('0.0', ItemObj.GetValue('Var_B')); BaseList_NILMItem[x].Va_B := FormatFloat('0.0', ItemObj.GetValue('Va_B')); BaseList_NILMItem[x].lastupdatetime := FormatDateTime('yyyymmdd hhnnss',now); BaseList_NILMItem[x].nilmstatus := '?????'; // BaseList_NILMItem[x].mqttTopic := ''; Item_UpdateCk := True; End; Break; end; end; end; if Item_NewCk then begin NILMItemTemp := TNILMItem.Create; NILMItemTemp.id := ItemObj.GetValue('id').ToString; NILMItemTemp.seq := ItemObj.GetValue('seq').ToString; NILMItemTemp.PreSeq := ItemObj.GetValue('PreSeq').ToString; NILMItemTemp.COMM := ItemObj.GetValue('COMM').ToString; NILMItemTemp.pfA := FormatFloat('0.0', ItemObj.GetValue('pfA')); NILMItemTemp.voltageA := FormatFloat('0.0', ItemObj.GetValue('voltageA')); NILMItemTemp.currentA := FormatFloat('0.0', ItemObj.GetValue('currentA')); NILMItemTemp.Var_A := FormatFloat('0.0', ItemObj.GetValue('Var_A')); NILMItemTemp.Va_A := FormatFloat('0.0', ItemObj.GetValue('Va_A')); NILMItemTemp.pfB := FormatFloat('0.0', ItemObj.GetValue('pfB')); NILMItemTemp.voltageB := FormatFloat('0.0', ItemObj.GetValue('voltageB')); NILMItemTemp.currentB := FormatFloat('0.0', ItemObj.GetValue('currentB')); NILMItemTemp.Var_B := FormatFloat('0.0', ItemObj.GetValue('Var_B')); NILMItemTemp.Va_B := FormatFloat('0.0', ItemObj.GetValue('Va_B')); NILMItemTemp.lastupdatetime := FormatDateTime('yyyymmdd hhnnss',now); NILMItemTemp.nilmstatus := '?????'; NILMItemTemp.delaycount := ''; NILMItemTemp.statusCheck_off := ''; NILMItemTemp.statusCheck_wait := ''; NILMItemTemp.statusCheck_drive := ''; // NILMItem.mqttTopic := ''; BaseList_NILMItem.Add(NILMItemTemp); end; // row 찾기 row_ck := 0; for var r := 1 to fMain.grdMQTTMonitor.RowCount - 1 do begin if fMain.grdMQTTMonitor.Cells[0, r] = ItemObj.GetValue('id').ToString then begin row_ck := r; Break; end; end; // 없으면 신규 row 추가 if row_ck = 0 then begin if Trim(fMain.grdMQTTMonitor.Cells[ 0, 1])='' then begin row_ck := 1; end else begin row_ck := fMain.grdMQTTMonitor.RowCount; fMain.grdMQTTMonitor.RowCount := fMain.grdMQTTMonitor.RowCount + 1; end; fMain.grdMQTTMonitor.Cells[ 0, row_ck] := ItemObj.GetValue('id').ToString; end; if Item_UpdateCk then begin fMain.grdMQTTMonitor.Cells[ 1, row_ck] := FormatDateTime('yyyymmdd hhnnss',now); fMain.grdMQTTMonitor.Cells[ 2, row_ck] := ItemObj.GetValue('seq').ToString; fMain.grdMQTTMonitor.Cells[ 3, row_ck] := ItemObj.GetValue('PreSeq').ToString; // fMain.grdMQTTMonitor.Cells[ 4, row_ck] := ItemObj.GetValue('COMM').ToString; fMain.grdMQTTMonitor.Cells[ 4, row_ck] := '???'; fMain.grdMQTTMonitor.Cells[ 5, row_ck] := FormatFloat('0.0', ItemObj.GetValue('pfA')); fMain.grdMQTTMonitor.Cells[ 6, row_ck] := FormatFloat('0.0', ItemObj.GetValue('voltageA')); fMain.grdMQTTMonitor.Cells[ 7, row_ck] := FormatFloat('0.0', ItemObj.GetValue('currentA')); fMain.grdMQTTMonitor.Cells[ 8, row_ck] := FormatFloat('0.0', ItemObj.GetValue('Var_A')); fMain.grdMQTTMonitor.Cells[ 9, row_ck] := FormatFloat('0.0', ItemObj.GetValue('Va_A')); fMain.grdMQTTMonitor.Cells[10, row_ck] := FormatFloat('0.0', ItemObj.GetValue('pfB')); fMain.grdMQTTMonitor.Cells[11, row_ck] := FormatFloat('0.0', ItemObj.GetValue('voltageB')); fMain.grdMQTTMonitor.Cells[12, row_ck] := FormatFloat('0.0', ItemObj.GetValue('currentB')); fMain.grdMQTTMonitor.Cells[13, row_ck] := FormatFloat('0.0', ItemObj.GetValue('Var_B')); fMain.grdMQTTMonitor.Cells[14, row_ck] := FormatFloat('0.0', ItemObj.GetValue('Va_B')); end; end; end; finally // fMain.grdMQTTMonitor.EndUpdate; JSONObject.Free; end; except on e : Exception do begin fMain.Log(fMain.reMQTTLog,'error',e.Message); end; end; end; } procedure TfMain.OnMQTTMessage(const ATopic, APayload: string); begin TThread.ForceQueue(nil, procedure begin ProcessIncomingMQTTMessage(ATopic, APayload); end); end; procedure TfMain.ProcessIncomingMQTTMessage(const ATopic, APayload: string); begin if ATopic.EndsWith('/gateway') then ProcessGatewayMessage(ATopic, APayload) else if ATopic.EndsWith('/status') then ProcessNodeStatusMessage(ATopic, APayload) else if ATopic.EndsWith('/event') then ProcessEventMessage(ATopic, APayload) else NILM_SaveMQTTData(ATopic, APayload); end; procedure TfMain.ProcessGatewayMessage(const ATopic, APayload: string); var Root: TJSONObject; GW: TNILMGatewayPayload; begin GW.Clear; Root := TJSONObject.ParseJSONValue(APayload) as TJSONObject; if Root = nil then Exit; try var jvGW := Root.GetValue('gw'); if Assigned(jvGW) and (not (jvGW is TJSONNull)) then GW.gw := jvGW.Value; var jvOnline := Root.GetValue('online'); if Assigned(jvOnline) and (not (jvOnline is TJSONNull)) then GW.online := SameText(jvOnline.Value, 'true'); var jvTot := Root.GetValue('nodes_total'); if Assigned(jvTot) and (not (jvTot is TJSONNull)) then GW.nodes_total := StrToIntDef(jvTot.Value, 0); var jvOnl := Root.GetValue('nodes_online'); if Assigned(jvOnl) and (not (jvOnl is TJSONNull)) then GW.nodes_online := StrToIntDef(jvOnl.Value, 0); var jvCyc := Root.GetValue('cycle_ms'); if Assigned(jvCyc) and (not (jvCyc is TJSONNull)) then GW.cycle_ms := StrToIntDef(jvCyc.Value, 0); var jvUpt := Root.GetValue('uptime_s'); if Assigned(jvUpt) and (not (jvUpt is TJSONNull)) then GW.uptime_s := StrToInt64Def(jvUpt.Value, 0); if Assigned(FNILMManager) then FNILMManager.SaveGatewayStatus(GW); Log(reMQTTLog, 'data', Format('[Gateway] %s (온라인: %s, 노드: %d/%d, 주기: %dms, 가동시간: %ds)', [GW.gw, BoolToStr(GW.online, True), GW.nodes_online, GW.nodes_total, GW.cycle_ms, GW.uptime_s])); finally Root.Free; end; end; procedure TfMain.ProcessNodeStatusMessage(const ATopic, APayload: string); var TopicParts: TArray; DeviceID: Integer; Root: TJSONObject; Status: TNILMNodeStatusPayload; begin TopicParts := ATopic.Split(['/']); if Length(TopicParts) < 2 then Exit; DeviceID := StrToIntDef(TopicParts[High(TopicParts) - 1], 0); if DeviceID <= 0 then Exit; Status.Clear; Root := TJSONObject.ParseJSONValue(APayload) as TJSONObject; if Root = nil then Exit; try var jvGW := Root.GetValue('gw'); if Assigned(jvGW) and (not (jvGW is TJSONNull)) then Status.gw := jvGW.Value; var jvDev := Root.GetValue('dev'); if Assigned(jvDev) and (not (jvDev is TJSONNull)) then Status.dev := jvDev.Value; var jvOnline := Root.GetValue('online'); if Assigned(jvOnline) and (not (jvOnline is TJSONNull)) then Status.online := SameText(jvOnline.Value, 'true'); var jvAge := Root.GetValue('last_seen_age_s'); if Assigned(jvAge) and (not (jvAge is TJSONNull)) then Status.last_seen_age_s := StrToIntDef(jvAge.Value, 0); var jvLoss := Root.GetValue('loss_rate'); if Assigned(jvLoss) and (not (jvLoss is TJSONNull)) then Status.loss_rate := StrToFloatDef(jvLoss.Value, 0.0); var jvPolls := Root.GetValue('polls'); if Assigned(jvPolls) and (not (jvPolls is TJSONNull)) then Status.polls := StrToIntDef(jvPolls.Value, 0); var jvResps := Root.GetValue('responses'); if Assigned(jvResps) and (not (jvResps is TJSONNull)) then Status.responses := StrToIntDef(jvResps.Value, 0); if Assigned(FNILMManager) then FNILMManager.SaveNodeStatus(DeviceID, Status); Log(reMQTTLog, 'data', Format('[노드상태] 장비 %d [%s]: %s (손실률: %.2f%%, 지연: %ds)', [DeviceID, Status.dev, IfThen(Status.online, 'ONLINE', 'OFFLINE'), Status.loss_rate * 100, Status.last_seen_age_s])); finally Root.Free; end; end; procedure TfMain.ProcessEventMessage(const ATopic, APayload: string); var TopicParts: TArray; DeviceID: Integer; Root: TJSONObject; Evt: TNILMEventPayload; begin TopicParts := ATopic.Split(['/']); if Length(TopicParts) < 2 then Exit; DeviceID := StrToIntDef(TopicParts[High(TopicParts) - 1], 0); Evt.Clear; Root := TJSONObject.ParseJSONValue(APayload) as TJSONObject; if Root = nil then Exit; try var jvGW := Root.GetValue('gw'); if Assigned(jvGW) and (not (jvGW is TJSONNull)) then Evt.gw := jvGW.Value; var jvDev := Root.GetValue('dev'); if Assigned(jvDev) and (not (jvDev is TJSONNull)) then Evt.dev := jvDev.Value; var jvAge := Root.GetValue('age_ms'); if Assigned(jvAge) and (not (jvAge is TJSONNull)) then Evt.age_ms := StrToIntDef(jvAge.Value, 0); var jvCode := Root.GetValue('code'); if Assigned(jvCode) and (not (jvCode is TJSONNull)) then Evt.code := StrToIntDef(jvCode.Value, 0); var jvName := Root.GetValue('name'); if Assigned(jvName) and (not (jvName is TJSONNull)) then Evt.name := jvName.Value; var jvSeq := Root.GetValue('seq'); if Assigned(jvSeq) and (not (jvSeq is TJSONNull)) then Evt.seq := StrToIntDef(jvSeq.Value, 0); var jvVal := Root.GetValue('value'); if Assigned(jvVal) and (not (jvVal is TJSONNull)) then Evt.value := StrToFloatDef(jvVal.Value, 0.0); if Assigned(FNILMManager) then FNILMManager.SaveNILMEvent(DeviceID, Evt); Log(reMainLog, 'alarm', Format('[ALARM] 장비 %d (%s) 이벤트: %s (코드:%d, 값:%.2f, 지연:%dms)', [DeviceID, Evt.dev, Evt.name, Evt.code, Evt.value, Evt.age_ms])); finally Root.Free; end; end; procedure TfMain.Log(Target: TRichEdit; LogType: string; Msg: string); begin if cbLogSave.Checked then AddLog_Thread(Msg); if cbLogView.Checked then begin if ((LogType = 'data') and (Target=reMainLog) and (not cbDataLogVIew.Checked)) or ((LogType = 'mqttmsg') and (Target=reMainLog) and (not cbDataLogVIew.Checked)) then exit; if (LogType = 'data') and (Target=reMqttlog) and (not cbMqttLogVIew.Checked) then exit; if (LogType = 'data') and (Target=reDblog) and (not cbDbLogVIew.Checked) then exit; if (LogType = 'data') and (Target=reOPClog) and (not cbOPCLogVIew.Checked) then exit; TThread.ForceQueue(nil, procedure begin if not Assigned(Target) then Exit; // 로그 라인 제한 (100줄) if Target.Lines.Count > StrToIntDef(etdMaxRow.Text, 100) then Target.Clear; if LogType = 'process' then Target.SelAttributes.Color := clLime else if LogType = 'complete' then Target.SelAttributes.Color := $0064B1FF else if LogType = 'error' then Target.SelAttributes.Color := clRed else if LogType = 'data' then Target.SelAttributes.Color := clSkyBlue else if LogType = 'mqttmsg' then Target.SelAttributes.Color := clSkyBlue else if LogType = 'api' then Target.SelAttributes.Color := clYellow else if LogType = 'alarm' then Target.SelAttributes.Color := clYellow else Target.SelAttributes.Color := clWhite; if (not cbFullLengthSet.Checked) and (Length(Msg) > StrToIntDef(etdMaxLength.Text, 100)) then begin Target.Lines.Add(Format('[%s] %s', [FormatDateTime('mm-dd HH:mm:ss', Now), Copy(Msg, 1, StrToIntDef(etdMaxLength.Text, 100)) + '......'])); end else begin Target.Lines.Add(Format('[%s] %s', [FormatDateTime('mm-dd HH:mm:ss', Now), Msg])); end; Target.Perform(EM_SCROLL, SB_BOTTOM, 0); end); end; end; // ══════════════════════════════════════════════════════════════════════════ // NILM 설정 탭 구현 // ══════════════════════════════════════════════════════════════════════════ procedure TfMain.NILM_ClearDeviceList; var D: TNILMDevice; begin if Assigned(FDeviceList) then begin for D in FDeviceList do if Assigned(D.SensorList) then D.SensorList.Free; FreeAndNil(FDeviceList); end; end; procedure TfMain.NILM_InitGrid; begin if not Assigned(grdNILMDevice) then Exit; grdNILMDevice.ColCount := 11; grdNILMDevice.RowCount := 2; grdNILMDevice.FixedRows := 1; grdNILMDevice.FixedCols := 0; grdNILMDevice.Options := grdNILMDevice.Options + [goRowSelect, goColSizing]; grdNILMDevice.DefaultRowHeight := 24; grdNILMDevice.ColWidths[0] := 60; // 장비번호 grdNILMDevice.ColWidths[1] := 100; // 장비명 grdNILMDevice.ColWidths[2] := 120; // 위치 grdNILMDevice.ColWidths[3] := 60; // 상수 grdNILMDevice.ColWidths[4] := 60; // 대상상 grdNILMDevice.ColWidths[5] := 100; // 꺼짐기준전류 grdNILMDevice.ColWidths[6] := 100; // 가동기준전류 grdNILMDevice.ColWidths[7] := 100; // 가동기준PF grdNILMDevice.ColWidths[8] := 50; // 활성 grdNILMDevice.ColWidths[9] := 250; // MQTT 토픽 grdNILMDevice.ColWidths[10] := 100; // 등록일 grdNILMDevice.Cells[0, 0] := '장비번호'; grdNILMDevice.Cells[1, 0] := '장비명칭'; grdNILMDevice.Cells[2, 0] := '설치위치'; grdNILMDevice.Cells[3, 0] := '상수'; grdNILMDevice.Cells[4, 0] := '대상 상'; grdNILMDevice.Cells[5, 0] := '꺼짐기준전류(A)'; grdNILMDevice.Cells[6, 0] := '가동기준전류(A)'; grdNILMDevice.Cells[7, 0] := '가동기준PF'; grdNILMDevice.Cells[8, 0] := '활성'; grdNILMDevice.Cells[9, 0] := 'MQTT 토픽'; grdNILMDevice.Cells[10, 0] := '등록일시'; end; procedure TfMain.NILM_LoadGrid; var D : TNILMDevice; Row : Integer; begin if not Assigned(grdNILMDevice) then Exit; if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; NILM_ClearDeviceList; try FDeviceList := FNILMManager.LoadDevices; except on E: Exception do begin Log(reDBLog, 'error', '[NILM] 장비 목록 로드 오류: ' + E.Message); FDeviceList := TList.Create; end; end; grdNILMDevice.RowCount := Max(2, FDeviceList.Count + 1); Row := 1; for D in FDeviceList do begin grdNILMDevice.Cells[0, Row] := IntToStr(D.DeviceID); grdNILMDevice.Cells[1, Row] := D.DeviceName; grdNILMDevice.Cells[2, Row] := D.Location; grdNILMDevice.Cells[3, Row] := D.PhaseType; grdNILMDevice.Cells[4, Row] := D.OpTargetPhase; grdNILMDevice.Cells[5, Row] := Format('%.2f', [D.OpThresholdOffCurrent]); grdNILMDevice.Cells[6, Row] := Format('%.2f', [D.OpThresholdRunCurrent]); grdNILMDevice.Cells[7, Row] := Format('%.2f', [D.OpThresholdPF]); grdNILMDevice.Cells[8, Row] := IfThen(D.IsActive, '✔', '─'); grdNILMDevice.Cells[9, Row] := D.MqttTopic; grdNILMDevice.Cells[10, Row] := FormatDateTime('yy-mm-dd hh:nn', D.CreatedAt); Inc(Row); end; Log(reDBLog, 'process', Format('[NILM] 장비 %d 건 로드 완료', [FDeviceList.Count])); // 콤보박스 동기화 (히스토리/이벤트 탭) if Assigned(cmbHistDevice) then Hist_LoadDeviceCombo(cmbHistDevice); if Assigned(cmbEvtDevice) then Hist_LoadDeviceCombo(cmbEvtDevice); if Assigned(cmbTrendDevice) then Hist_LoadDeviceCombo(cmbTrendDevice, False); end; function TfMain.NILM_SelectedDeviceID: Integer; var Row: Integer; begin Result := -1; if not Assigned(grdNILMDevice) then Exit; Row := grdNILMDevice.Row; if Row < 1 then Exit; Result := StrToIntDef(grdNILMDevice.Cells[0, Row], -1); end; // ══════════════════════════════════════════════════════════════════════════ // 히스토리 탭 구현 // ══════════════════════════════════════════════════════════════════════════ procedure TfMain.Hist_LoadDeviceCombo(ACombo: TComboBox; TotalUse:Boolean = True); var Q: TFDQuery; begin ACombo.Items.Clear; if TotalUse then begin ACombo.Items.Add('(전체)'); ACombo.ItemIndex := 0; end; if not Assigned(FNILMManager) then FNILMManager := TNILMManager.Create; try Q := FNILMManager.LoadDevicesSimple; try while not Q.EOF do begin ACombo.Items.Add( Format('[%d] %s', [Q.Fields[0].AsInteger, Q.Fields[1].AsString])); Q.Next; end; if not TotalUse then begin if ACombo.Items.Count > 0 then ACombo.ItemIndex := 0; end; finally Q.Free; end; except // DB 미연결 시 무시 end; end; procedure TfMain.Hist_InitGrid; const COLS: array[0..31] of string = ( '수신시각','장비번호','SEQ','이전SEQ','COMM', '결선','RSSI','Total_W','Total_PF', 'pf_a','vrms_a','irms_a','w_a','var_a','va_a', 'pf_b','vrms_b','irms_b','w_b','var_b','va_b', 'pf_c','vrms_c','irms_c','w_c','var_c','va_c', 'wh','내부온도','외부온도','진동','기울기'); var i: Integer; begin if not Assigned(grdHistory) then Exit; grdHistory.ColCount := Length(COLS); grdHistory.RowCount := 2; grdHistory.FixedRows := 1; grdHistory.FixedCols := 0; grdHistory.Options := grdHistory.Options + [goRowSelect, goColSizing]; grdHistory.DefaultRowHeight := 22; for i := 0 to High(COLS) do begin grdHistory.ColWidths[i] := IfThen(i = 0, 140, IfThen(i < 5, 60, 62)); grdHistory.Cells[i, 0] := COLS[i]; end; end; procedure TfMain.Hist_InitEventGrid; const COLS: array[0..4] of string = ('발생시각','유형','장비번호','메시지','상세내용'); var i: Integer; begin if not Assigned(grdEventLog) then Exit; grdEventLog.ColCount := Length(COLS); grdEventLog.RowCount := 2; grdEventLog.FixedRows := 1; grdEventLog.FixedCols := 0; grdEventLog.Options := grdEventLog.Options + [goRowSelect, goColSizing]; grdEventLog.DefaultRowHeight := 22; grdEventLog.ColWidths[0] := 140; grdEventLog.ColWidths[1] := 90; grdEventLog.ColWidths[2] := 70; grdEventLog.ColWidths[3] := 300; grdEventLog.ColWidths[4] := 300; for i := 0 to High(COLS) do grdEventLog.Cells[i, 0] := COLS[i]; end; procedure TfMain.Hist_ExportGridToCSV(AGrid: TStringGrid; const ATitle: string); var Dlg : TSaveDialog; F : TextFile; Row, Col: Integer; Line : string; begin Dlg := TSaveDialog.Create(nil); try Dlg.Title := ATitle + ' CSV 내보내기'; Dlg.Filter := 'CSV 파일 (*.csv)|*.csv'; Dlg.DefaultExt := 'csv'; Dlg.FileName := ATitle + '_' + FormatDateTime('yyyymmdd_hhnn', Now) + '.csv'; if not Dlg.Execute then Exit; AssignFile(F, Dlg.FileName); Rewrite(F); try for Row := 0 to AGrid.RowCount - 1 do begin Line := ''; for Col := 0 to AGrid.ColCount - 1 do begin if Col > 0 then Line := Line + ','; Line := Line + '"' + StringReplace(AGrid.Cells[Col, Row], '"', '""', [rfReplaceAll]) + '"'; end; Writeln(F, Line); end; finally CloseFile(F); end; MessageDlg('CSV 내보내기 완료: ' + Dlg.FileName, mtInformation, [mbOK], 0); finally Dlg.Free; end; end; // ══════════════════════════════════════════════════════════════════════════ // MQTT → DB 자동 저장 & OPC 쓰기 // ══════════════════════════════════════════════════════════════════════════ function TfMain.WriteOPCValueIfChanged(const ATagName: string; const AValue: string): Integer; var PrevVal: string; Item: OLEVariant; targetVT: Integer; ItemValues: OLEVariant; inputText: string; begin Result := 0; // Default: 변경없음 if FPreviousOPCValues.TryGetValue(ATagName, PrevVal) then begin if PrevVal = AValue then Exit; // Value hasn't changed end; // Attempt to write to OPC if not bConnect or VarIsEmpty(OPCGroup) or VarIsNull(OPCGroup) then Exit(2); if not FDynamicOPCItems.TryGetValue(ATagName, Item) or VarIsNull(Item) or VarIsEmpty(Item) then begin try Item := OPCGroup.OPCItems.AddItem(ATagName, FDynamicOPCItems.Count + 1000); try Item.IsActive := True; except end; FDynamicOPCItems.AddOrSetValue(ATagName, Item); except on E: Exception do begin Log(reOPCLog, 'error', Format('[OPC 태그등록 실패] %s (값: %s) - %s', [ATagName, AValue, E.Message])); Exit(-1); // Error end; end; end; if VarIsNull(Item) or VarIsEmpty(Item) then Exit(2); inputText := Trim(AValue); targetVT := varEmpty; try targetVT := Item.CanonicalDataType; except targetVT := varEmpty; end; if (targetVT = 0) or (targetVT = varEmpty) then begin if SameText(ExtractFileName(StringReplace(ATagName, '/', '\', [rfReplaceAll])), 'COMM') or SameText(ExtractFileName(StringReplace(ATagName, '/', '\', [rfReplaceAll])), 'OpStatus') or SameText(ExtractFileName(StringReplace(ATagName, '/', '\', [rfReplaceAll])), 'wire') or SameText(ExtractFileName(StringReplace(ATagName, '/', '\', [rfReplaceAll])), 'rssi') then targetVT := varSmallInt else targetVT := varSingle; end; ItemValues := VarArrayCreate([0, 0], varVariant); case targetVT of varSmallInt, varInteger, varByte, varWord, varShortInt: ItemValues[0] := Integer(StrToIntDef(inputText, 0)); varLongWord: ItemValues[0] := Integer(StrToInt64Def(inputText, 0)); varInt64: ItemValues[0] := StrToInt64Def(inputText, 0); varSingle, varDouble: ItemValues[0] := Double(StrToFloatDef(inputText, 0.0)); varBoolean: ItemValues[0] := OleVariant(Boolean((inputText <> '0') and (inputText <> ''))); varOleStr: ItemValues[0] := OleVariant(inputText); else ItemValues[0] := OleVariant(inputText); end; try Item.Write(ItemValues[0]); FPreviousOPCValues.AddOrSetValue(ATagName, AValue); Result := 1; // Success except on E: Exception do begin Result := -1; // Error Log(reOPCLog, 'error', Format('[OPC 쓰기 실패] %s = %s (타입: %d) - %s', [ATagName, AValue, targetVT, E.Message])); end; end; end; procedure TfMain.ClearFailedOPCTags; var Key: string; KeysToRemove: TList; Item: OLEVariant; begin KeysToRemove := TList.Create; try for Key in FDynamicOPCItems.Keys do begin if FDynamicOPCItems.TryGetValue(Key, Item) then begin if VarIsNull(Item) or VarIsEmpty(Item) then KeysToRemove.Add(Key); end; end; for Key in KeysToRemove do FDynamicOPCItems.Remove(Key); finally KeysToRemove.Free; end; end; procedure TfMain.NILM_SaveMQTTData(const ATopic, APayload: string); var TopicParts : TArray; DeviceID : Integer; Root, Slave, TotObj, ChObj: TJSONObject; SlaveArr, ChArr: TJSONArray; Seq, Comm, Rssi: Integer; TargetDevice: TNILMDevice; deviceFound: Boolean; DevSensors: TNILMSensorConfigList; i, k: Integer; opStatus: string; currVal, pfVal: Double; MeasurePayload: TNILMMeasurementPayload; successCount, failCount, skipCount, DupCount: Integer; res: Integer; GridDataObj: TJSONObject; function GetValNum(const AObj: TJSONObject; const AKey: string; ADefault: Double = 0.0): Double; var JV: TJSONValue; begin Result := ADefault; if AObj = nil then Exit; JV := AObj.GetValue(AKey); if Assigned(JV) and (not (JV is TJSONNull)) then Result := StrToFloatDef(JV.Value, ADefault); end; function GetValInt(const AObj: TJSONObject; const AKey: string; ADefault: Integer = 0): Integer; var JV: TJSONValue; begin Result := ADefault; if AObj = nil then Exit; JV := AObj.GetValue(AKey); if Assigned(JV) and (not (JV is TJSONNull)) then Result := StrToIntDef(JV.Value, ADefault); end; function GetValInt64(const AObj: TJSONObject; const AKey: string; ADefault: Int64 = 0): Int64; var JV: TJSONValue; begin Result := ADefault; if AObj = nil then Exit; JV := AObj.GetValue(AKey); if Assigned(JV) and (not (JV is TJSONNull)) then Result := StrToInt64Def(JV.Value, ADefault); end; function GetValStr(const AObj: TJSONObject; const AKey, ADefault: string): string; var JV: TJSONValue; begin Result := ADefault; if AObj = nil then Exit; JV := AObj.GetValue(AKey); if Assigned(JV) and (not (JV is TJSONNull)) then Result := JV.Value; end; procedure SendOPC(const ATagSuffix, AValStr: string); begin var TagName := IntToStr(DeviceID) + '/' + ATagSuffix; res := WriteOPCValueIfChanged('SYSTEM.' + TagName, AValStr); if res = 1 then Inc(successCount) else if res = 2 then Inc(skipCount) else if res = -1 then Inc(failCount) else Inc(DupCount); end; begin if not Assigned(FNILMManager) then Exit; try Root := TJSONObject.ParseJSONValue(APayload) as TJSONObject; if Root = nil then Exit; try // 1순위: JSON 페이로드의 dev 필드에서 번호 추출 (예: "NILM_032" -> 32, "NILM_002" -> 2) DeviceID := 0; var devStr := GetValStr(Root, 'dev', ''); if devStr <> '' then begin var numStr := ''; for var ch in devStr do if ch in ['0'..'9'] then numStr := numStr + ch; DeviceID := StrToIntDef(numStr, 0); end; // 2순위: 토픽의 마지막 세그먼트에서 추출 if DeviceID <= 0 then begin TopicParts := ATopic.Split(['/']); if Length(TopicParts) > 0 then DeviceID := StrToIntDef(TopicParts[High(TopicParts)], 0); end; if DeviceID <= 0 then Exit; // 수신 시각 기록 및 타임아웃 처리 플래그 리셋 if Assigned(FLastRecvTime) then FLastRecvTime.AddOrSetValue(DeviceID, Now); if Assigned(FDeviceTimeoutHandled) then FDeviceTimeoutHandled.AddOrSetValue(DeviceID, False); MeasurePayload.Clear; // 1. 신규 규격 vs 레거시 포맷 파싱 if Root.GetValue('slave_data') <> nil then begin // 레거시 slave_data 구조 SlaveArr := Root.GetValue('slave_data'); if (SlaveArr = nil) or (SlaveArr.Count = 0) then Exit; Slave := SlaveArr.Items[0] as TJSONObject; MeasurePayload.seq := GetValInt(Slave, 'seq', 0); MeasurePayload.COMM := GetValInt(Slave, 'COMM', 0); MeasurePayload.wire := GetValStr(Slave, 'wire', PHASE_3P4W); MeasurePayload.rssi := GetValInt(Slave, 'rssi', 0); MeasurePayload.freq := GetValNum(Slave, 'frequency', 60.0); MeasurePayload.wh := GetValInt64(Slave, 'wh', 0); // A상 MeasurePayload.ch[0].IsValid := True; MeasurePayload.ch[0].pf := GetValNum(Slave, 'power_factor_a', GetValNum(Slave, 'pfA', 0)); MeasurePayload.ch[0].v := GetValNum(Slave, 'vrms_a', GetValNum(Slave, 'voltageA', 0)); MeasurePayload.ch[0].i := GetValNum(Slave, 'irms_a', GetValNum(Slave, 'currentA', 0)); MeasurePayload.ch[0].p := GetValNum(Slave, 'active_power_a', GetValNum(Slave, 'wA', 0)); MeasurePayload.ch[0].q := GetValNum(Slave, 'reactive_power_a', GetValNum(Slave, 'Var_A', 0)); MeasurePayload.ch[0].s := GetValNum(Slave, 'apparent_power_a', GetValNum(Slave, 'Va_A', 0)); // B상 if MeasurePayload.wire <> PHASE_3P3W then begin MeasurePayload.ch[1].IsValid := True; MeasurePayload.ch[1].pf := GetValNum(Slave, 'power_factor_b', GetValNum(Slave, 'pfB', 0)); MeasurePayload.ch[1].v := GetValNum(Slave, 'vrms_b', GetValNum(Slave, 'voltageB', 0)); MeasurePayload.ch[1].i := GetValNum(Slave, 'irms_b', GetValNum(Slave, 'currentB', 0)); MeasurePayload.ch[1].p := GetValNum(Slave, 'active_power_b', GetValNum(Slave, 'wB', 0)); MeasurePayload.ch[1].q := GetValNum(Slave, 'reactive_power_b', GetValNum(Slave, 'Var_B', 0)); MeasurePayload.ch[1].s := GetValNum(Slave, 'apparent_power_b', GetValNum(Slave, 'Va_B', 0)); end; // C상 MeasurePayload.ch[2].IsValid := True; MeasurePayload.ch[2].pf := GetValNum(Slave, 'power_factor_c', GetValNum(Slave, 'pfC', 0)); MeasurePayload.ch[2].v := GetValNum(Slave, 'vrms_c', GetValNum(Slave, 'voltageC', 0)); MeasurePayload.ch[2].i := GetValNum(Slave, 'irms_c', GetValNum(Slave, 'currentC', 0)); MeasurePayload.ch[2].p := GetValNum(Slave, 'active_power_c', GetValNum(Slave, 'wC', 0)); MeasurePayload.ch[2].q := GetValNum(Slave, 'reactive_power_c', GetValNum(Slave, 'Var_C', 0)); MeasurePayload.ch[2].s := GetValNum(Slave, 'apparent_power_c', GetValNum(Slave, 'Va_C', 0)); end else begin // 신규 표준 규격 구조 MeasurePayload.gw := GetValStr(Root, 'gw', ''); MeasurePayload.dev := GetValStr(Root, 'dev', ''); MeasurePayload.seq := GetValInt(Root, 'seq', 0); MeasurePayload.COMM := GetValInt(Root, 'COMM', 0); MeasurePayload.rssi := GetValInt(Root, 'rssi', 0); MeasurePayload.wire := GetValStr(Root, 'wire', PHASE_3P4W); MeasurePayload.freq := GetValNum(Root, 'freq', 60.0); MeasurePayload.wh := GetValInt64(Root, 'wh', 0); ChArr := Root.GetValue('ch'); if Assigned(ChArr) then begin for k := 0 to Min(2, ChArr.Count - 1) do begin if (ChArr.Items[k] <> nil) and (not (ChArr.Items[k] is TJSONNull)) and (ChArr.Items[k] is TJSONObject) then begin ChObj := ChArr.Items[k] as TJSONObject; MeasurePayload.ch[k].IsValid := True; MeasurePayload.ch[k].v := GetValNum(ChObj, 'v', 0.0); MeasurePayload.ch[k].i := GetValNum(ChObj, 'i', 0.0); MeasurePayload.ch[k].p := GetValNum(ChObj, 'p', 0.0); MeasurePayload.ch[k].q := GetValNum(ChObj, 'q', 0.0); MeasurePayload.ch[k].s := GetValNum(ChObj, 's', 0.0); MeasurePayload.ch[k].pf := GetValNum(ChObj, 'pf', 0.0); end; end; end; var jvTotal := Root.GetValue('total'); if Assigned(jvTotal) and (not (jvTotal is TJSONNull)) and (jvTotal is TJSONObject) then begin TotObj := jvTotal as TJSONObject; MeasurePayload.total.p := GetValNum(TotObj, 'p', 0.0); MeasurePayload.total.q := GetValNum(TotObj, 'q', 0.0); MeasurePayload.total.s := GetValNum(TotObj, 's', 0.0); MeasurePayload.total.pf := GetValNum(TotObj, 'pf', 0.0); end; end; deviceFound := False; if Assigned(FDeviceList) then begin for i := 0 to FDeviceList.Count - 1 do if FDeviceList[i].DeviceID = DeviceID then begin TargetDevice := FDeviceList[i]; deviceFound := True; Break; end; end; // wire가 null이거나 비어있을 경우 장비 설정에서 Fallback if (MeasurePayload.wire = '') or SameText(MeasurePayload.wire, 'null') then begin if deviceFound and (TargetDevice.PhaseType <> '') then MeasurePayload.wire := TargetDevice.PhaseType else MeasurePayload.wire := PHASE_3P4W; end; // ─── [가상 전력 계산] 전압 미연결 / 전류 전용(1-CT) 패킷 처리 ─── var hasRealVoltage: Boolean := False; var hasRealPower: Boolean := False; var hasCurrent: Boolean := False; for k := 0 to 2 do begin if MeasurePayload.ch[k].v > 0 then hasRealVoltage := True; if MeasurePayload.ch[k].p > 0 then hasRealPower := True; if MeasurePayload.ch[k].i > 0 then hasCurrent := True; end; if (MeasurePayload.total.p > 0) then hasRealPower := True; var bDoEstimate: Boolean := False; if deviceFound then bDoEstimate := TargetDevice.IsEstimatePower else bDoEstimate := True; if bDoEstimate and hasCurrent and (not hasRealPower) and (not hasRealVoltage) then begin var NomV: Double := 220.0; var AssPF: Double := 0.90; var EstWire: string := 'SINGLE'; if deviceFound then begin if TargetDevice.NominalVoltage > 0 then NomV := TargetDevice.NominalVoltage; if TargetDevice.AssumedPF > 0 then AssPF := TargetDevice.AssumedPF; if TargetDevice.EstimateWiring <> '' then EstWire := TargetDevice.EstimateWiring; end; var sumP: Double := 0.0; var sumQ: Double := 0.0; var sumS: Double := 0.0; var maxI: Double := 0.0; for k := 0 to 2 do begin if MeasurePayload.ch[k].i > 0 then begin MeasurePayload.ch[k].v := NomV; MeasurePayload.ch[k].pf := AssPF; MeasurePayload.ch[k].s := NomV * MeasurePayload.ch[k].i; MeasurePayload.ch[k].p := MeasurePayload.ch[k].s * AssPF; MeasurePayload.ch[k].q := Sqrt(Max(Sqr(MeasurePayload.ch[k].s) - Sqr(MeasurePayload.ch[k].p), 0.0)); sumP := sumP + MeasurePayload.ch[k].p; sumQ := sumQ + MeasurePayload.ch[k].q; sumS := sumS + MeasurePayload.ch[k].s; if MeasurePayload.ch[k].i > maxI then maxI := MeasurePayload.ch[k].i; end; end; if SameText(EstWire, '3PHASE_BALANCED') and (maxI > 0) then begin // 3상 평형 부하 추정: P = √3 * V_line * I * PF MeasurePayload.total.s := Sqrt(3.0) * NomV * maxI; MeasurePayload.total.p := MeasurePayload.total.s * AssPF; MeasurePayload.total.q := Sqrt(Max(Sqr(MeasurePayload.total.s) - Sqr(MeasurePayload.total.p), 0.0)); MeasurePayload.total.pf := AssPF; end else begin // 단상 또는 다채널 합산 MeasurePayload.total.p := sumP; MeasurePayload.total.q := sumQ; MeasurePayload.total.s := sumS; MeasurePayload.total.pf := AssPF; end; end; // 3P3W / 3P4W 전체 전력 계산 보정 (기존 정상 다채널 계측용) if (MeasurePayload.total.p = 0) and (MeasurePayload.ch[0].p > 0) then begin if SameText(MeasurePayload.wire, PHASE_3P3W) then begin MeasurePayload.total.p := MeasurePayload.ch[0].p + MeasurePayload.ch[2].p; MeasurePayload.total.q := MeasurePayload.ch[0].q + MeasurePayload.ch[2].q; end else begin MeasurePayload.total.p := MeasurePayload.ch[0].p + MeasurePayload.ch[1].p + MeasurePayload.ch[2].p; MeasurePayload.total.q := MeasurePayload.ch[0].q + MeasurePayload.ch[1].q + MeasurePayload.ch[2].q; end; MeasurePayload.total.s := Sqrt(Sqr(MeasurePayload.total.p) + Sqr(MeasurePayload.total.q)); if MeasurePayload.total.s > 0 then MeasurePayload.total.pf := MeasurePayload.total.p / MeasurePayload.total.s; end; DevSensors := nil; if deviceFound then DevSensors := TargetDevice.SensorList; // 가동 상태(opStatus) 판정 opStatus := '0'; // 전원꺼짐 (0) if deviceFound then begin currVal := 0.0; pfVal := 0.0; if SameText(TargetDevice.OpTargetPhase, 'L1') then begin currVal := MeasurePayload.ch[0].i; pfVal := MeasurePayload.ch[0].pf; end else if SameText(TargetDevice.OpTargetPhase, 'L2') and (not SameText(MeasurePayload.wire, PHASE_3P3W)) then begin currVal := MeasurePayload.ch[1].i; pfVal := MeasurePayload.ch[1].pf; end else if SameText(TargetDevice.OpTargetPhase, 'L3') then begin currVal := MeasurePayload.ch[2].i; pfVal := MeasurePayload.ch[2].pf; end else // 'TOTAL' or 'AVG' begin if SameText(MeasurePayload.wire, PHASE_3P3W) then begin currVal := (MeasurePayload.ch[0].i + MeasurePayload.ch[2].i) / 2.0; pfVal := MeasurePayload.total.pf; if pfVal = 0 then pfVal := (MeasurePayload.ch[0].pf + MeasurePayload.ch[2].pf) / 2.0; end else begin currVal := (MeasurePayload.ch[0].i + MeasurePayload.ch[1].i + MeasurePayload.ch[2].i) / 3.0; pfVal := MeasurePayload.total.pf; if pfVal = 0 then pfVal := (MeasurePayload.ch[0].pf + MeasurePayload.ch[1].pf + MeasurePayload.ch[2].pf) / 3.0; end; end; if (currVal >= TargetDevice.OpThresholdRunCurrent) and (pfVal >= TargetDevice.OpThresholdPF) then opStatus := '2' // 가동 else if (currVal > TargetDevice.OpThresholdOffCurrent) then opStatus := '1' // 미가동 else opStatus := '0'; // 전원꺼짐 end; // 그리드 표시용 JSON 객체 구성 GridDataObj := TJSONObject.Create; try GridDataObj.AddPair('wire', MeasurePayload.wire); GridDataObj.AddPair('rssi', TJSONNumber.Create(MeasurePayload.rssi)); GridDataObj.AddPair('frequency', TJSONNumber.Create(MeasurePayload.freq)); GridDataObj.AddPair('wh', TJSONNumber.Create(MeasurePayload.wh)); GridDataObj.AddPair('total_p', TJSONNumber.Create(MeasurePayload.total.p)); GridDataObj.AddPair('total_q', TJSONNumber.Create(MeasurePayload.total.q)); GridDataObj.AddPair('total_s', TJSONNumber.Create(MeasurePayload.total.s)); GridDataObj.AddPair('total_pf', TJSONNumber.Create(MeasurePayload.total.pf)); GridDataObj.AddPair('power_factor_a', TJSONNumber.Create(MeasurePayload.ch[0].pf)); GridDataObj.AddPair('vrms_a', TJSONNumber.Create(MeasurePayload.ch[0].v)); GridDataObj.AddPair('irms_a', TJSONNumber.Create(MeasurePayload.ch[0].i)); GridDataObj.AddPair('active_power_a', TJSONNumber.Create(MeasurePayload.ch[0].p)); GridDataObj.AddPair('reactive_power_a', TJSONNumber.Create(MeasurePayload.ch[0].q)); GridDataObj.AddPair('apparent_power_a', TJSONNumber.Create(MeasurePayload.ch[0].s)); GridDataObj.AddPair('power_factor_b', TJSONNumber.Create(MeasurePayload.ch[1].pf)); GridDataObj.AddPair('vrms_b', TJSONNumber.Create(MeasurePayload.ch[1].v)); GridDataObj.AddPair('irms_b', TJSONNumber.Create(MeasurePayload.ch[1].i)); GridDataObj.AddPair('active_power_b', TJSONNumber.Create(MeasurePayload.ch[1].p)); GridDataObj.AddPair('reactive_power_b', TJSONNumber.Create(MeasurePayload.ch[1].q)); GridDataObj.AddPair('apparent_power_b', TJSONNumber.Create(MeasurePayload.ch[1].s)); GridDataObj.AddPair('power_factor_c', TJSONNumber.Create(MeasurePayload.ch[2].pf)); GridDataObj.AddPair('vrms_c', TJSONNumber.Create(MeasurePayload.ch[2].v)); GridDataObj.AddPair('irms_c', TJSONNumber.Create(MeasurePayload.ch[2].i)); GridDataObj.AddPair('active_power_c', TJSONNumber.Create(MeasurePayload.ch[2].p)); GridDataObj.AddPair('reactive_power_c', TJSONNumber.Create(MeasurePayload.ch[2].q)); GridDataObj.AddPair('apparent_power_c', TJSONNumber.Create(MeasurePayload.ch[2].s)); // 1. 그리드를 갱신하면서 seq 변경 여부 확인 var bSeqChanged: Boolean := gridmonitorRefresh_main( DeviceID, MeasurePayload.seq, 0, MeasurePayload.COMM, APayload, opStatus, GridDataObj, DevSensors); var devName: string := ''; if deviceFound then devName := TargetDevice.DeviceName; // 2. DB에 저장 (chkAutoSaveDB 체크 시에만 실행) if Assigned(chkAutoSaveDB) and chkAutoSaveDB.Checked then begin try FNILMManager.SaveMeasurementData( DeviceID, devName, MeasurePayload, APayload, opStatus, DevSensors, bSeqChanged); except on E: Exception do Log(reDBLog, 'error', '[DB저장 오류] ' + E.Message); end; end; // 당일 누적 전력량(Wh) 실시간 적분 계산 (자정 외부 DB 전송용) var nowTime := Now; var lastTime: TDateTime := 0; if FLastEnergyUpdate.TryGetValue(DeviceID, lastTime) and (lastTime > 0) then begin var deltaSec: Double := (nowTime - lastTime) * 86400.0; if (deltaSec > 0) and (deltaSec < 60.0) then begin var currentDayWh: Double := 0; FDayEnergyValue.TryGetValue(DeviceID, currentDayWh); currentDayWh := currentDayWh + (MeasurePayload.total.p * deltaSec / 3600.0); FDayEnergyValue.AddOrSetValue(DeviceID, currentDayWh); end; end; FLastEnergyUpdate.AddOrSetValue(DeviceID, nowTime); // 3. seq가 변경된 경우에만 OPC 서버에 쓰기 수행 if bSeqChanged then begin Log(reMainLog, 'mqttmsg', ATopic + ' ' + APayload); successCount := 0; failCount := 0; skipCount := 0; DupCount := 0; // 공통/메타 SendOPC('COMM', IntToStr(MeasurePayload.COMM)); SendOPC('OpStatus', opStatus); SendOPC('wire', IfThen(SameText(MeasurePayload.wire, PHASE_3P3W), '0', '1')); SendOPC('rssi', IntToStr(MeasurePayload.rssi)); SendOPC('freq', FloatToStr(MeasurePayload.freq)); SendOPC('wh', IntToStr(MeasurePayload.wh)); // Total SendOPC('total_p', FloatToStr(MeasurePayload.total.p)); SendOPC('total_q', FloatToStr(MeasurePayload.total.q)); SendOPC('total_s', FloatToStr(MeasurePayload.total.s)); SendOPC('total_pf', FloatToStr(MeasurePayload.total.pf)); // A상 SendOPC('power_factor_a', FloatToStr(MeasurePayload.ch[0].pf)); SendOPC('vrms_a', FloatToStr(MeasurePayload.ch[0].v)); SendOPC('irms_a', FloatToStr(MeasurePayload.ch[0].i)); SendOPC('active_power_a', FloatToStr(MeasurePayload.ch[0].p)); SendOPC('reactive_power_a', FloatToStr(MeasurePayload.ch[0].q)); SendOPC('apparent_power_a', FloatToStr(MeasurePayload.ch[0].s)); // B상 if SameText(MeasurePayload.wire, PHASE_3P3W) then begin SendOPC('power_factor_b', '0'); SendOPC('vrms_b', '0'); SendOPC('irms_b', '0'); SendOPC('active_power_b', '0'); SendOPC('reactive_power_b', '0'); SendOPC('apparent_power_b', '0'); end else begin SendOPC('power_factor_b', FloatToStr(MeasurePayload.ch[1].pf)); SendOPC('vrms_b', FloatToStr(MeasurePayload.ch[1].v)); SendOPC('irms_b', FloatToStr(MeasurePayload.ch[1].i)); SendOPC('active_power_b', FloatToStr(MeasurePayload.ch[1].p)); SendOPC('reactive_power_b', FloatToStr(MeasurePayload.ch[1].q)); SendOPC('apparent_power_b', FloatToStr(MeasurePayload.ch[1].s)); end; // C상 SendOPC('power_factor_c', FloatToStr(MeasurePayload.ch[2].pf)); SendOPC('vrms_c', FloatToStr(MeasurePayload.ch[2].v)); SendOPC('irms_c', FloatToStr(MeasurePayload.ch[2].i)); SendOPC('active_power_c', FloatToStr(MeasurePayload.ch[2].p)); SendOPC('reactive_power_c', FloatToStr(MeasurePayload.ch[2].q)); SendOPC('apparent_power_c', FloatToStr(MeasurePayload.ch[2].s)); if failCount > 0 then Log(reOPCLog, 'error', Format('[OPC 쓰기] %s - 성공: %d, 에러: %d, 무시: %d, 변경없음: %d', [ATopic, successCount, failCount, skipCount, DupCount])) else if (successCount > 0) or (failCount = 0) then Log(reOPCLog, 'process', Format('[OPC 쓰기] %s - 성공: %d, 에러: 0, 무시: %d, 변경없음: %d', [ATopic, successCount, skipCount, DupCount])); end; finally GridDataObj.Free; end; finally Root.Free; end; except on E: Exception do Log(reMainLog, 'error', '[NILM-SAVE] ' + E.Message); end; end; procedure TfMain.NILMErrorHandler(const AMsg: string); begin Log(reDBLog, 'error', AMsg); end; end.