SungjuNewPrime/NILM_Agent/AgentNILM_MQTTClient(Delphi13 VCL)/uMain.pas
2026-09-04 11:15:47 +09:00

3532 lines
128 KiB
ObjectPascal

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<TNILMDevice>;
FHistoryQuery : TFDQuery; // 조회 결과 캐시 유지
FEventLogQuery : TFDQuery;
FPreviousOPCValues: TDictionary<string, string>;
FDynamicOPCItems: TDictionary<string, OLEVariant>;
FLastOPCRetryTime: TDateTime;
FLastEnergyUpdate: TDictionary<Integer, TDateTime>;
FDayEnergyValue: TDictionary<Integer, Double>;
FLastDailyEnergySyncDate: TDate;
FLastRecvTime: TDictionary<Integer, TDateTime>;
FDeviceTimeoutHandled: TDictionary<Integer, Boolean>;
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<TNILMItem>;
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<string>;
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<string>.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<TNILMDevice>;
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<TNILMItem>.Create;
BaseList_NILMItem := TObjectList<TNILMItem>.Create(True);
NILMItem := TNILMItem.Create;
FPreviousOPCValues := TDictionary<string, string>.Create;
FDynamicOPCItems := TDictionary<string, OLEVariant>.Create;
FLastEnergyUpdate := TDictionary<Integer, TDateTime>.Create;
FDayEnergyValue := TDictionary<Integer, Double>.Create;
FLastDailyEnergySyncDate := Date;
FLastRecvTime := TDictionary<Integer, TDateTime>.Create;
FDeviceTimeoutHandled := TDictionary<Integer, Boolean>.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<TJSONArray>('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<Integer>('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<Integer>('seq').ToString then begin
// BaseList_NILMItem[x].lastupdatetime := FormatDateTime('yyyymmdd hhnnss',now);
Item_UpdateCk := False;
end
else Begin
BaseList_NILMItem[x].seq := ItemObj.GetValue<Integer>('seq').ToString;
BaseList_NILMItem[x].PreSeq := ItemObj.GetValue<Integer>('PreSeq').ToString;
BaseList_NILMItem[x].COMM := ItemObj.GetValue<Integer>('COMM').ToString;
BaseList_NILMItem[x].pfA := FormatFloat('0.0', ItemObj.GetValue<Double>('pfA'));
BaseList_NILMItem[x].voltageA := FormatFloat('0.0', ItemObj.GetValue<Double>('voltageA'));
BaseList_NILMItem[x].currentA := FormatFloat('0.0', ItemObj.GetValue<Double>('currentA'));
BaseList_NILMItem[x].Var_A := FormatFloat('0.0', ItemObj.GetValue<Double>('Var_A'));
BaseList_NILMItem[x].Va_A := FormatFloat('0.0', ItemObj.GetValue<Double>('Va_A'));
BaseList_NILMItem[x].pfB := FormatFloat('0.0', ItemObj.GetValue<Double>('pfB'));
BaseList_NILMItem[x].voltageB := FormatFloat('0.0', ItemObj.GetValue<Double>('voltageB'));
BaseList_NILMItem[x].currentB := FormatFloat('0.0', ItemObj.GetValue<Double>('currentB'));
BaseList_NILMItem[x].Var_B := FormatFloat('0.0', ItemObj.GetValue<Double>('Var_B'));
BaseList_NILMItem[x].Va_B := FormatFloat('0.0', ItemObj.GetValue<Double>('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<Integer>('id').ToString;
NILMItemTemp.seq := ItemObj.GetValue<Integer>('seq').ToString;
NILMItemTemp.PreSeq := ItemObj.GetValue<Integer>('PreSeq').ToString;
NILMItemTemp.COMM := ItemObj.GetValue<Integer>('COMM').ToString;
NILMItemTemp.pfA := FormatFloat('0.0', ItemObj.GetValue<Double>('pfA'));
NILMItemTemp.voltageA := FormatFloat('0.0', ItemObj.GetValue<Double>('voltageA'));
NILMItemTemp.currentA := FormatFloat('0.0', ItemObj.GetValue<Double>('currentA'));
NILMItemTemp.Var_A := FormatFloat('0.0', ItemObj.GetValue<Double>('Var_A'));
NILMItemTemp.Va_A := FormatFloat('0.0', ItemObj.GetValue<Double>('Va_A'));
NILMItemTemp.pfB := FormatFloat('0.0', ItemObj.GetValue<Double>('pfB'));
NILMItemTemp.voltageB := FormatFloat('0.0', ItemObj.GetValue<Double>('voltageB'));
NILMItemTemp.currentB := FormatFloat('0.0', ItemObj.GetValue<Double>('currentB'));
NILMItemTemp.Var_B := FormatFloat('0.0', ItemObj.GetValue<Double>('Var_B'));
NILMItemTemp.Va_B := FormatFloat('0.0', ItemObj.GetValue<Double>('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<Integer>('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<Integer>('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<Integer>('seq').ToString;
fMain.grdMQTTMonitor.Cells[ 3, row_ck] := ItemObj.GetValue<Integer>('PreSeq').ToString;
// fMain.grdMQTTMonitor.Cells[ 4, row_ck] := ItemObj.GetValue<Integer>('COMM').ToString;
fMain.grdMQTTMonitor.Cells[ 4, row_ck] := '???';
fMain.grdMQTTMonitor.Cells[ 5, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('pfA'));
fMain.grdMQTTMonitor.Cells[ 6, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('voltageA'));
fMain.grdMQTTMonitor.Cells[ 7, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('currentA'));
fMain.grdMQTTMonitor.Cells[ 8, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('Var_A'));
fMain.grdMQTTMonitor.Cells[ 9, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('Va_A'));
fMain.grdMQTTMonitor.Cells[10, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('pfB'));
fMain.grdMQTTMonitor.Cells[11, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('voltageB'));
fMain.grdMQTTMonitor.Cells[12, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('currentB'));
fMain.grdMQTTMonitor.Cells[13, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('Var_B'));
fMain.grdMQTTMonitor.Cells[14, row_ck] := FormatFloat('0.0', ItemObj.GetValue<Double>('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<string>;
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<string>;
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<TNILMDevice>.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<string>;
Item: OLEVariant;
begin
KeysToRemove := TList<string>.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<string>;
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<TJSONArray>('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<TJSONArray>('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.