Plancia configurabile su Modbus RTU

Due programmi che condividono uModbusRTU e uGauge:

- Console/PlanciaConsole: plancia nautica descritta da file XML, con
  modalita' plancia e modalita' configurazione. Comandi, spie, selettori,
  strumenti e allarmi sonori; il bus gira in un thread suo perche' la
  finestra non si fermi mai.
- ProjectPlancia: il programma di prova piu' vecchio, usato per collaudare
  i canali.

Le plance sono in Console/*.xml, la documentazione in Console/LEGGIMI.md.
Esclusi dal versionamento i compilati (.exe, .dcu), i file dell'IDE e un
audio da 17 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
f.bittiandClaude Opus 5 committed 2026-09-22 16:56:34 +02:00
commit 4a01a2ec88
69 files changed
+11658

No files matched your search

+631
View File
@@ -0,0 +1,631 @@
unit uMain;
interface
uses
Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants,
System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs,
Vcl.StdCtrls, Vcl.ExtCtrls, Vcl.ComCtrls, Vcl.Buttons,
System.Generics.Collections, System.Win.Registry, System.StrUtils,
uModbusRTU, uGauge;
type
TMainForm = class(TForm)
pnlTop: TPanel;
lblPort: TLabel;
cboPort: TComboBox;
lblBaud: TLabel;
cboBaud: TComboBox;
btnConnect: TButton;
lblStatus: TLabel;
lblSlaveRele: TLabel;
edtSlaveRele: TEdit;
lblSlaveIO: TLabel;
edtSlaveIO: TEdit;
lblSlaveAnalog: TLabel;
edtSlaveAnalog: TEdit;
pgcMain: TPageControl;
tsInterruttori: TTabSheet;
tsPulsanti: TTabSheet;
tsInputs: TTabSheet;
tsAnalog: TTabSheet;
pnlInterruttori: TPanel;
pnlPulsanti: TPanel;
pnlInputs: TPanel;
pnlAnalog: TPanel;
mmoLog: TMemo;
tmrPoll: TTimer;
CBReadInput: TCheckBox;
CBReadAnalog: TCheckBox;
procedure FormCreate(Sender: TObject);
procedure FormDestroy(Sender: TObject);
procedure btnConnectClick(Sender: TObject);
procedure tmrPollTimer(Sender: TObject);
private
FModbus: TModbusRTU;
FSwitchButtons: array[0..15] of TSpeedButton;
FPulseButtons: array[0..15] of TSpeedButton;
FInputShapes: array[0..7] of TShape;
FInputLabels: array[0..7] of TLabel;
FAnalogLabels: array[0..3] of TLabel;
FAnalogGauges: array[0..3] of TTankGauge;
/// Ultimo stato mostrato per ogni ingresso, per registrare a log solo le
/// transizioni: un contatto che si chiude e' un evento, non un livello.
FInputState: array[0..7] of Boolean;
FInputValid: array[0..7] of Boolean;
/// Ultimo errore gia' registrato, per non riempire il log con lo stesso
/// messaggio due volte al secondo finche' il bus resta muto.
FInputErr: string;
FAnalogErr: string;
procedure Log(const AMsg: string);
procedure SetInputVisual(AIndex: Integer; AValid, AClosed: Boolean);
procedure InvalidateInputs;
procedure BuildSwitchControls;
procedure BuildPulseControls;
procedure BuildInputControls;
procedure BuildAnalogControls;
procedure SwitchButtonClick(Sender: TObject);
procedure PulseButtonMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PulseButtonMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure SetPulseState(ABtn: TSpeedButton; AOn: Boolean);
procedure UpdateButtonVisual(ABtn: TSpeedButton; AOn: Boolean);
procedure SetConnectedState(AConnected: Boolean);
public
end;
var
MainForm: TMainForm;
implementation
{$R *.dfm}
type
/// Scalatura di un canale analogico: dal valore grezzo del registro Modbus
/// al valore ingegneristico mostrato dal gauge.
TAnalogCfg = record
Title: string;
RawMin: Integer;
RawMax: Integer;
EngMin: Double;
EngMax: Double;
Units: string;
WarnBelow: Double;
WarnAbove: Double;
end;
const
GRID_COLS = 4;
CTRL_W = 90;
CTRL_H = 30;
MARGIN = 12;
HDR_H = 24;
GAUGE_W = 130;
GAUGE_H = 200;
// Passo fra una spia d'ingresso e l'altra: deve stare larga la didascalia
// su due righe, altrimenti le scritte si accavallano.
INPUT_SPACING = 84;
// Configurazione dei 4 canali analogici. RawMin/RawMax vanno allineati al
// fondo scala del modulo (4095 = ADC a 12 bit); EngMin/EngMax sono i valori
// reali corrispondenti. WarnBelow/WarnAbove colorano di rosso la colonna.
ANALOG_CFG: array[0..3] of TAnalogCfg = (
(Title: 'Temperatura'; RawMin: 0; RawMax: 4095;
EngMin: 0; EngMax: 120; Units: '°C';
WarnBelow: GAUGE_NO_WARN_LO; WarnAbove: 100),
(Title: 'Livello serbatoio'; RawMin: 0; RawMax: 4095;
EngMin: 0; EngMax: 100; Units: '%';
WarnBelow: 15; WarnAbove: GAUGE_NO_WARN_HI),
(Title: 'Canale 2'; RawMin: 0; RawMax: 4095;
EngMin: 0; EngMax: 100; Units: '%';
WarnBelow: GAUGE_NO_WARN_LO; WarnAbove: GAUGE_NO_WARN_HI),
(Title: 'Canale 3'; RawMin: 0; RawMax: 4095;
EngMin: 0; EngMax: 100; Units: '%';
WarnBelow: GAUGE_NO_WARN_LO; WarnAbove: GAUGE_NO_WARN_HI)
);
// Etichette dei pulsanti momentanei: stringa vuota = 'Canale N'.
// Es.: impostare PULSE_NAMES[0] := 'Horn' per il canale 0.
PULSE_NAMES: array[0..15] of string = (
'', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', ''
);
/// Le porte realmente presenti, lette dal registro di Windows. Elencarle a
/// mano significa non vedere l'adattatore USB quando si presenta con un numero
/// diverso da quelli previsti.
procedure EnumSerialPorts(AList: TStrings);
var
Reg: TRegistry;
Names: TStringList;
Name: string;
begin
AList.Clear;
Reg := TRegistry.Create(KEY_READ);
Names := TStringList.Create;
try
Reg.RootKey := HKEY_LOCAL_MACHINE;
if Reg.OpenKeyReadOnly('HARDWARE\DEVICEMAP\SERIALCOMM') then
begin
Reg.GetValueNames(Names);
for Name in Names do
AList.Add(Reg.ReadString(Name));
Reg.CloseKey;
end;
finally
Names.Free;
Reg.Free;
end;
end;
procedure TMainForm.FormCreate(Sender: TObject);
var
I: Integer;
begin
EnumSerialPorts(cboPort.Items);
if cboPort.Items.Count = 0 then
// Nessuna porta trovata: meglio una lista di ripiego che una casella vuota.
for I := 1 to 10 do
cboPort.Items.Add('COM' + IntToStr(I));
I := cboPort.Items.IndexOf('COM7');
if I < 0 then
I := 0;
cboPort.ItemIndex := I;
cboBaud.Items.CommaText := '9600,19200,38400,57600,115200';
cboBaud.ItemIndex := 0;
edtSlaveRele.Text := '1';
edtSlaveIO.Text := '2';
edtSlaveAnalog.Text := '5';
BuildSwitchControls;
BuildPulseControls;
BuildInputControls;
BuildAnalogControls;
SetConnectedState(False);
end;
procedure TMainForm.FormDestroy(Sender: TObject);
begin
tmrPoll.Enabled := False;
if Assigned(FModbus) then
begin
FModbus.Disconnect;
FModbus.Free;
end;
end;
procedure TMainForm.Log(const AMsg: string);
begin
mmoLog.Lines.Add(FormatDateTime('hh:nn:ss', Now) + ' ' + AMsg);
end;
function ChannelCaption(AIndex: Integer): string;
begin
if PULSE_NAMES[AIndex] <> '' then
Result := PULSE_NAMES[AIndex]
else
Result := 'Canale ' + IntToStr(AIndex);
end;
procedure TMainForm.BuildSwitchControls;
var
I, Row, Col: Integer;
Hdr: TLabel;
Btn: TSpeedButton;
begin
Hdr := TLabel.Create(Self);
Hdr.Parent := pnlInterruttori;
Hdr.Caption := 'Il canale resta chiuso finché il pulsante è premuto giù.';
Hdr.Left := MARGIN;
Hdr.Top := MARGIN;
for I := 0 to 15 do
begin
Row := I div GRID_COLS;
Col := I mod GRID_COLS;
Btn := TSpeedButton.Create(Self);
Btn.Parent := pnlInterruttori;
Btn.Caption := 'Canale ' + IntToStr(I);
Btn.Left := MARGIN + Col * (CTRL_W + MARGIN);
Btn.Top := HDR_H + MARGIN + Row * (CTRL_H + 6);
Btn.Width := CTRL_W;
Btn.Height := CTRL_H;
Btn.Tag := I;
// GroupIndex univoco + AllowAllUp: il pulsante resta premuto e si sgancia
// al click successivo, senza influenzare gli altri canali.
Btn.GroupIndex := I + 1;
Btn.AllowAllUp := True;
Btn.Down := False;
Btn.OnClick := SwitchButtonClick;
FSwitchButtons[I] := Btn;
UpdateButtonVisual(Btn, False);
end;
end;
procedure TMainForm.BuildPulseControls;
var
I, Row, Col: Integer;
Hdr: TLabel;
Btn: TSpeedButton;
begin
Hdr := TLabel.Create(Self);
Hdr.Parent := pnlPulsanti;
Hdr.Caption := 'Il canale resta chiuso solo mentre il pulsante è premuto ' +
'(contatto momentaneo, es. horn).';
Hdr.Left := MARGIN;
Hdr.Top := MARGIN;
for I := 0 to 15 do
begin
Row := I div GRID_COLS;
Col := I mod GRID_COLS;
Btn := TSpeedButton.Create(Self);
Btn.Parent := pnlPulsanti;
Btn.Caption := ChannelCaption(I);
Btn.Left := MARGIN + Col * (CTRL_W + MARGIN);
Btn.Top := HDR_H + MARGIN + Row * (CTRL_H + 6);
Btn.Width := CTRL_W;
Btn.Height := CTRL_H;
Btn.Tag := I;
// Nessun GroupIndex: il pulsante non resta giù. Il comando è legato a
// MouseDown/MouseUp, non a OnClick, per chiudere e riaprire il contatto.
Btn.OnMouseDown := PulseButtonMouseDown;
Btn.OnMouseUp := PulseButtonMouseUp;
FPulseButtons[I] := Btn;
UpdateButtonVisual(Btn, False);
end;
end;
procedure TMainForm.UpdateButtonVisual(ABtn: TSpeedButton; AOn: Boolean);
begin
ABtn.ParentFont := False;
if AOn then
begin
ABtn.Font.Style := [fsBold];
ABtn.Font.Color := clGreen;
end
else
begin
ABtn.Font.Style := [];
ABtn.Font.Color := clWindowText;
end;
end;
procedure TMainForm.BuildInputControls;
var
I: Integer;
Shp: TShape;
Lbl: TLabel;
Hdr: TLabel;
begin
Hdr := TLabel.Create(Self);
Hdr.Parent := pnlInputs;
Hdr.Caption := 'Contatto chiuso = spia verde. Grigio = aperto. ' +
'Bordo rosso = dato non disponibile, non fidarsi di quello che mostra.';
Hdr.Left := MARGIN;
Hdr.Top := MARGIN;
for I := 0 to 7 do
begin
Shp := TShape.Create(Self);
Shp.Parent := pnlInputs;
Shp.Shape := stCircle;
Shp.Width := 24;
Shp.Height := 24;
Shp.Left := MARGIN + I * INPUT_SPACING;
Shp.Top := HDR_H + MARGIN;
FInputShapes[I] := Shp;
// La serigrafia del modulo parte da DI1, i canali Modbus da 0: tenere
// tutti e due sotto gli occhi evita di sbagliare morsetto in barca.
Lbl := TLabel.Create(Self);
Lbl.Parent := pnlInputs;
Lbl.Left := Shp.Left;
Lbl.Top := Shp.Top + 30;
FInputLabels[I] := Lbl;
FInputValid[I] := False;
FInputState[I] := False;
SetInputVisual(I, False, False);
end;
end;
/// Distingue tre casi, non due: chiuso, aperto, e "non lo so". Un bus caduto
/// non deve somigliare a un contatto aperto.
procedure TMainForm.SetInputVisual(AIndex: Integer; AValid, AClosed: Boolean);
var
Shp: TShape;
begin
Shp := FInputShapes[AIndex];
if not AValid then
begin
Shp.Brush.Color := clSilver;
Shp.Pen.Color := clRed;
Shp.Pen.Width := 2;
FInputLabels[AIndex].Caption := Format('DI%d (ch%d)'#13'n/d', [AIndex + 1, AIndex]);
end
else if AClosed then
begin
Shp.Brush.Color := clLime;
Shp.Pen.Color := clGreen;
Shp.Pen.Width := 1;
FInputLabels[AIndex].Caption := Format('DI%d (ch%d)'#13'CHIUSO', [AIndex + 1, AIndex]);
end
else
begin
Shp.Brush.Color := clGray;
Shp.Pen.Color := clBlack;
Shp.Pen.Width := 1;
FInputLabels[AIndex].Caption := Format('DI%d (ch%d)'#13'aperto', [AIndex + 1, AIndex]);
end;
end;
procedure TMainForm.InvalidateInputs;
var
I: Integer;
begin
for I := 0 to 7 do
begin
FInputValid[I] := False;
SetInputVisual(I, False, False);
end;
end;
function RawToEng(AIndex: Integer; ARaw: Word): Double;
var
Cfg: TAnalogCfg;
begin
Cfg := ANALOG_CFG[AIndex];
if Cfg.RawMax = Cfg.RawMin then
Exit(Cfg.EngMin);
Result := Cfg.EngMin +
(Integer(ARaw) - Cfg.RawMin) * (Cfg.EngMax - Cfg.EngMin) /
(Cfg.RawMax - Cfg.RawMin);
end;
procedure TMainForm.BuildAnalogControls;
var
I: Integer;
Lbl: TLabel;
Gauge: TTankGauge;
begin
// Il gauge è un TGraphicControl: senza doppio buffering la colonna
// sfarfalla ad ogni ciclo di polling.
pnlAnalog.DoubleBuffered := True;
for I := 0 to 3 do
begin
Gauge := TTankGauge.Create(Self);
Gauge.Parent := pnlAnalog;
Gauge.Left := MARGIN + I * (GAUGE_W + MARGIN);
Gauge.Top := MARGIN;
Gauge.Width := GAUGE_W;
Gauge.Height := GAUGE_H;
Gauge.Title := ANALOG_CFG[I].Title;
Gauge.Units := ANALOG_CFG[I].Units;
Gauge.SetRange(ANALOG_CFG[I].EngMin, ANALOG_CFG[I].EngMax);
Gauge.SetWarnings(ANALOG_CFG[I].WarnBelow, ANALOG_CFG[I].WarnAbove);
FAnalogGauges[I] := Gauge;
// Sotto al gauge resta il valore grezzo letto dal registro.
Lbl := TLabel.Create(Self);
Lbl.Parent := pnlAnalog;
Lbl.Caption := 'Registro: --';
Lbl.Left := Gauge.Left;
Lbl.Top := Gauge.Top + GAUGE_H + 4;
FAnalogLabels[I] := Lbl;
end;
end;
procedure TMainForm.SetConnectedState(AConnected: Boolean);
var
I: Integer;
begin
if not AConnected then
begin
// Senza polling i valori a video sarebbero fermi all'ultima lettura.
for I := 0 to 3 do
begin
FAnalogGauges[I].Valid := False;
FAnalogLabels[I].Caption := 'Registro: --';
end;
InvalidateInputs;
end;
if AConnected then
begin
btnConnect.Caption := 'Disconnetti';
lblStatus.Caption := 'Stato: Connesso';
lblStatus.Font.Color := clGreen;
end
else
begin
btnConnect.Caption := 'Connetti';
lblStatus.Caption := 'Stato: Disconnesso';
lblStatus.Font.Color := clRed;
end;
cboPort.Enabled := not AConnected;
cboBaud.Enabled := not AConnected;
tmrPoll.Enabled := AConnected;
end;
procedure TMainForm.btnConnectClick(Sender: TObject);
begin
if Assigned(FModbus) and FModbus.IsConnected then
begin
FModbus.Disconnect;
FreeAndNil(FModbus);
SetConnectedState(False);
Log('Disconnesso.');
Exit;
end;
try
FModbus := TModbusRTU.Create(cboPort.Text, StrToInt(cboBaud.Text), 500);
FModbus.Connect;
SetConnectedState(True);
Log('Connesso a ' + cboPort.Text + ' @ ' + cboBaud.Text + ' baud.');
except
on E: Exception do
begin
Log('ERRORE connessione: ' + E.Message);
FreeAndNil(FModbus);
SetConnectedState(False);
end;
end;
end;
procedure TMainForm.SwitchButtonClick(Sender: TObject);
var
Btn: TSpeedButton;
Idx: Integer;
SlaveAddr: Integer;
begin
Btn := TSpeedButton(Sender);
UpdateButtonVisual(Btn, Btn.Down);
if not (Assigned(FModbus) and FModbus.IsConnected) then
begin
Log('Impossibile comandare il relè: non connesso.');
//Btn.Down := not Btn.Down;
//UpdateButtonVisual(Btn, Btn.Down);
Exit;
end;
Idx := Btn.Tag;
SlaveAddr := StrToIntDef(edtSlaveRele.Text, 1);
try
FModbus.WriteSingleCoil(SlaveAddr, Idx, Btn.Down);
Log(Format('Interruttore canale %d -> %s', [Idx, BoolToStr(Btn.Down, True)]));
except
on E: Exception do
begin
Log('ERRORE scrittura relè: ' + E.Message);
Btn.Down := not Btn.Down;
UpdateButtonVisual(Btn, Btn.Down);
end;
end;
end;
procedure TMainForm.PulseButtonMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if Button = mbLeft then
SetPulseState(TSpeedButton(Sender), True);
end;
procedure TMainForm.PulseButtonMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
// Il mouse è catturato dal pulsante: il MouseUp arriva anche se il cursore
// è stato trascinato fuori, quindi il contatto viene sempre riaperto.
if Button = mbLeft then
SetPulseState(TSpeedButton(Sender), False);
end;
procedure TMainForm.SetPulseState(ABtn: TSpeedButton; AOn: Boolean);
var
Idx: Integer;
SlaveAddr: Integer;
begin
UpdateButtonVisual(ABtn, AOn);
if not (Assigned(FModbus) and FModbus.IsConnected) then
begin
if AOn then
Log('Impossibile comandare il relè: non connesso.');
Exit;
end;
Idx := ABtn.Tag;
SlaveAddr := StrToIntDef(edtSlaveRele.Text, 1);
try
FModbus.WriteSingleCoil(SlaveAddr, Idx, AOn);
Log(Format('Pulsante %s -> %s', [ChannelCaption(Idx), BoolToStr(AOn, True)]));
except
on E: Exception do
// Nessun rollback: se fallisce la chiusura si tenta comunque l'apertura
// al rilascio, così il canale non resta eccitato per un errore.
Log('ERRORE scrittura relè: ' + E.Message);
end;
end;
procedure TMainForm.tmrPollTimer(Sender: TObject);
var
Inputs: TArray<Boolean>;
Analog: TArray<Word>;
I: Integer;
SlaveIO, SlaveAnalog: Integer;
begin
if not (Assigned(FModbus) and FModbus.IsConnected) then Exit;
SlaveIO := StrToIntDef(edtSlaveIO.Text, 2);
SlaveAnalog := StrToIntDef(edtSlaveAnalog.Text, 5);
if CBReadInput.Checked then
begin
try
Inputs := FModbus.ReadDiscreteInputs(SlaveIO, 0, 8);
for I := 0 to 7 do
begin
// Il log registra le transizioni, non i livelli: su una barca interessa
// sapere *quando* un contatto si e' chiuso, non che e' chiuso da un'ora.
if (not FInputValid[I]) or (Inputs[I] <> FInputState[I]) then
Log(Format('DI%d (ch%d) -> %s', [I + 1, I,
IfThen(Inputs[I], 'CHIUSO', 'aperto')]));
FInputState[I] := Inputs[I];
FInputValid[I] := True;
SetInputVisual(I, True, Inputs[I]);
end;
FInputErr := '';
except
on E: Exception do
begin
// Le spie vanno spente a "non so": lasciarle com'erano farebbe passare
// un bus caduto per un quadro tutto a posto.
if E.Message <> FInputErr then
begin
Log(Format('ERRORE lettura ingressi slave %d: %s', [SlaveIO, E.Message]));
FInputErr := E.Message;
end;
InvalidateInputs;
end;
end;
end
else
InvalidateInputs;
if CBReadAnalog.Checked then
begin
try
Analog := FModbus.ReadHoldingRegisters(SlaveAnalog, 0, 4);
for I := 0 to 3 do
begin
FAnalogLabels[I].Caption := 'Registro: ' + IntToStr(Analog[I]);
FAnalogGauges[I].Value := RawToEng(I, Analog[I]);
end;
FAnalogErr := '';
except
on E: Exception do
begin
if E.Message <> FAnalogErr then
begin
Log(Format('ERRORE lettura analogici slave %d: %s', [SlaveAnalog, E.Message]));
FAnalogErr := E.Message;
end;
for I := 0 to 3 do
begin
FAnalogGauges[I].Valid := False;
FAnalogLabels[I].Caption := 'Registro: --';
end;
end;
end;
end;
end;
end.