Files
Plancia/Console/uConsoleMain.pas
T
f.bittiandClaude Opus 5 4a01a2ec88 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>
2026-09-22 16:56:34 +02:00

2931 lines
87 KiB
ObjectPascal

unit uConsoleMain;
{
Plancia configurabile, due modalita' decise da riga di comando:
PlanciaConsole.exe -> aboard (default)
PlanciaConsole.exe aboard
PlanciaConsole.exe config -> configurazione
PlanciaConsole.exe config -f rotta.xml
In "aboard" legge l'XML, si connette da sola e opera: nessun comando di
modifica a vista, solo la barra di stato.
In "config" mostra palette, pannello proprieta' e salvataggio, e NON apre
la porta seriale: cosi' non si comandano relE' per sbaglio mentre si
disegna la plancia.
}
interface
uses
Winapi.Windows, Winapi.Messages, System.SysUtils, System.Classes,
System.Types, System.UITypes, System.Math, System.Generics.Collections,
System.IOUtils, System.StrUtils,
Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls,
Vcl.ExtCtrls, Vcl.ExtDlgs, Vcl.Buttons,
uModbusRTU, uModbusWorker, uAlarmSound, uGauge, uImageLib, uPlanciaElements, uPlanciaConfig;
type
TAppMode = (amAboard, amConfig);
TElementKinds = set of TElementKind;
/// Una bobina da leggere da sola durante l'allineamento iniziale.
TCoilChannel = record
Slave: Integer;
Channel: Integer;
end;
/// Una richiesta Modbus che copre tutti i canali contigui di uno slave.
TPollGroup = record
Slave: Integer;
First: Integer;
Count: Integer;
end;
TConsoleForm = class(TForm)
pnlToolbar: TPanel;
btnNuovo: TButton;
btnApri: TButton;
btnSalva: TButton;
btnSalvaCome: TButton;
btnAnnulla: TButton;
btnRipeti: TButton;
chkGriglia: TCheckBox;
lblZoom: TLabel;
cboZoom: TComboBox;
lblFile: TLabel;
pnlStatus: TPanel;
shpLed: TShape;
lblStatus: TLabel;
btnModo: TButton;
btnNotte: TButton;
cboPlancia: TComboBox;
btnAllarmi: TButton;
pnlPalette: TPanel;
lblPaletteTitle: TLabel;
gbImmagini: TGroupBox;
lstImmagini: TListBox;
btnImgAdd: TButton;
btnImgDel: TButton;
gbGenerale: TGroupBox;
lblPort: TLabel;
cboPort: TComboBox;
lblBaud: TLabel;
cboBaud: TComboBox;
lblPoll: TLabel;
edtPoll: TEdit;
lblTitolo: TLabel;
edtTitolo: TEdit;
lblPanelSize: TLabel;
edtPanelW: TEdit;
edtPanelH: TEdit;
lblSfondo: TLabel;
cboSfondo: TComboBox;
pnlProps: TPanel;
lblPropTitle: TLabel;
lblCap: TLabel;
edtCaption: TEdit;
lblFontSize: TLabel;
edtFontSize: TEdit;
lblSlave: TLabel;
edtSlave: TEdit;
lblCanale: TLabel;
edtCanale: TEdit;
lblPos: TLabel;
edtLeft: TEdit;
edtTop: TEdit;
lblSize: TLabel;
edtWidth: TEdit;
edtHeight: TEdit;
lblTipo: TLabel;
cboTipo: TComboBox;
gbImgElem: TGroupBox;
lblShape: TLabel;
cboShape: TComboBox;
lblCapPos: TLabel;
cboCapPos: TComboBox;
lblOnColor: TLabel;
edtOnColor: TEdit;
edtOffColor: TEdit;
lblImgOff: TLabel;
cboImgOff: TComboBox;
lblImgOn: TLabel;
cboImgOn: TComboBox;
gbGauge: TGroupBox;
lblRaw: TLabel;
edtRawMin: TEdit;
edtRawMax: TEdit;
lblEng: TLabel;
edtEngMin: TEdit;
edtEngMax: TEdit;
lblUnits: TLabel;
edtUnits: TEdit;
lblWarn: TLabel;
edtWarnLo: TEdit;
edtWarnHi: TEdit;
lblDigits: TLabel;
edtDigits: TEdit;
lblDecimals: TLabel;
edtDecimals: TEdit;
lblCanale2: TLabel;
edtCanale2: TEdit;
chkMolla: TCheckBox;
gbTesto: TGroupBox;
lblFontName: TLabel;
edtFontName: TEdit;
lblSpacing: TLabel;
edtSpacing: TEdit;
lblFrame: TLabel;
cboFrame: TComboBox;
edtFrameWidth: TEdit;
gbSuono: TGroupBox;
chkAllarme: TCheckBox;
cboSuono: TComboBox;
btnSuoniRileggi: TButton;
gbRotary: TGroupBox;
lblPositions: TLabel;
cboPositions: TComboBox;
lblLegend: TLabel;
edtLegend: TEdit;
btnElimina: TButton;
scrCanvas: TScrollBox;
pnlCanvas: TPanel;
tmrPoll: TTimer;
dlgApri: TOpenDialog;
dlgSalva: TSaveDialog;
dlgImmagine: TOpenPictureDialog;
procedure FormCreate(Sender: TObject);
procedure FormDestroy(Sender: TObject);
procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
procedure FormKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
procedure FormKeyPress(Sender: TObject; var Key: Char);
procedure btnSuoniRileggiClick(Sender: TObject);
procedure btnAnnullaClick(Sender: TObject);
procedure btnRipetiClick(Sender: TObject);
procedure btnNuovoClick(Sender: TObject);
procedure btnApriClick(Sender: TObject);
procedure btnSalvaClick(Sender: TObject);
procedure btnSalvaComeClick(Sender: TObject);
procedure btnEliminaClick(Sender: TObject);
procedure chkGrigliaClick(Sender: TObject);
procedure ZoomChanged(Sender: TObject);
procedure btnImgAddClick(Sender: TObject);
procedure btnImgDelClick(Sender: TObject);
procedure BackgroundChanged(Sender: TObject);
procedure PropChanged(Sender: TObject);
procedure KindChanged(Sender: TObject);
procedure btnModoClick(Sender: TObject);
procedure btnNotteClick(Sender: TObject);
procedure cboPlanciaSelect(Sender: TObject);
procedure btnAllarmiClick(Sender: TObject);
procedure GeneralChanged(Sender: TObject);
procedure CanvasDragOver(Sender, Source: TObject; X, Y: Integer;
State: TDragState; var Accept: Boolean);
procedure CanvasDragDrop(Sender, Source: TObject; X, Y: Integer);
procedure CanvasMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure tmrPollTimer(Sender: TObject);
private
FMode: TAppMode;
/// Vero solo se l'applicazione e' partita in configurazione. Una plancia
/// avviata in servizio non deve offrire la via per essere modificata.
FCanConfigure: Boolean;
/// Finestra e zoom di lavoro, messi da parte mentre si sta in plancia:
/// tornando a configurare si ritrova il posto com'era stato lasciato.
FConfigBounds: TRect;
FConfigZoom: Double;
/// Plancia in modalita' notturna: fondo scuro e colori abbassati.
FNight: Boolean;
FConfig: TPlanciaConfig;
FFileName: string;
FDirty: Boolean;
/// Il file corrente non si e' aperto: non ci si salva sopra alla chiusura.
FLoadFailed: Boolean;
FElements: TList<TPlanciaElement>;
FSelected: TPlanciaElement;
/// Il bus vive qui: un thread che possiede la porta. Il thread
/// principale gli mette in coda i comandi e ritira le letture.
FWorker: TModbusWorker;
/// Suoni di allarme delle spie.
FAlarms: TAlarmPlayer;
FUpdatingProps: Boolean;
FLampPlan: TList<TPollGroup>;
FGaugePlan: TList<TPollGroup>;
FSwitchPlan: TList<TPollGroup>;
FPlanDirty: Boolean;
/// Primo errore di lettura del ciclo in corso, e quanti ce ne sono stati.
FPollError: string;
FPollErrors: Integer;
/// Fino a quando il messaggio in barra non va sovrascritto.
FStatusUntil: UInt64;
/// Prima lettura delle bobine dopo l'ingresso in plancia: serve a mettere
/// i comandi come sono i rele' veri, pulsanti compresi.
FAligning: Boolean;
FAlignReported: Boolean;
/// Canali di bobina usati dalla plancia, senza doppioni: l'allineamento
/// iniziale li legge uno per uno.
FCoilChannels: TArray<TCoilChannel>;
/// Richieste delle bobine effettivamente in uso: partono raggruppate e si
/// dividono da sole quando un blocco non risponde.
FCoilReqs: TArray<TPollRequest>;
/// Quando ogni bobina e' stata comandata l'ultima volta.
FCoilWrites: TDictionary<Int64, UInt64>;
FZoom: Double;
FBackImage: TImage;
/// Percorsi delle plance elencate in cboPlancia, nello stesso ordine.
FPlanciaFiles: TStringList;
/// Storico delle modifiche in configurazione: fotografie dello stato
/// prima di ogni modifica (FUndo) e di quelle annullate (FRedo).
FUndo: TObjectList<TPlanciaSnapshot>;
FRedo: TObjectList<TPlanciaSnapshot>;
/// Chiave e ora dell'ultima fotografia: modifiche in fila con la stessa
/// chiave diventano un passo solo.
FUndoKey: string;
FUndoTime: UInt64;
procedure ParseCommandLine;
procedure PushUndo(const AKey: string = '');
procedure ClearUndo;
procedure StepHistory(AFrom, ATo: TObjectList<TPlanciaSnapshot>);
procedure UpdateUndoButtons;
function SelectedIndex: Integer;
procedure LoadGeneral;
procedure NudgeSelected(ADX, ADY: Integer; AResize: Boolean);
procedure FocusCanvas;
procedure ElementBeginChange(ASender: TPlanciaElement);
procedure RefreshPlanciaList;
procedure SwitchPlancia(const AFileName: string);
procedure LayoutStatusBar;
procedure BuildPalette;
procedure PaletteMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure ApplyMode;
procedure SwitchMode(ANewMode: TAppMode);
procedure ApplyNight;
procedure RebuildPanel;
function CreateElementControl(ADef: TElementDef): TPlanciaElement;
procedure SelectElement(AElement: TPlanciaElement);
procedure LoadProps;
/// Casella della seconda bobina: c'e' solo a tre posizioni, e ricorda
/// quale canale verrebbe usato lasciandola vuota.
procedure UpdateRotaryFields(ADef: TElementDef);
procedure DeleteSelected;
procedure MarkDirty;
procedure FitWindowToPanel;
procedure SetZoom(const AZoom: Double);
procedure ResizeCanvas;
procedure RefreshImageLists;
/// Rilegge la cartella dei suoni e riempie la casella.
procedure RefreshSoundList;
/// Fa suonare l'allarme di una spia appena accesa.
procedure TriggerAlarm(AElement: TPlanciaElement);
/// Ferma il suono di una spia, se nessun'altra spia accesa lo usa.
procedure StopAlarm(AElement: TPlanciaElement);
/// Rimette in moto il suono di un allarme ancora presente, se e' finito.
procedure KeepAlarmSounding(AElement: TPlanciaElement);
procedure ElementMuteRequest(ASender: TPlanciaElement);
/// Rimette in funzione gli allarmi il cui silenzio e' scaduto e aggiorna
/// il tasto della barra di stato.
procedure UpdateMutedAlarms;
procedure UpdateBackground;
function FindFreeSpot(const APreferred: TPoint;
AWidth, AHeight: Integer): TPoint;
procedure UpdateCaptions;
procedure SetStatus(const AMsg: string; AOk: Boolean);
/// Come SetStatus, ma il messaggio resta per AHoldMs prima di essere
/// sostituito dallo stato del bus.
procedure SetStatusFor(const AMsg: string; AOk: Boolean; AHoldMs: Integer);
function UniqueCaption(AKind: TElementKind): string;
function GridStep: Integer;
procedure DoLoad(const AFileName: string);
procedure DoSave(const AFileName: string);
function ConfirmDiscard: Boolean;
procedure ConnectModbus;
procedure DisconnectModbus;
procedure BuildPollPlan;
/// Spezza a meta' una richiesta di bobine che non risponde.
procedure SplitCoilRequest(const ARequest: TPollRequest);
procedure AddToPlan(APlan: TList<TPollGroup>; ASlave, AChannel: Integer);
procedure NotePollError(const AWhat, AError: string);
procedure InvalidateGroup(AKinds: TElementKinds;
const AGroup: TPollGroup);
procedure ApplyReading(const AReading: TPollReading);
/// Chiude l'allineamento iniziale quando le bobine sono state lette.
procedure FinishAligning(const AReadings: TArray<TPollReading>);
function BusReady: Boolean;
procedure InvalidateReadings;
/// Allinea gli altri comandi che stanno sulla stessa bobina.
procedure MirrorCoil(ASlave, AChannel: Integer; AOn: Boolean;
AExcept: TPlanciaElement);
/// Segna l'istante in cui una bobina e' stata comandata.
procedure NoteCoilWrite(ASlave, AChannel: Integer);
/// Vero se la risposta e' piu' vecchia dell'ultimo comando su quella
/// bobina: va scartata, altrimenti il comando torna indietro da solo.
function ReadingStale(ASlave, AChannel: Integer; ASent: UInt64): Boolean;
procedure ElementCommand(ASender: TPlanciaElement; AOn: Boolean;
var AAccepted: Boolean);
procedure ElementRotary(ASender: TPlanciaElement; APos: Integer;
var AAccepted: Boolean);
procedure ElementSelectRequest(ASender: TPlanciaElement);
procedure ElementGeometryChanged(ASender: TPlanciaElement);
public
end;
var
ConsoleForm: TConsoleForm;
implementation
{$R *.dfm}
const
PALETTE_TOP = 26;
PALETTE_ITEM_H = 30;
PALETTE_GAP = 4;
MAX_REGISTERS_PER_READ = 125;
MAX_BITS_PER_READ = 2000;
// Passo di ricerca di una posizione libera per un nuovo elemento.
FREE_SPOT_STEP = 20;
// Passi di annulla conservati: oltre, i piu' vecchi si perdono.
UNDO_LIMIT = 100;
// Entro questo intervallo le modifiche in fila allo stesso campo o allo
// stesso elemento (cifre digitate, ctrl+freccia tenuto premuto) si fondono.
UNDO_MERGE_MS = 1500;
// Quanto resta a video un messaggio importante (comando dato, errore di
// scrittura) prima che lo stato del bus lo sostituisca.
STATUS_HOLD_MS = 2500;
// Tipi intercambiabili dopo il disegno. Condividono tutti i campi della
// definizione - canale, colori, forma, immagini, etichetta - e differiscono
// solo nel funzionamento, quindi il passaggio dall'uno all'altro non perde
// niente. La spia c'e' perche' su un quadro vero una lente rossa puo' essere
// un allarme e non un comando, e dalla foto non si capisce. Aggiungerne uno
// qui basta a renderlo scambiabile.
SWITCHABLE_KINDS: array[0..2] of TElementKind = (ekButton, ekSwitch, ekLamp);
{ avvio }
procedure TConsoleForm.FormCreate(Sender: TObject);
var
I: Integer;
begin
FConfig := TPlanciaConfig.Create;
FElements := TList<TPlanciaElement>.Create;
FLampPlan := TList<TPollGroup>.Create;
FGaugePlan := TList<TPollGroup>.Create;
FSwitchPlan := TList<TPollGroup>.Create;
FPlanciaFiles := TStringList.Create;
FAlarms := TAlarmPlayer.Create;
FCoilWrites := TDictionary<Int64, UInt64>.Create;
FUndo := TObjectList<TPlanciaSnapshot>.Create(True);
FRedo := TObjectList<TPlanciaSnapshot>.Create(True);
FZoom := 1;
for I := 1 to 20 do
cboPort.Items.Add('COM' + IntToStr(I));
cboBaud.Items.CommaText := '9600,19200,38400,57600,115200';
cboZoom.Items.CommaText := '50%,75%,100%,125%,150%,200%';
cboZoom.ItemIndex := 2;
cboPositions.Items.CommaText := '2,3';
for var Sh := Low(TKeyShape) to High(TKeyShape) do
cboShape.Items.Add(SHAPE_NAMES[Sh]);
for var Sk := Low(SWITCHABLE_KINDS) to High(SWITCHABLE_KINDS) do
cboTipo.Items.Add(ELEMENT_NAMES[SWITCHABLE_KINDS[Sk]]);
for var Cp := Low(TCaptionPos) to High(TCaptionPos) do
cboCapPos.Items.Add(CAPTION_POS_NAMES[Cp]);
for var Fr := Low(TFrameKind) to High(TFrameKind) do
cboFrame.Items.Add(FRAME_NAMES[Fr]);
BuildPalette;
ParseCommandLine;
// Solo chi e' partito configurando puo' fare avanti e indietro: su una
// plancia avviata in servizio il pulsante non compare proprio.
FCanConfigure := FMode = amConfig;
ApplyMode;
ApplyNight;
if FileExists(FFileName) then
DoLoad(FFileName)
else
begin
RebuildPanel;
if FMode = amAboard then
SetStatus(Format('Configurazione non trovata: %s', [FFileName]), False)
else
SetStatus('Nuova plancia. Trascina un elemento dalla palette.', True);
end;
if FMode = amAboard then
ConnectModbus;
end;
procedure TConsoleForm.FormDestroy(Sender: TObject);
begin
tmrPoll.Enabled := False;
DisconnectModbus;
FAlarms.Free;
FCoilWrites.Free;
FPlanciaFiles.Free;
FRedo.Free;
FUndo.Free;
FSwitchPlan.Free;
FGaugePlan.Free;
FLampPlan.Free;
FElements.Free;
FConfig.Free;
end;
procedure TConsoleForm.ParseCommandLine;
var
I: Integer;
P, Low: string;
begin
FMode := amAboard;
// Senza indicazioni si riprende la plancia usata l'ultima volta; se non c'e'
// promemoria, o il file non esiste piu', il predefinito accanto all'exe.
FFileName := LoadLastFile;
if FFileName = '' then
FFileName := DefaultConfigFile;
FConfigZoom := 1;
I := 1;
while I <= ParamCount do
begin
P := ParamStr(I);
Low := LowerCase(P);
while (Low <> '') and CharInSet(Low[1], ['-', '/']) do
Delete(Low, 1, 1);
if (Low = 'config') or (Low = 'configuration') or (Low = 'conf') then
FMode := amConfig
else if Low = 'aboard' then
FMode := amAboard
else if (Low = 'f') or (Low = 'file') then
begin
// forma "-f nome.xml"
Inc(I);
if I <= ParamCount then
FFileName := ExpandFileName(ParamStr(I));
end
else if Low.StartsWith('file=') then
FFileName := ExpandFileName(Copy(P, Pos('=', P) + 1, MaxInt))
else if not P.StartsWith('-') and not P.StartsWith('/') then
// un parametro libero e' inteso come percorso del file
FFileName := ExpandFileName(P);
Inc(I);
end;
end;
{ palette }
procedure TConsoleForm.BuildPalette;
var
K: TElementKind;
Chip: TPanel;
Y: Integer;
begin
Y := PALETTE_TOP;
for K := Low(TElementKind) to High(TElementKind) do
begin
Chip := TPanel.Create(Self);
Chip.Parent := pnlPalette;
Chip.SetBounds(10, Y, pnlPalette.ClientWidth - 24, PALETTE_ITEM_H);
Chip.Caption := ELEMENT_NAMES[K];
Chip.BevelOuter := bvRaised;
Chip.Cursor := crHandPoint;
Chip.Hint := ELEMENT_HINTS[K] + ' - trascina sul pannello';
Chip.ShowHint := True;
Chip.Tag := Ord(K);
Chip.DragMode := dmManual;
Chip.OnMouseDown := PaletteMouseDown;
Inc(Y, PALETTE_ITEM_H + PALETTE_GAP);
end;
end;
procedure TConsoleForm.PaletteMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
// Soglia di 6 px: un click secco non fa partire il trascinamento.
if (Button = mbLeft) and (FMode = amConfig) then
TControl(Sender).BeginDrag(False, 6);
end;
{ modalita' e ricostruzione del pannello }
procedure TConsoleForm.ApplyMode;
var
Editing: Boolean;
begin
Editing := FMode = amConfig;
pnlToolbar.Visible := Editing;
pnlPalette.Visible := Editing;
pnlProps.Visible := Editing;
KeyPreview := Editing;
pnlCanvas.DoubleBuffered := True;
// Il pulsante sta nella barra di stato perche' e' l'unica che resta visibile
// in plancia: la barra degli strumenti sparisce insieme alla palette.
btnModo.Visible := FCanConfigure;
if Editing then
btnModo.Caption := 'Vai in plancia'
else
btnModo.Caption := 'Torna a configurare';
RefreshPlanciaList;
UpdateCaptions;
end;
procedure TConsoleForm.LayoutStatusBar;
var
X: Integer;
procedure Place(AControl: TControl);
begin
if not AControl.Visible then
Exit;
AControl.Left := X - AControl.Width;
X := AControl.Left - 6;
end;
begin
// Da destra verso sinistra, solo quello che si vede: in aboard il pulsante
// di configurazione puo' mancare e non deve restare un buco.
X := pnlStatus.ClientWidth - 10;
Place(btnModo);
Place(btnNotte);
Place(cboPlancia);
Place(btnAllarmi);
end;
procedure TConsoleForm.RefreshPlanciaList;
var
Dir, F, Title: string;
I: Integer;
begin
FPlanciaFiles.Clear;
cboPlancia.Items.BeginUpdate;
try
cboPlancia.Items.Clear;
// Le plance fra cui scegliere sono gli XML accanto a quella caricata: una
// plancia si trasporta copiando la sua cartella, e le sorelle viaggiano con
// lei. Gli XML che non sono plance vengono scartati.
Dir := ExtractFilePath(ExpandFileName(FFileName));
if TDirectory.Exists(Dir) then
for F in TDirectory.GetFiles(Dir, '*.xml') do
if ReadPanelTitle(F, Title) then
begin
if Title = '' then
Title := ChangeFileExt(ExtractFileName(F), '');
// Due plance con lo stesso titolo si distinguono dal nome del file.
if cboPlancia.Items.IndexOf(Title) >= 0 then
Title := Format('%s (%s)', [Title, ExtractFileName(F)]);
cboPlancia.Items.Add(Title);
FPlanciaFiles.Add(F);
end;
cboPlancia.ItemIndex := -1;
for I := 0 to FPlanciaFiles.Count - 1 do
if SameFileName(FPlanciaFiles[I], ExpandFileName(FFileName)) then
cboPlancia.ItemIndex := I;
finally
cboPlancia.Items.EndUpdate;
end;
// Solo a bordo: in configurazione c'e' gia' "Apri", che chiede anche delle
// modifiche non salvate. Con una plancia sola non c'e' niente da scegliere.
cboPlancia.Visible := (FMode = amAboard) and (FPlanciaFiles.Count > 1);
LayoutStatusBar;
end;
procedure TConsoleForm.cboPlanciaSelect(Sender: TObject);
var
I: Integer;
begin
I := cboPlancia.ItemIndex;
if (I < 0) or (I >= FPlanciaFiles.Count) then
Exit;
// Il fuoco va tolto alla casella: con le frecce della tastiera cambierebbe
// plancia di nuovo a ogni tasto.
ActiveControl := nil;
SwitchPlancia(FPlanciaFiles[I]);
end;
procedure TConsoleForm.SwitchPlancia(const AFileName: string);
var
Probe: TPlanciaConfig;
begin
if SameFileName(ExpandFileName(AFileName), ExpandFileName(FFileName)) then
Exit;
// Prima si legge il file a parte. Se e' rotto la plancia in servizio resta
// quella di prima: DoLoad in caso di errore lascerebbe un pannello vuoto,
// e a bordo e' peggio di non aver cambiato niente.
Probe := TPlanciaConfig.Create;
try
try
Probe.LoadFromFile(AFileName);
except
on E: Exception do
begin
SetStatus(Format('Plancia %s non caricata: %s',
[ExtractFileName(AFileName), E.Message]), False);
RefreshPlanciaList;
Exit;
end;
end;
finally
Probe.Free;
end;
// La nuova plancia puo' stare su un'altra porta o un altro baud: il bus si
// chiude e si riapre con i parametri del file. Le bobine non vengono
// toccate, i rele' restano come sono e la nuova plancia li rilegge.
DisconnectModbus;
// Si riparte da 1:1, poi FitWindowToPanel rimpicciolisce se serve.
SetZoom(1);
DoLoad(AFileName);
if FMode = amAboard then
ConnectModbus;
RefreshPlanciaList;
end;
procedure TConsoleForm.btnNotteClick(Sender: TObject);
begin
FNight := not FNight;
ApplyNight;
end;
procedure TConsoleForm.ApplyNight;
var
E: TPlanciaElement;
Back: TColor;
begin
if FNight then
begin
Back := CLR_NIGHT_BG;
btnNotte.Caption := 'Giorno';
end
else
begin
Back := clBtnFace;
btnNotte.Caption := 'Notte';
end;
pnlCanvas.Color := Back;
scrCanvas.Color := Back;
for E in FElements do
begin
// Gli elementi sono controlli non finestrati: il loro Color e' il fondo su
// cui disegnano le parti trasparenti, e deve seguire quello del pannello.
E.Color := Back;
E.NightMode := FNight;
end;
// Lo sfondo e' un bitmap: non basta cambiare un colore, va rifatto scuro.
UpdateBackground;
pnlCanvas.Invalidate;
end;
procedure TConsoleForm.btnModoClick(Sender: TObject);
begin
if FMode = amConfig then
SwitchMode(amAboard)
else
SwitchMode(amConfig);
end;
procedure TConsoleForm.SwitchMode(ANewMode: TAppMode);
var
E: TPlanciaElement;
begin
if FMode = ANewMode then
Exit;
// Uscendo dalla configurazione le modifiche non salvate vanno chieste prima,
// altrimenti passare in plancia le perderebbe in silenzio.
if (FMode = amConfig) and not ConfirmDiscard then
Exit;
if FMode = amConfig then
begin
FConfigBounds := BoundsRect;
FConfigZoom := FZoom;
end;
FMode := ANewMode;
if FMode = amAboard then
begin
SelectElement(nil);
ApplyMode;
for E in FElements do
E.EditMode := False;
// In plancia si riparte sempre da 1:1 e poi si rimpicciolisce solo se lo
// schermo non basta, esattamente come all'avvio.
SetZoom(1);
FitWindowToPanel;
FPlanDirty := True;
ConnectModbus;
end
else
begin
// Configurando si sta spostando roba a video, non comandando il campo: il
// bus va lasciato libero, cosi' la porta torna disponibile agli strumenti.
DisconnectModbus;
// Nessun allarme deve continuare a suonare fuori dalla plancia, e i
// silenzi non hanno piu' senso: si riparte puliti.
FAlarms.StopAll;
for E in FElements do
E.ClearAlarmMute;
UpdateMutedAlarms;
ApplyMode;
for E in FElements do
begin
// Gli stati letti dal campo non valgono piu': lasciarli accesi mostrerebbe
// una plancia che sembra in servizio mentre non sta leggendo nulla.
E.SetInvalid;
E.SyncState(False);
E.SyncRotary(0);
E.EditMode := True;
end;
if FConfigZoom > 0 then
SetZoom(FConfigZoom);
if not FConfigBounds.IsEmpty then
BoundsRect := FConfigBounds;
SetStatus('Configurazione.', True);
end;
end;
procedure TConsoleForm.RebuildPanel;
var
E: TPlanciaElement;
D: TElementDef;
begin
for E in FElements do
E.Free;
FElements.Clear;
FSelected := nil;
// Lo sfondo va creato per primo: i controlli non finestrati vengono
// disegnati nell'ordine di inserimento, quindi gli elementi restano sopra.
FreeAndNil(FBackImage);
FBackImage := TImage.Create(Self);
FBackImage.Parent := pnlCanvas;
FBackImage.Align := alClient;
FBackImage.Stretch := True;
FBackImage.OnDragOver := CanvasDragOver;
FBackImage.OnDragDrop := CanvasDragDrop;
FBackImage.OnMouseDown := CanvasMouseDown;
ResizeCanvas;
UpdateBackground;
for D in FConfig.Elements do
CreateElementControl(D);
FPlanDirty := True;
RefreshImageLists;
RefreshSoundList;
LoadProps;
FitWindowToPanel;
UpdateCaptions;
end;
procedure TConsoleForm.ResizeCanvas;
begin
pnlCanvas.SetBounds(0, 0, Round(FConfig.PanelWidth * FZoom),
Round(FConfig.PanelHeight * FZoom));
end;
/// Copia scurita di un'immagine, pixel per pixel. Serve per lo sfondo in
/// modalita' notturna: una lamiera chiara a tutto schermo sarebbe la cosa piu'
/// abbagliante del quadro, altro che i comandi.
function DimPicture(APic: TPicture; AFactor: Double): TBitmap;
var
X, Y: Integer;
Row: PByteArray;
Tab: array[0..255] of Byte;
begin
for X := 0 to 255 do
Tab[X] := Round(X * AFactor);
Result := TBitmap.Create;
try
Result.PixelFormat := pf24bit;
Result.SetSize(APic.Width, APic.Height);
Result.Canvas.Draw(0, 0, APic.Graphic);
for Y := 0 to Result.Height - 1 do
begin
Row := Result.ScanLine[Y];
for X := 0 to Result.Width * 3 - 1 do
Row[X] := Tab[Row[X]];
end;
except
Result.Free;
raise;
end;
end;
procedure TConsoleForm.UpdateBackground;
var
Pic: TPicture;
Dark: TBitmap;
begin
if FBackImage = nil then
Exit;
Pic := FConfig.Images.Picture(FConfig.Background);
if Pic = nil then
begin
FBackImage.Picture.Assign(nil);
Exit;
end;
if not FNight then
begin
FBackImage.Picture.Assign(Pic);
Exit;
end;
Dark := DimPicture(Pic, NIGHT_DIM);
try
FBackImage.Picture.Assign(Dark);
finally
Dark.Free;
end;
end;
procedure TConsoleForm.SetZoom(const AZoom: Double);
var
E: TPlanciaElement;
begin
if AZoom <= 0 then
Exit;
FZoom := AZoom;
ResizeCanvas;
for E in FElements do
E.Scale := FZoom;
end;
procedure TConsoleForm.ZoomChanged(Sender: TObject);
var
S: string;
begin
S := StringReplace(cboZoom.Text, '%', '', [rfReplaceAll]);
SetZoom(StrToIntDef(Trim(S), 100) / 100);
end;
procedure TConsoleForm.FitWindowToPanel;
var
Work: TRect;
W, H, AvailW, AvailH: Integer;
Z: Double;
begin
// In aboard la finestra veste la plancia: niente cornice grigia inutile.
// In configurazione la finestra resta grande, serve spazio per lavorare.
if FMode <> amAboard then
Exit;
Work := Screen.WorkAreaRect;
// Margini larghi: se la plancia sfiora il limite compaiono le barre di
// scorrimento e l'ultima fila di comandi resta tagliata.
AvailW := Work.Width - 40;
AvailH := Work.Height - 90 - pnlStatus.Height;
// Se la plancia non entra nello schermo si rimpicciolisce, invece di
// costringere l'operatore a scorrere per raggiungere un comando.
Z := 1;
if FConfig.PanelWidth > AvailW then
Z := Min(Z, AvailW / FConfig.PanelWidth);
if FConfig.PanelHeight > AvailH then
Z := Min(Z, AvailH / FConfig.PanelHeight);
if Z < 1 then
SetZoom(Z);
W := Round(FConfig.PanelWidth * FZoom);
H := Round(FConfig.PanelHeight * FZoom) + pnlStatus.Height;
if W > AvailW then
W := AvailW;
if H > AvailH + pnlStatus.Height then
H := AvailH + pnlStatus.Height;
if W < 320 then
W := 320;
if H < 200 then
H := 200;
ClientWidth := W;
ClientHeight := H;
end;
/// Suggerimento a comparsa di un elemento: dice su cosa lavora sul bus.
function ElementHint(ADef: TElementDef): string;
begin
if not ElementHasChannel(ADef.Kind) then
Exit(ELEMENT_NAMES[ADef.Kind]);
if not ADef.Configured then
Exit(ELEMENT_NAMES[ADef.Kind] + ' - nessun canale configurato');
// Il selettore a tre posizioni comanda due bobine: vanno dette entrambe,
// perche' la seconda nell'XML puo' non essere scritta da nessuna parte.
if ADef.CoilCount > 1 then
Result := Format('%s - slave %d, canali %d e %d',
[ELEMENT_NAMES[ADef.Kind], ADef.Slave, ADef.Channel, ADef.RightChannel])
else
Result := Format('%s - slave %d, canale %d',
[ELEMENT_NAMES[ADef.Kind], ADef.Slave, ADef.Channel]);
end;
function TConsoleForm.CreateElementControl(ADef: TElementDef): TPlanciaElement;
begin
Result := TPlanciaElement.CreateElement(Self, ADef, FConfig.Images);
Result.Parent := pnlCanvas;
Result.Color := pnlCanvas.Color;
Result.NightMode := FNight;
Result.InkColor := FConfig.Ink;
Result.EditMode := FMode = amConfig;
Result.GridSize := GridStep;
Result.Scale := FZoom;
Result.OnCommand := ElementCommand;
Result.OnRotary := ElementRotary;
Result.OnSelectRequest := ElementSelectRequest;
Result.OnGeometryChanged := ElementGeometryChanged;
Result.OnBeginChange := ElementBeginChange;
Result.OnMuteRequest := ElementMuteRequest;
Result.OnDragOver := CanvasDragOver;
Result.OnDragDrop := CanvasDragDrop;
Result.Hint := ElementHint(ADef);
FElements.Add(Result);
end;
function TConsoleForm.GridStep: Integer;
begin
if chkGriglia.Checked then
Result := 10
else
Result := 1;
end;
procedure TConsoleForm.chkGrigliaClick(Sender: TObject);
var
E: TPlanciaElement;
begin
for E in FElements do
E.GridSize := GridStep;
end;
function TConsoleForm.UniqueCaption(AKind: TElementKind): string;
var
D: TElementDef;
N: Integer;
begin
N := 0;
for D in FConfig.Elements do
if D.Kind = AKind then
Inc(N);
Result := Format('%s %d', [ELEMENT_NAMES[AKind], N]);
end;
{ drag & drop dalla palette }
procedure TConsoleForm.CanvasDragOver(Sender, Source: TObject; X, Y: Integer;
State: TDragState; var Accept: Boolean);
begin
Accept := (FMode = amConfig) and (Source is TControl) and
(TControl(Source).Parent = pnlPalette);
end;
function TConsoleForm.FindFreeSpot(const APreferred: TPoint;
AWidth, AHeight: Integer): TPoint;
function Occupied(AX, AY: Integer): Boolean;
var
D: TElementDef;
R, Dummy: TRect;
begin
R := Rect(AX, AY, AX + AWidth, AY + AHeight);
for D in FConfig.Elements do
if IntersectRect(Dummy, R, D.Bounds) then
Exit(True);
Result := False;
end;
var
Step, X, Y, I: Integer;
begin
Result := APreferred;
if Result.X < 0 then
Result.X := 0;
if Result.Y < 0 then
Result.Y := 0;
if Result.X + AWidth > FConfig.PanelWidth then
Result.X := Max(0, FConfig.PanelWidth - AWidth);
if Result.Y + AHeight > FConfig.PanelHeight then
Result.Y := Max(0, FConfig.PanelHeight - AHeight);
if not Occupied(Result.X, Result.Y) then
Exit;
// Occupato: prima si prova a cascata in diagonale, cosi' il nuovo elemento
// resta vicino a dove e' stato rilasciato invece di saltare altrove.
Step := Max(GridStep, FREE_SPOT_STEP);
for I := 1 to 24 do
begin
X := Result.X + I * Step;
Y := Result.Y + I * Step;
if (X + AWidth > FConfig.PanelWidth) or (Y + AHeight > FConfig.PanelHeight) then
Break;
if not Occupied(X, Y) then
Exit(Point(X, Y));
end;
// Se la diagonale non basta si scandisce tutto il pannello.
Y := 0;
while Y + AHeight <= FConfig.PanelHeight do
begin
X := 0;
while X + AWidth <= FConfig.PanelWidth do
begin
if not Occupied(X, Y) then
Exit(Point(X, Y));
Inc(X, Step);
end;
Inc(Y, Step);
end;
// Pannello pieno: si lascia dove l'utente ha rilasciato.
end;
procedure TConsoleForm.CanvasDragDrop(Sender, Source: TObject;
X, Y: Integer);
var
P: TPoint;
Kind: TElementKind;
Def: TElementDef;
Step: Integer;
begin
if not ((FMode = amConfig) and (Source is TControl) and
(TControl(Source).Parent = pnlPalette)) then
Exit;
P := Point(X, Y);
// Se si rilascia sopra un elemento gia' presente, le coordinate arrivano
// relative a quello: vanno riportate sul pannello.
if (Sender <> pnlCanvas) and (Sender is TControl) then
P := TControl(Sender).ClientToParent(P, pnlCanvas);
// Dalle coordinate a video a quelle logiche del pannello.
P := Point(Round(P.X / FZoom), Round(P.Y / FZoom));
PushUndo;
Kind := TElementKind(TControl(Source).Tag);
Def := TElementDef.Create(Kind);
Def.Caption := UniqueCaption(Kind);
P := Point(P.X - Def.Width div 2, P.Y - Def.Height div 2);
Step := GridStep;
if Step > 1 then
P := Point(Round(P.X / Step) * Step, Round(P.Y / Step) * Step);
P := FindFreeSpot(P, Def.Width, Def.Height);
Def.Left := P.X;
Def.Top := P.Y;
FConfig.Elements.Add(Def);
SelectElement(CreateElementControl(Def));
MarkDirty;
if Def.Caption <> '' then
SetStatus(Format('Aggiunto: %s', [Def.Caption]), True)
else
SetStatus('Aggiunta immagine. Scegli quale nel pannello a destra.', True);
end;
procedure TConsoleForm.CanvasMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
// Click sul vuoto: deseleziona.
if (FMode = amConfig) and (Button = mbLeft) then
begin
SelectElement(nil);
FocusCanvas;
end;
end;
{ selezione e proprieta' }
procedure TConsoleForm.ElementSelectRequest(ASender: TPlanciaElement);
begin
SelectElement(ASender);
FocusCanvas;
end;
procedure TConsoleForm.FocusCanvas;
begin
// Cliccando un elemento il fuoco restava nell'ultima casella delle
// proprieta': li' ctrl+freccia sposta il cursore nel testo invece
// dell'elemento. Portarlo sul pannello rende i tasti della plancia.
if (ActiveControl <> scrCanvas) and scrCanvas.CanFocus then
scrCanvas.SetFocus;
end;
procedure TConsoleForm.ElementBeginChange(ASender: TPlanciaElement);
begin
PushUndo;
end;
procedure TConsoleForm.ElementGeometryChanged(ASender: TPlanciaElement);
begin
MarkDirty;
if ASender = FSelected then
begin
FUpdatingProps := True;
try
edtLeft.Text := IntToStr(ASender.Def.Left);
edtTop.Text := IntToStr(ASender.Def.Top);
edtWidth.Text := IntToStr(ASender.Def.Width);
edtHeight.Text := IntToStr(ASender.Def.Height);
finally
FUpdatingProps := False;
end;
end;
end;
procedure TConsoleForm.SelectElement(AElement: TPlanciaElement);
begin
if FSelected <> nil then
FSelected.Selected := False;
FSelected := AElement;
if FSelected <> nil then
FSelected.Selected := True;
LoadProps;
end;
procedure TConsoleForm.RefreshImageLists;
var
I, Sel: Integer;
Names: TStringList;
begin
Sel := lstImmagini.ItemIndex;
lstImmagini.Items.BeginUpdate;
try
lstImmagini.Items.Clear;
for I := 0 to FConfig.Images.Count - 1 do
if FConfig.Images.Item(I).Loaded then
lstImmagini.Items.Add(FConfig.Images.Item(I).Name)
else
// Il file manca o non si apre: va detto subito, non a bordo.
lstImmagini.Items.Add(FConfig.Images.Item(I).Name + ' [!]');
finally
lstImmagini.Items.EndUpdate;
end;
if Sel < lstImmagini.Items.Count then
lstImmagini.ItemIndex := Sel;
Names := TStringList.Create;
try
FConfig.Images.FillNames(Names, True);
FUpdatingProps := True;
try
cboImgOff.Items.Assign(Names);
cboImgOn.Items.Assign(Names);
cboSfondo.Items.Assign(Names);
cboSfondo.ItemIndex := cboSfondo.Items.IndexOf(FConfig.Background);
finally
FUpdatingProps := False;
end;
finally
Names.Free;
end;
end;
procedure TConsoleForm.RefreshSoundList;
var
F, Scelto: string;
begin
// Si ricorda la scelta: rileggendo la cartella non deve sparire il file
// gia' assegnato alla spia.
Scelto := cboSuono.Text;
FUpdatingProps := True;
try
cboSuono.Items.BeginUpdate;
try
cboSuono.Items.Clear;
cboSuono.Items.Add('');
if TDirectory.Exists(FConfig.SoundsDir) then
for F in TDirectory.GetFiles(FConfig.SoundsDir, '*.*') do
if MatchStr(LowerCase(ExtractFileExt(F)), ['.mp3', '.wav']) then
cboSuono.Items.Add(ExtractFileName(F));
finally
cboSuono.Items.EndUpdate;
end;
// Un file assegnato ma non piu' nella cartella resta in elenco, con un
// segno: meglio vederlo mancante che vederlo sparire in silenzio.
if (Scelto <> '') and (cboSuono.Items.IndexOf(Scelto) < 0) then
cboSuono.Items.Add(Scelto);
cboSuono.ItemIndex := cboSuono.Items.IndexOf(Scelto);
finally
FUpdatingProps := False;
end;
end;
procedure TConsoleForm.btnSuoniRileggiClick(Sender: TObject);
begin
RefreshSoundList;
if not TDirectory.Exists(FConfig.SoundsDir) then
SetStatusFor(Format('Cartella dei suoni non trovata: %s',
[FConfig.SoundsDir]), False, STATUS_HOLD_MS)
else
SetStatusFor(Format('%d suoni nella cartella %s',
[cboSuono.Items.Count - 1, ExtractFileName(FConfig.SoundsDir)]), True,
STATUS_HOLD_MS);
end;
procedure TConsoleForm.TriggerAlarm(AElement: TPlanciaElement);
var
D: TElementDef;
F: string;
begin
D := AElement.Def;
if (FAlarms = nil) or not D.Alarm or (D.Sound = '') then
Exit;
// Zittito: la spia resta accesa e segnata, ma il cicalino tace.
if AElement.AlarmMuted then
Exit;
F := IncludeTrailingPathDelimiter(FConfig.SoundsDir) + D.Sound;
// In ciclo: finche' l'allarme c'e', il suono ricomincia.
if not FAlarms.Play(F, True) then
// Un allarme che non suona va detto: chi sta guardando altrove si fida
// del cicalino.
SetStatusFor(Format('%s: allarme muto, %s (%s)',
[D.Caption, FAlarms.LastError, D.Sound]), False, STATUS_HOLD_MS);
end;
procedure TConsoleForm.KeepAlarmSounding(AElement: TPlanciaElement);
var
F: string;
begin
if FAlarms = nil then
Exit;
F := IncludeTrailingPathDelimiter(FConfig.SoundsDir) + AElement.Def.Sound;
// In silenzio: se il file manca l'ha gia' detto TriggerAlarm, e ripeterlo
// dieci volte al secondo riempirebbe la barra di stato.
if not FAlarms.IsPlaying(F) then
FAlarms.Play(F, True);
end;
procedure TConsoleForm.StopAlarm(AElement: TPlanciaElement);
var
E: TPlanciaElement;
Suono: string;
begin
Suono := AElement.Def.Sound;
if (FAlarms = nil) or (Suono = '') then
Exit;
// Lo stesso file puo' servire a piu' spie: si tace solo se nessun'altra
// spia accesa lo sta ancora usando, altrimenti si zittirebbe un allarme
// che c'e' ancora.
for E in FElements do
if (E <> AElement) and (E.Def.Kind = ekLamp) and E.Def.Alarm and
E.State and not E.AlarmMuted and SameText(E.Def.Sound, Suono) then
Exit;
FAlarms.Stop(IncludeTrailingPathDelimiter(FConfig.SoundsDir) + Suono);
end;
/// Quanto dura un silenzio, come lo si dice a voce.
function DurataMuta(ASeconds: Integer): string;
begin
if ASeconds >= 3600 then
Result := 'un''ora'
else if ASeconds >= 60 then
Result := Format('%d minuti', [ASeconds div 60])
else
Result := Format('%d secondi', [ASeconds]);
end;
procedure TConsoleForm.ElementMuteRequest(ASender: TPlanciaElement);
var
Secondi: Integer;
begin
Secondi := ASender.PressAlarmMute;
if Secondi > 0 then
begin
// Il suono in corso si ferma subito: aspettare la fine del cicalino
// mentre si preme non avrebbe senso. Solo il suo, pero': gli altri
// allarmi non li ha zittiti nessuno.
StopAlarm(ASender);
SetStatusFor(Format('%s: allarme zittito per %s. Premi di nuovo per ' +
'allungare, ancora per riattivarlo.',
[ASender.Def.Caption, DurataMuta(Secondi)]), True, STATUS_HOLD_MS);
end
else
SetStatusFor(Format('%s: allarme di nuovo attivo.',
[ASender.Def.Caption]), True, STATUS_HOLD_MS);
UpdateMutedAlarms;
end;
procedure TConsoleForm.btnAllarmiClick(Sender: TObject);
var
E: TPlanciaElement;
begin
for E in FElements do
E.ClearAlarmMute;
SetStatusFor('Allarmi sonori di nuovo attivi.', True, STATUS_HOLD_MS);
UpdateMutedAlarms;
end;
procedure TConsoleForm.UpdateMutedAlarms;
var
E: TPlanciaElement;
Zittiti, Minimo: Integer;
Visibile: Boolean;
begin
Zittiti := 0;
Minimo := 0;
for E in FElements do
begin
if E.Def.Kind <> ekLamp then
Continue;
if E.AlarmMuted then
begin
Inc(Zittiti);
if (Minimo = 0) or (E.MuteSecondsLeft < Minimo) then
Minimo := E.MuteSecondsLeft;
end
// Solo quando il silenzio finisce, non a ogni giro: zittire "per un
// minuto" vuol dire che dopo un minuto l'allarme torna a farsi sentire.
else if E.TakeMuteExpired and E.State then
TriggerAlarm(E);
// Finche' l'allarme c'e', il suono continua: se il file e' finito
// ricomincia. Un cicalino che suona una volta sola lo si perde se in
// quel momento si sta guardando altrove.
if (not E.AlarmMuted) and E.State and E.Def.Alarm and
(E.Def.Sound <> '') then
KeepAlarmSounding(E);
end;
Visibile := Zittiti > 0;
if Visibile then
if Zittiti = 1 then
btnAllarmi.Caption := Format('1 allarme zittito (%s) - riattiva',
[DurataMuta(Minimo)])
else
btnAllarmi.Caption := Format('%d allarmi zittiti (%s) - riattiva',
[Zittiti, DurataMuta(Minimo)]);
if btnAllarmi.Visible <> Visibile then
begin
btnAllarmi.Visible := Visibile;
LayoutStatusBar;
end;
end;
procedure TConsoleForm.btnImgAddClick(Sender: TObject);
var
F: string;
Entry: TImageEntry;
begin
if not dlgImmagine.Execute then
Exit;
for F in dlgImmagine.Files do
begin
Entry := FConfig.Images.AddFile(F);
if not Entry.Loaded then
SetStatus(Format('Immagine "%s" non caricata: %s',
[Entry.Name, Entry.Error]), False);
end;
RefreshImageLists;
MarkDirty;
end;
procedure TConsoleForm.btnImgDelClick(Sender: TObject);
var
Nome: string;
D: TElementDef;
Usata: Integer;
begin
if lstImmagini.ItemIndex < 0 then
Exit;
Nome := FConfig.Images.Item(lstImmagini.ItemIndex).Name;
Usata := 0;
for D in FConfig.Elements do
if (D.ImageOff = Nome) or (D.ImageOn = Nome) then
Inc(Usata);
if (Usata > 0) and (MessageDlg(Format(
'L''immagine "%s" e'' usata da %d elementi. Rimuoverla comunque?',
[Nome, Usata]), mtConfirmation, [mbYes, mbNo], 0) <> mrYes) then
Exit;
PushUndo;
for D in FConfig.Elements do
begin
if D.ImageOff = Nome then
D.ImageOff := '';
if D.ImageOn = Nome then
D.ImageOn := '';
end;
if FConfig.Background = Nome then
FConfig.Background := '';
FConfig.Images.Delete(Nome);
RefreshImageLists;
UpdateBackground;
RebuildPanel;
MarkDirty;
end;
procedure TConsoleForm.BackgroundChanged(Sender: TObject);
begin
if FUpdatingProps then
Exit;
PushUndo('sfondo');
FConfig.Background := cboSfondo.Text;
UpdateBackground;
MarkDirty;
end;
/// Posizione di un tipo dentro SWITCHABLE_KINDS, -1 se non e' scambiabile.
function SwitchableIndex(AKind: TElementKind): Integer;
var
I: Integer;
begin
for I := Low(SWITCHABLE_KINDS) to High(SWITCHABLE_KINDS) do
if SWITCHABLE_KINDS[I] = AKind then
Exit(I);
Result := -1;
end;
procedure TConsoleForm.LoadProps;
var
Has, IsGauge, IsLamp, IsDisplay, IsRotary, IsKey: Boolean;
HasChannel, UsesImages, IsPicture: Boolean;
D: TElementDef;
begin
Has := FSelected <> nil;
IsGauge := Has and (FSelected.Def.Kind = ekGauge);
IsLamp := Has and (FSelected.Def.Kind = ekLamp);
IsDisplay := Has and (FSelected.Def.Kind = ekDisplay);
IsRotary := Has and (FSelected.Def.Kind = ekRotary);
HasChannel := Has and ElementHasChannel(FSelected.Def.Kind);
UsesImages := Has and ElementUsesImages(FSelected.Def.Kind);
IsPicture := Has and (FSelected.Def.Kind = ekImage);
// I gruppi occupano lo stesso posto: si mostra solo quello del tipo scelto.
gbGauge.Visible := IsGauge or IsDisplay;
gbGauge.Caption := 'Scala del gauge';
if IsDisplay then
gbGauge.Caption := 'Scala del display';
lblDigits.Visible := IsDisplay;
edtDigits.Visible := IsDisplay;
lblDecimals.Visible := IsDisplay;
edtDecimals.Visible := IsDisplay;
lblWarn.Visible := IsGauge;
edtWarnLo.Visible := IsGauge;
edtWarnHi.Visible := IsGauge;
gbRotary.Visible := IsRotary;
// L'allarme sonoro e' roba da spie: un pulsante lo si sta premendo, non
// c'e' niente da segnalare.
gbSuono.Visible := IsLamp;
if IsRotary then
UpdateRotaryFields(FSelected.Def)
else
begin
lblCanale2.Visible := False;
edtCanale2.Visible := False;
chkMolla.Visible := False;
end;
gbTesto.Visible := Has and (FSelected.Def.Kind = ekLabel);
gbImgElem.Visible := UsesImages;
// Tipo e forma valgono per i comandi a tasto e per la spia, che puo'
// avere lo stesso aspetto di un pulsante (lente tonda, tasto a video).
IsKey := Has and (FSelected.Def.Kind in [ekButton, ekSwitch, ekLamp]);
// Il tipo si cambia solo fra i comandi intercambiabili: per un gauge o una
// scritta la casella non avrebbe niente da offrire.
lblTipo.Visible := IsKey;
cboTipo.Visible := IsKey;
lblShape.Visible := IsKey;
cboShape.Visible := IsKey;
lblOnColor.Visible := IsKey or IsLamp;
edtOnColor.Visible := IsKey or IsLamp;
edtOffColor.Visible := IsKey or IsLamp;
lblCapPos.Visible := UsesImages and not IsPicture;
cboCapPos.Visible := UsesImages and not IsPicture;
// L'immagine decorativa non ha stato, quindi una sola immagine.
lblImgOn.Visible := UsesImages and not IsPicture;
cboImgOn.Visible := UsesImages and not IsPicture;
if IsPicture then
lblImgOff.Caption := 'Immagine'
else
lblImgOff.Caption := 'Immagine a riposo (vuoto = disegno)';
btnElimina.Enabled := Has;
edtCaption.Enabled := Has;
edtFontSize.Enabled := Has;
lblSlave.Enabled := HasChannel;
lblCanale.Enabled := HasChannel;
edtSlave.Enabled := HasChannel;
edtCanale.Enabled := HasChannel;
edtLeft.Enabled := Has;
edtTop.Enabled := Has;
edtWidth.Enabled := Has;
edtHeight.Enabled := Has;
FUpdatingProps := True;
try
if not Has then
begin
lblPropTitle.Caption := 'Nessun elemento selezionato';
edtCaption.Text := '';
edtSlave.Text := '';
edtCanale.Text := '';
edtLeft.Text := '';
edtTop.Text := '';
edtWidth.Text := '';
edtHeight.Text := '';
edtFontSize.Text := '';
cboImgOff.ItemIndex := -1;
cboImgOn.ItemIndex := -1;
Exit;
end;
D := FSelected.Def;
if IsLamp then
lblPropTitle.Caption := ELEMENT_NAMES[D.Kind] + ' (ingresso)'
else
lblPropTitle.Caption := ELEMENT_NAMES[D.Kind];
edtCaption.Text := D.Caption;
if IsKey then
cboTipo.ItemIndex := SwitchableIndex(D.Kind);
if HasChannel then
begin
edtSlave.Text := IntToStr(D.Slave);
edtCanale.Text := IntToStr(D.Channel);
end
else
begin
edtSlave.Text := '';
edtCanale.Text := '';
end;
if D.FontSize > 0 then
edtFontSize.Text := IntToStr(D.FontSize)
else
edtFontSize.Text := '';
if UsesImages then
begin
cboImgOff.ItemIndex := cboImgOff.Items.IndexOf(D.ImageOff);
cboImgOn.ItemIndex := cboImgOn.Items.IndexOf(D.ImageOn);
cboCapPos.ItemIndex := Ord(D.CaptionPos);
cboShape.ItemIndex := Ord(D.Shape);
edtOnColor.Text := ColorToHtml(D.OnColor);
if D.OffColor = clNone then
edtOffColor.Text := ''
else
edtOffColor.Text := ColorToHtml(D.OffColor);
end;
if IsLamp then
begin
chkAllarme.Checked := D.Alarm;
cboSuono.ItemIndex := cboSuono.Items.IndexOf(D.Sound);
if (D.Sound <> '') and (cboSuono.ItemIndex < 0) then
begin
// Il file assegnato non c'e' piu' nella cartella: si mostra lo stesso,
// altrimenti salvando si perderebbe il nome.
cboSuono.Items.Add(D.Sound);
cboSuono.ItemIndex := cboSuono.Items.IndexOf(D.Sound);
end;
end;
if IsDisplay then
begin
edtDigits.Text := IntToStr(D.Digits);
edtDecimals.Text := IntToStr(D.Decimals);
end;
if IsRotary then
begin
cboPositions.ItemIndex := cboPositions.Items.IndexOf(IntToStr(D.Positions));
edtLegend.Text := D.Legend;
if D.Channel2 >= 0 then
edtCanale2.Text := IntToStr(D.Channel2)
else
edtCanale2.Text := '';
chkMolla.Checked := D.Momentary;
end;
if D.Kind = ekLabel then
begin
edtFontName.Text := D.FontName;
edtSpacing.Text := IntToStr(D.Spacing);
cboFrame.ItemIndex := Ord(D.Frame);
edtFrameWidth.Text := IntToStr(D.FrameWidth);
end;
edtLeft.Text := IntToStr(D.Left);
edtTop.Text := IntToStr(D.Top);
edtWidth.Text := IntToStr(D.Width);
edtHeight.Text := IntToStr(D.Height);
if IsGauge or IsDisplay then
begin
edtRawMin.Text := IntToStr(D.RawMin);
edtRawMax.Text := IntToStr(D.RawMax);
edtEngMin.Text := FloatToStr(D.EngMin);
edtEngMax.Text := FloatToStr(D.EngMax);
edtUnits.Text := D.Units;
if D.WarnBelow <= GAUGE_NO_WARN_LO then
edtWarnLo.Text := ''
else
edtWarnLo.Text := FloatToStr(D.WarnBelow);
if D.WarnAbove >= GAUGE_NO_WARN_HI then
edtWarnHi.Text := ''
else
edtWarnHi.Text := FloatToStr(D.WarnAbove);
end;
finally
FUpdatingProps := False;
end;
end;
procedure TConsoleForm.UpdateRotaryFields(ADef: TElementDef);
var
Is3Pos: Boolean;
begin
// La seconda bobina esiste solo a tre posizioni: mostrare la casella su un
// selettore a due farebbe credere che ne comandi due.
Is3Pos := (ADef <> nil) and (ADef.Kind = ekRotary) and (ADef.Positions >= 3);
lblCanale2.Visible := Is3Pos;
edtCanale2.Visible := Is3Pos;
if Is3Pos then
lblCanale2.Caption := Format('Canale lato destro (vuoto = %d)',
[ADef.Channel + 1]);
chkMolla.Visible := (ADef <> nil) and (ADef.Kind = ekRotary);
end;
procedure TConsoleForm.PropChanged(Sender: TObject);
var
D: TElementDef;
function Num(AEdit: TEdit; ADefault: Double): Double;
begin
if Trim(AEdit.Text) = '' then
Exit(ADefault);
Result := StrToFloatDef(AEdit.Text, ADefault);
end;
begin
if FUpdatingProps or (FSelected = nil) then
Exit;
D := FSelected.Def;
// Una cifra alla volta: le battute sullo stesso campo dello stesso elemento
// diventano un passo solo, altrimenti "120" si annullerebbe in tre volte.
if Sender is TComponent then
PushUndo(Format('prop:%s:%p', [TComponent(Sender).Name, Pointer(D)]))
else
PushUndo;
D.Caption := edtCaption.Text;
if ElementHasChannel(D.Kind) then
begin
D.Slave := StrToIntDef(edtSlave.Text, D.Slave);
D.Channel := StrToIntDef(edtCanale.Text, D.Channel);
end;
D.Left := StrToIntDef(edtLeft.Text, D.Left);
D.Top := StrToIntDef(edtTop.Text, D.Top);
D.Width := StrToIntDef(edtWidth.Text, D.Width);
D.Height := StrToIntDef(edtHeight.Text, D.Height);
if D.Width < MIN_ELEMENT_SIZE then
D.Width := MIN_ELEMENT_SIZE;
if D.Height < MIN_ELEMENT_SIZE then
D.Height := MIN_ELEMENT_SIZE;
D.FontSize := StrToIntDef(edtFontSize.Text, 0);
if D.FontSize < 0 then
D.FontSize := 0;
if ElementUsesImages(D.Kind) then
begin
D.ImageOff := cboImgOff.Text;
if D.Kind = ekImage then
D.ImageOn := ''
else
begin
D.ImageOn := cboImgOn.Text;
if cboCapPos.ItemIndex >= 0 then
D.CaptionPos := TCaptionPos(cboCapPos.ItemIndex);
D.OnColor := HtmlToColor(edtOnColor.Text, D.OnColor);
if Trim(edtOffColor.Text) = '' then
D.OffColor := clNone
else
D.OffColor := HtmlToColor(edtOffColor.Text, D.OffColor);
end;
if (D.Kind in [ekButton, ekSwitch, ekLamp]) and (cboShape.ItemIndex >= 0) then
D.Shape := TKeyShape(cboShape.ItemIndex);
end;
if ElementIsAnalog(D.Kind) then
begin
D.RawMin := StrToIntDef(edtRawMin.Text, D.RawMin);
D.RawMax := StrToIntDef(edtRawMax.Text, D.RawMax);
D.EngMin := Num(edtEngMin, D.EngMin);
D.EngMax := Num(edtEngMax, D.EngMax);
D.Units := edtUnits.Text;
end;
if D.Kind = ekGauge then
begin
D.WarnBelow := Num(edtWarnLo, GAUGE_NO_WARN_LO);
D.WarnAbove := Num(edtWarnHi, GAUGE_NO_WARN_HI);
end;
if D.Kind = ekDisplay then
begin
D.Digits := StrToIntDef(edtDigits.Text, D.Digits);
D.Decimals := StrToIntDef(edtDecimals.Text, D.Decimals);
if D.Digits < 1 then
D.Digits := 1;
if D.Decimals < 0 then
D.Decimals := 0;
end;
if D.Kind = ekLamp then
begin
D.Alarm := chkAllarme.Checked;
D.Sound := cboSuono.Text;
end;
if D.Kind = ekRotary then
begin
D.Positions := StrToIntDef(cboPositions.Text, D.Positions);
D.Legend := edtLegend.Text;
// Vuoto = automatico, cioe' la bobina dopo quella di sinistra.
if Trim(edtCanale2.Text) = '' then
D.Channel2 := -1
else
D.Channel2 := Max(0, StrToIntDef(edtCanale2.Text, -1));
D.Momentary := chkMolla.Checked;
// Diventato a molla, riparte dal centro: e' il suo stato a riposo.
if D.Momentary then
FSelected.SyncRotary(0);
// Il numero di posizioni decide se la seconda bobina si vede, e il
// suggerimento del canale automatico segue il canale di sinistra.
// Solo queste due caselle, non LoadProps: riscriverebbe il testo di
// quella in cui si sta scrivendo, spostando il cursore.
UpdateRotaryFields(D);
end;
if D.Kind = ekLabel then
begin
D.FontName := Trim(edtFontName.Text);
D.Spacing := StrToIntDef(edtSpacing.Text, D.Spacing);
if cboFrame.ItemIndex >= 0 then
D.Frame := TFrameKind(cboFrame.ItemIndex);
D.FrameWidth := Max(1, StrToIntDef(edtFrameWidth.Text, D.FrameWidth));
end;
FSelected.Hint := ElementHint(D);
FSelected.ApplyDef;
FPlanDirty := True;
MarkDirty;
end;
procedure TConsoleForm.KindChanged(Sender: TObject);
var
D: TElementDef;
NewKind, OldKind: TElementKind;
begin
if FUpdatingProps or (FSelected = nil) or (cboTipo.ItemIndex < 0) then
Exit;
NewKind := SWITCHABLE_KINDS[cboTipo.ItemIndex];
D := FSelected.Def;
if D.Kind = NewKind then
Exit;
PushUndo;
// L'etichetta di partenza e' il nome del tipo: se l'utente non l'ha ancora
// cambiata, seguirla al nuovo tipo evita un "Pulsante" che e' un interruttore.
if D.Caption = ELEMENT_NAMES[D.Kind] then
D.Caption := ELEMENT_NAMES[NewKind];
OldKind := D.Kind;
D.Kind := NewKind;
// Un interruttore acceso che diventa pulsante resterebbe illuminato per
// sempre: il pulsante non viene riletto dal campo, quindi nessuno lo
// spegnerebbe piu'. Si riparte da spento e ci pensa il polling.
FSelected.SyncState(False);
FSelected.ApplyDef;
// Cambia la funzione Modbus con cui il canale viene letto: gli interruttori
// rileggono le bobine, i pulsanti no, le spie leggono gli ingressi digitali.
// Il piano di polling va rifatto.
FPlanDirty := True;
FSelected.Hint := ElementHint(D);
MarkDirty;
// Da comando a spia il canale cambia significato: non e' piu' una bobina
// da comandare ma un ingresso da leggere. Va detto, perche' il numero resta
// quello di prima e quasi mai e' giusto.
if (NewKind = ekLamp) and (OldKind <> ekLamp) then
SetStatus(Format('%s: ora il canale %d e'' un ingresso digitale da ' +
'leggere, non piu'' una bobina. Verifica slave e canale.',
[D.Caption, D.Channel]), True)
else if (OldKind = ekLamp) and (NewKind <> ekLamp) then
SetStatus(Format('%s: ora il canale %d e'' una bobina da comandare, non ' +
'piu'' un ingresso. Verifica slave e canale.',
[D.Caption, D.Channel]), True);
// Titolo e caselle visibili dipendono dal tipo: ricaricare le proprieta'
// rimette il pannello in pari.
LoadProps;
end;
procedure TConsoleForm.GeneralChanged(Sender: TObject);
begin
if FUpdatingProps then
Exit;
if Sender is TComponent then
PushUndo('generale:' + TComponent(Sender).Name)
else
PushUndo;
FConfig.Port := cboPort.Text;
FConfig.Baud := StrToIntDef(cboBaud.Text, FConfig.Baud);
FConfig.PollMs := StrToIntDef(edtPoll.Text, FConfig.PollMs);
FConfig.Title := edtTitolo.Text;
FConfig.PanelWidth := StrToIntDef(edtPanelW.Text, FConfig.PanelWidth);
FConfig.PanelHeight := StrToIntDef(edtPanelH.Text, FConfig.PanelHeight);
if FConfig.PanelWidth < 100 then
FConfig.PanelWidth := 100;
if FConfig.PanelHeight < 100 then
FConfig.PanelHeight := 100;
ResizeCanvas;
MarkDirty;
end;
procedure TConsoleForm.DeleteSelected;
var
E: TPlanciaElement;
D: TElementDef;
begin
if (FMode <> amConfig) or (FSelected = nil) then
Exit;
PushUndo;
E := FSelected;
D := E.Def;
SelectElement(nil);
FElements.Remove(E);
E.Free;
// La lista possiede le definizioni: Remove la distrugge.
FConfig.Elements.Remove(D);
FPlanDirty := True;
MarkDirty;
SetStatus('Elemento eliminato.', True);
end;
procedure TConsoleForm.btnEliminaClick(Sender: TObject);
begin
DeleteSelected;
end;
procedure TConsoleForm.FormKeyDown(Sender: TObject; var Key: Word;
Shift: TShiftState);
var
InText: Boolean;
DX, DY: Integer;
begin
if FMode <> amConfig then
Exit;
// Annulla e ripeti valgono ovunque, anche con il fuoco in una casella delle
// proprieta': ogni battuta li' e' gia' una modifica nello storico, e l'annulla
// del testo della casella andrebbe per conto suo.
if (ssCtrl in Shift) and not (ssAlt in Shift) then
if (Key = Ord('Z')) and not (ssShift in Shift) then
begin
StepHistory(FUndo, FRedo);
Key := 0;
Exit;
end
else if (Key = Ord('Y')) or ((Key = Ord('Z')) and (ssShift in Shift)) then
begin
StepHistory(FRedo, FUndo);
Key := 0;
Exit;
end;
// Nelle caselle frecce e Canc lavorano sul testo, non sulla plancia.
InText := (ActiveControl is TCustomEdit) or
(ActiveControl is TCustomComboBox) or (ActiveControl is TCustomListBox);
if InText then
Exit;
if Key = VK_DELETE then
begin
DeleteSelected;
Key := 0;
Exit;
end;
if (FSelected = nil) or not (Key in [VK_LEFT, VK_RIGHT, VK_UP, VK_DOWN]) then
Exit;
DX := 0;
DY := 0;
case Key of
VK_LEFT: DX := -1;
VK_RIGHT: DX := 1;
VK_UP: DY := -1;
VK_DOWN: DY := 1;
end;
// Ctrl+freccia sposta di un pixel, Maiusc+freccia allarga o stringe:
// destra e giu' ingrandiscono, sinistra e su rimpiccioliscono.
if (ssCtrl in Shift) and not (ssShift in Shift) then
begin
NudgeSelected(DX, DY, False);
Key := 0;
end
else if (ssShift in Shift) and not (ssCtrl in Shift) then
begin
NudgeSelected(DX, DY, True);
Key := 0;
end;
end;
procedure TConsoleForm.FormKeyPress(Sender: TObject; var Key: Char);
begin
// Ctrl+Z e Ctrl+Y arrivano anche come carattere: senza fermarli qui la
// casella col fuoco farebbe il proprio annulla del testo, e quello
// riscriverebbe la proprieta' appena riportata indietro.
if (FMode = amConfig) and ((Key = #26) or (Key = #25)) then
Key := #0;
end;
procedure TConsoleForm.NudgeSelected(ADX, ADY: Integer; AResize: Boolean);
var
D: TElementDef;
L, T, W, H: Integer;
begin
if FSelected = nil then
Exit;
D := FSelected.Def;
L := D.Left;
T := D.Top;
W := D.Width;
H := D.Height;
// Un pixel esatto, senza griglia: e' proprio quello che la griglia non
// permette di fare col mouse. Si resta dentro il pannello.
if AResize then
begin
W := EnsureRange(W + ADX, MIN_ELEMENT_SIZE,
Max(MIN_ELEMENT_SIZE, FConfig.PanelWidth - L));
H := EnsureRange(H + ADY, MIN_ELEMENT_SIZE,
Max(MIN_ELEMENT_SIZE, FConfig.PanelHeight - T));
end
else
begin
L := EnsureRange(L + ADX, 0, Max(0, FConfig.PanelWidth - W));
T := EnsureRange(T + ADY, 0, Max(0, FConfig.PanelHeight - H));
end;
if (L = D.Left) and (T = D.Top) and (W = D.Width) and (H = D.Height) then
Exit;
// Tenendo premuto il tasto si fa un passo di annulla solo.
if AResize then
PushUndo(Format('misura:%p', [Pointer(D)]))
else
PushUndo(Format('sposta:%p', [Pointer(D)]));
D.Left := L;
D.Top := T;
D.Width := W;
D.Height := H;
FSelected.ApplyDef;
ElementGeometryChanged(FSelected);
end;
{ annulla e ripeti }
function TConsoleForm.SelectedIndex: Integer;
begin
if FSelected = nil then
Result := -1
else
Result := FConfig.Elements.IndexOf(FSelected.Def);
end;
procedure TConsoleForm.PushUndo(const AKey: string);
var
Snap: TPlanciaSnapshot;
Now: UInt64;
begin
if FMode <> amConfig then
Exit;
Now := GetTickCount64;
if (AKey <> '') and (AKey = FUndoKey) and (FUndo.Count > 0) and
(Now - FUndoTime < UNDO_MERGE_MS) then
begin
// Stessa modifica che continua: la foto di partenza c'e' gia'.
FUndoTime := Now;
Exit;
end;
Snap := FConfig.TakeSnapshot;
Snap.Selected := SelectedIndex;
FUndo.Add(Snap);
while FUndo.Count > UNDO_LIMIT do
FUndo.Delete(0);
// Una modifica nuova chiude la strada a quelle annullate.
FRedo.Clear;
FUndoKey := AKey;
FUndoTime := Now;
UpdateUndoButtons;
end;
procedure TConsoleForm.ClearUndo;
begin
FUndo.Clear;
FRedo.Clear;
FUndoKey := '';
UpdateUndoButtons;
end;
procedure TConsoleForm.UpdateUndoButtons;
begin
btnAnnulla.Enabled := FUndo.Count > 0;
btnRipeti.Enabled := FRedo.Count > 0;
end;
procedure TConsoleForm.StepHistory(AFrom, ATo: TObjectList<TPlanciaSnapshot>);
var
Snap, Current: TPlanciaSnapshot;
Sel: Integer;
begin
if (FMode <> amConfig) or (AFrom.Count = 0) then
Exit;
// A trascinamento in corso il pannello non si ricostruisce: si
// distruggerebbe l'elemento che ha il mouse in mano.
if GetKeyState(VK_LBUTTON) < 0 then
Exit;
Current := FConfig.TakeSnapshot;
Current.Selected := SelectedIndex;
ATo.Add(Current);
Snap := AFrom.Extract(AFrom.Last);
try
FConfig.RestoreSnapshot(Snap);
Sel := Snap.Selected;
finally
Snap.Free;
end;
// La prossima modifica e' comunque un passo nuovo.
FUndoKey := '';
LoadGeneral;
RebuildPanel;
if (Sel >= 0) and (Sel < FElements.Count) then
SelectElement(FElements[Sel]);
FPlanDirty := True;
MarkDirty;
UpdateUndoButtons;
end;
procedure TConsoleForm.btnAnnullaClick(Sender: TObject);
begin
StepHistory(FUndo, FRedo);
end;
procedure TConsoleForm.btnRipetiClick(Sender: TObject);
begin
StepHistory(FRedo, FUndo);
end;
{ file }
procedure TConsoleForm.MarkDirty;
begin
FDirty := True;
UpdateCaptions;
end;
procedure TConsoleForm.UpdateCaptions;
var
Star: string;
begin
if FMode = amAboard then
begin
Caption := FConfig.Title;
Exit;
end;
if FDirty then
Star := ' *'
else
Star := '';
Caption := Format('Plancia - Configurazione - %s%s',
[ExtractFileName(FFileName), Star]);
lblFile.Caption := FFileName + Star;
end;
procedure TConsoleForm.SetStatus(const AMsg: string; AOk: Boolean);
var
Conn: string;
begin
if AOk then
shpLed.Brush.Color := clLime
else
shpLed.Brush.Color := clRed;
if (FMode = amAboard) and (FConfig <> nil) then
Conn := Format('%s %d - ', [FConfig.Port, FConfig.Baud])
else
Conn := '';
lblStatus.Caption := Conn + AMsg;
end;
procedure TConsoleForm.SetStatusFor(const AMsg: string; AOk: Boolean;
AHoldMs: Integer);
begin
SetStatus(AMsg, AOk);
FStatusUntil := GetTickCount64 + UInt64(AHoldMs);
end;
function TConsoleForm.ConfirmDiscard: Boolean;
begin
Result := True;
if (FMode <> amConfig) or not FDirty then
Exit;
case MessageDlg('La plancia e'' stata modificata. Salvare le modifiche?',
mtConfirmation, [mbYes, mbNo, mbCancel], 0) of
mrYes:
begin
btnSalvaClick(nil);
Result := not FDirty;
end;
mrNo:
Result := True;
else
Result := False;
end;
end;
procedure TConsoleForm.DoLoad(const AFileName: string);
begin
try
FConfig.LoadFromFile(AFileName);
FFileName := AFileName;
FDirty := False;
FLoadFailed := False;
except
on E: Exception do
begin
// La lettura fallita lascia la configurazione vuota: il file va segnato
// come non caricato, altrimenti chiudendo ci si salverebbe sopra un
// pannello vuoto, cancellando quello che c'era.
FFileName := AFileName;
FLoadFailed := True;
FDirty := False;
RebuildPanel;
SetStatusFor(Format('%s non si apre: %s',
[ExtractFileName(AFileName), E.Message]), False, 60000);
Exit;
end;
end;
// Anche solo aperta, questa diventa la plancia da riaprire la prossima volta.
SaveLastFile(FFileName);
// Lo storico appartiene al file: annullare dopo un Apri riporterebbe la
// plancia precedente dentro quella nuova.
ClearUndo;
LoadGeneral;
RebuildPanel;
SetStatus(Format('Caricati %d elementi da %s',
[FConfig.Elements.Count, ExtractFileName(FFileName)]), True);
end;
procedure TConsoleForm.LoadGeneral;
begin
FUpdatingProps := True;
try
if cboPort.Items.IndexOf(FConfig.Port) < 0 then
cboPort.Items.Add(FConfig.Port);
cboPort.ItemIndex := cboPort.Items.IndexOf(FConfig.Port);
if cboBaud.Items.IndexOf(IntToStr(FConfig.Baud)) < 0 then
cboBaud.Items.Add(IntToStr(FConfig.Baud));
cboBaud.ItemIndex := cboBaud.Items.IndexOf(IntToStr(FConfig.Baud));
edtPoll.Text := IntToStr(FConfig.PollMs);
edtTitolo.Text := FConfig.Title;
edtPanelW.Text := IntToStr(FConfig.PanelWidth);
edtPanelH.Text := IntToStr(FConfig.PanelHeight);
finally
FUpdatingProps := False;
end;
end;
procedure TConsoleForm.DoSave(const AFileName: string);
begin
try
FConfig.SaveToFile(AFileName);
FFileName := AFileName;
FDirty := False;
// Ora il file c'e' e si apre: e' tornato un file su cui salvare.
FLoadFailed := False;
SaveLastFile(FFileName);
UpdateCaptions;
SetStatus('Salvato in ' + FFileName, True);
except
on E: Exception do
SetStatus('Errore di salvataggio: ' + E.Message, False);
end;
end;
procedure TConsoleForm.btnNuovoClick(Sender: TObject);
begin
if not ConfirmDiscard then
Exit;
FConfig.Clear;
FFileName := DefaultConfigFile;
FDirty := False;
ClearUndo;
LoadGeneral;
RebuildPanel;
SetStatus('Nuova plancia.', True);
end;
procedure TConsoleForm.btnApriClick(Sender: TObject);
begin
if not ConfirmDiscard then
Exit;
dlgApri.FileName := FFileName;
if dlgApri.Execute then
DoLoad(dlgApri.FileName);
end;
procedure TConsoleForm.btnSalvaClick(Sender: TObject);
begin
DoSave(FFileName);
end;
procedure TConsoleForm.btnSalvaComeClick(Sender: TObject);
begin
dlgSalva.FileName := FFileName;
if dlgSalva.Execute then
DoSave(dlgSalva.FileName);
end;
procedure TConsoleForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
CanClose := True;
if (FMode <> amConfig) or not FDirty then
Exit;
// Il file non si era aperto: salvarci sopra il pannello vuoto lo
// cancellerebbe. Meglio chiedere dove metterlo.
if FLoadFailed then
begin
if MessageDlg(Format('%s non era stato letto, e salvarci sopra ora ' +
'cancellerebbe quello che contiene.' + sLineBreak +
'Salvare il lavoro in un altro file?', [ExtractFileName(FFileName)]),
mtWarning, [mbYes, mbNo], 0) = mrYes then
begin
btnSalvaComeClick(nil);
CanClose := not FDirty;
end;
Exit;
end;
// Chiudendo la configurazione il lavoro si salva da solo, senza chiedere:
// e' il file su cui si stava lavorando, ed e' quello che verra' riaperto.
DoSave(FFileName);
// Salvataggio fallito (disco pieno, file di sola lettura): qui la domanda
// serve, altrimenti si chiuderebbe perdendo tutto in silenzio.
if FDirty then
CanClose := MessageDlg(Format('Non riesco a salvare %s.' + sLineBreak +
'Chiudere comunque, perdendo le modifiche?', [FFileName]),
mtWarning, [mbYes, mbNo], 0) = mrYes;
end;
{ Modbus }
procedure TConsoleForm.ConnectModbus;
begin
// La porta la apre il thread del bus, che non fa aspettare la finestra:
// se la COM non c'e' il tentativo, e il suo timeout, avvengono la'.
FWorker := TModbusWorker.Create(FConfig.Port, FConfig.Baud,
FConfig.TimeoutMs, FConfig.PollMs);
FPlanDirty := True;
// Entrando in plancia la prima lettura delle bobine non serve solo a
// controllare: serve a mettere comandi e selettori nella posizione in cui
// sono i rele' veri, pulsanti compresi. Da li' in avanti i pulsanti non si
// rileggono piu', perche' il loro stato lo decide il mouse.
FAligning := True;
FAlignReported := False;
// Le divisioni imparate valgono per il modulo di prima: si riparte da capo.
SetLength(FCoilReqs, 0);
// Il timer ora non parla col bus: ritira quello che il thread ha letto.
// Piu' fitto del polling, cosi' le letture arrivano a video appena pronte.
tmrPoll.Interval := Max(50, FConfig.PollMs div 4);
tmrPoll.Enabled := True;
SetStatus('Apertura porta, lettura dei canali uno per uno...', True);
end;
procedure TConsoleForm.DisconnectModbus;
begin
tmrPoll.Enabled := False;
if FWorker <> nil then
begin
// Stop sveglia il thread; l'attesa dura al massimo la richiesta in corso.
FWorker.Stop;
FreeAndNil(FWorker);
end;
end;
function TConsoleForm.BusReady: Boolean;
begin
Result := (FWorker <> nil) and FWorker.IsBusConnected;
if not Result then
SetStatus('Comando ignorato: non connesso.', False);
end;
/// Chiave di una bobina nel registro delle scritture appena fatte.
function CoilKey(ASlave, AChannel: Integer): Int64;
begin
Result := Int64(ASlave) * 1000000 + AChannel;
end;
procedure TConsoleForm.NoteCoilWrite(ASlave, AChannel: Integer);
begin
// Si segna quando: una risposta partita prima di questo momento parla di
// una bobina che nel frattempo e' stata comandata, e non va creduta.
FCoilWrites.AddOrSetValue(CoilKey(ASlave, AChannel), GetTickCount64);
end;
function TConsoleForm.ReadingStale(ASlave, AChannel: Integer;
ASent: UInt64): Boolean;
var
Scritto: UInt64;
begin
Result := FCoilWrites.TryGetValue(CoilKey(ASlave, AChannel), Scritto) and
(Scritto >= ASent);
end;
procedure TConsoleForm.MirrorCoil(ASlave, AChannel: Integer; AOn: Boolean;
AExcept: TPlanciaElement);
var
E: TPlanciaElement;
begin
// Sulla stessa bobina possono stare piu' comandi: lo stesso rele' comandato
// da due punti della plancia, o un pulsante e un interruttore. Premendone
// uno gli altri si muovono subito, senza aspettare la rilettura: mezzo
// secondo di disaccordo fra due comandi che sono la stessa cosa si vede.
// Le spie no: leggono gli ingressi, che sono un altro spazio di indirizzi.
for E in FElements do
begin
if (E = AExcept) or (E.Def.Slave <> ASlave) then
Continue;
case E.Def.Kind of
ekButton, ekSwitch:
if E.Def.Channel = AChannel then
E.SyncState(AOn);
ekRotary:
if E.Def.CoilCount > 1 then
begin
// Tre posizioni: la bobina dice da che lato, l'altra resta com'e'.
if E.Def.Channel = AChannel then
begin
if AOn then
E.SyncRotary(-1)
else if E.Rotary < 0 then
E.SyncRotary(0);
end
else if E.Def.RightChannel = AChannel then
begin
if AOn then
E.SyncRotary(1)
else if E.Rotary > 0 then
E.SyncRotary(0);
end;
end
else if E.Def.Channel = AChannel then
E.SyncRotary(Ord(AOn));
end;
end;
end;
procedure TConsoleForm.ElementCommand(ASender: TPlanciaElement; AOn: Boolean;
var AAccepted: Boolean);
begin
if not ASender.Def.Configured then
begin
AAccepted := False;
SetStatusFor(Format('%s: nessun canale configurato, non comanda niente.',
[ASender.Def.Caption]), False, STATUS_HOLD_MS);
Exit;
end;
if not BusReady then
begin
AAccepted := False;
Exit;
end;
// Il comando va in coda e il click torna subito. L'elemento si accende
// fidandosi: se la scrittura non andra' a segno lo dira' la barra di stato,
// e la rilettura delle bobine lo rimettera' a posto entro un ciclo.
FWorker.EnqueueWrite(ASender.Def.Slave, ASender.Def.Channel, AOn,
ASender.Def.Caption);
NoteCoilWrite(ASender.Def.Slave, ASender.Def.Channel);
MirrorCoil(ASender.Def.Slave, ASender.Def.Channel, AOn, ASender);
AAccepted := True;
SetStatusFor(Format('%s -> %s', [ASender.Def.Caption, BoolToStr(AOn, True)]),
True, STATUS_HOLD_MS);
end;
procedure TConsoleForm.ElementRotary(ASender: TPlanciaElement; APos: Integer;
var AAccepted: Boolean);
var
D: TElementDef;
begin
if not ASender.Def.Configured then
begin
AAccepted := False;
SetStatusFor(Format('%s: nessun canale configurato, non comanda niente.',
[ASender.Def.Caption]), False, STATUS_HOLD_MS);
Exit;
end;
if not BusReady then
begin
AAccepted := False;
Exit;
end;
D := ASender.Def;
if D.CoilCount > 1 then
begin
// Tre posizioni, due bobine: la prima e' il lato sinistro, la seconda il
// destro; al centro sono entrambe aperte. Non devono mai essere chiuse
// insieme, quindi si apre SEMPRE prima quella del lato opposto e solo
// dopo si chiude l'altra. La coda mantiene l'ordine, quindi sul filo le
// due scritture partono in questa sequenza.
if APos < 0 then
begin
FWorker.EnqueueWrite(D.Slave, D.RightChannel, False, D.Caption);
FWorker.EnqueueWrite(D.Slave, D.Channel, True, D.Caption);
end
else
begin
FWorker.EnqueueWrite(D.Slave, D.Channel, False, D.Caption);
FWorker.EnqueueWrite(D.Slave, D.RightChannel, APos > 0, D.Caption);
end;
NoteCoilWrite(D.Slave, D.Channel);
NoteCoilWrite(D.Slave, D.RightChannel);
MirrorCoil(D.Slave, D.Channel, APos < 0, ASender);
MirrorCoil(D.Slave, D.RightChannel, APos > 0, ASender);
end
else
begin
FWorker.EnqueueWrite(D.Slave, D.Channel, APos > 0, D.Caption);
NoteCoilWrite(D.Slave, D.Channel);
MirrorCoil(D.Slave, D.Channel, APos > 0, ASender);
end;
AAccepted := True;
SetStatusFor(Format('%s -> posizione %d', [D.Caption, APos]), True,
STATUS_HOLD_MS);
end;
{ polling }
procedure TConsoleForm.AddToPlan(APlan: TList<TPollGroup>;
ASlave, AChannel: Integer);
var
I: Integer;
G: TPollGroup;
begin
for I := 0 to APlan.Count - 1 do
begin
G := APlan[I];
if G.Slave <> ASlave then
Continue;
if AChannel < G.First then
begin
G.Count := G.Count + (G.First - AChannel);
G.First := AChannel;
end
else if AChannel >= G.First + G.Count then
G.Count := AChannel - G.First + 1;
APlan[I] := G;
Exit;
end;
G.Slave := ASlave;
G.First := AChannel;
G.Count := 1;
APlan.Add(G);
end;
procedure TConsoleForm.BuildPollPlan;
var
D: TElementDef;
G: TPollGroup;
Plan: TArray<TPollRequest>;
I: Integer;
procedure Add(AKind: TRequestKind; const AGroup: TPollGroup; AMax: Integer);
var
Req: TPollRequest;
begin
if AGroup.Count > AMax then
begin
// Un solo elemento con un canale altissimo allargherebbe la richiesta
// oltre quello che una trama Modbus puo' portare.
SetStatusFor(Format('Slave %d: intervallo troppo ampio (%d canali), ' +
'richiesta saltata.', [AGroup.Slave, AGroup.Count]), False,
STATUS_HOLD_MS);
Exit;
end;
Req.Kind := AKind;
Req.Slave := AGroup.Slave;
Req.First := AGroup.First;
Req.Count := AGroup.Count;
Plan := Plan + [Req];
end;
/// Un canale di bobina da leggere da solo, senza doppioni: due comandi
/// sulla stessa bobina si leggono una volta.
procedure AddCoilChannel(ASlave, AChannel: Integer);
var
I: Integer;
C: TCoilChannel;
begin
for I := 0 to High(FCoilChannels) do
if (FCoilChannels[I].Slave = ASlave) and
(FCoilChannels[I].Channel = AChannel) then
Exit;
C.Slave := ASlave;
C.Channel := AChannel;
FCoilChannels := FCoilChannels + [C];
end;
begin
FLampPlan.Clear;
FGaugePlan.Clear;
FSwitchPlan.Clear;
SetLength(FCoilChannels, 0);
for D in FConfig.Elements do
begin
// Un elemento senza modulo non entra nel piano: non si legge e non si
// comanda, sta sulla plancia come disegno finche' non gli si da' un
// canale.
if not D.Configured then
Continue;
case D.Kind of
ekLamp: AddToPlan(FLampPlan, D.Slave, D.Channel);
ekGauge, ekDisplay: AddToPlan(FGaugePlan, D.Slave, D.Channel);
// Anche i pulsanti: il loro canale serve nella prima lettura, per
// mettere la plancia nella posizione in cui sono le bobine vere.
// Contigui agli altri non allargano nemmeno la richiesta.
ekSwitch, ekButton:
begin
AddToPlan(FSwitchPlan, D.Slave, D.Channel);
AddCoilChannel(D.Slave, D.Channel);
end;
ekRotary:
begin
AddToPlan(FSwitchPlan, D.Slave, D.Channel);
AddCoilChannel(D.Slave, D.Channel);
if D.CoilCount > 1 then
begin
AddToPlan(FSwitchPlan, D.Slave, D.RightChannel);
AddCoilChannel(D.Slave, D.RightChannel);
end;
end;
end;
end;
// Il piano passa al thread del bus, che dal giro dopo legge questo.
SetLength(Plan, 0);
for G in FLampPlan do
Add(rqInputs, G, MAX_BITS_PER_READ);
for G in FGaugePlan do
Add(rqRegisters, G, MAX_REGISTERS_PER_READ);
if FAligning then
// All'ingresso in plancia le bobine si leggono UNA PER UNA. Un blocco che
// arriva oltre l'ultimo canale del modulo fallisce tutto, e con lui
// fallirebbe l'allineamento di tutti i comandi, compresi quelli su canali
// che esistono: cosi' invece fallisce solo la richiesta del canale che non
// c'e'. Costa un giro piu' lento, ma si fa una volta sola.
for I := 0 to High(FCoilChannels) do
begin
G.Slave := FCoilChannels[I].Slave;
G.First := FCoilChannels[I].Channel;
G.Count := 1;
Add(rqCoils, G, MAX_BITS_PER_READ);
end
else
begin
// A regime le bobine si leggono raggruppate, una richiesta per slave.
// Se una richiesta fallisce viene spezzata a meta' (vedi SplitCoilRequest)
// e qui si usa la divisione gia' imparata: un modulo da 32 canali
// interrogato fino al 38 continua cosi' a dare i suoi primi 32, invece di
// far fallire tutto il blocco.
if Length(FCoilReqs) = 0 then
for G in FSwitchPlan do
Add(rqCoils, G, MAX_BITS_PER_READ)
else
for I := 0 to High(FCoilReqs) do
Plan := Plan + [FCoilReqs[I]];
end;
// Le richieste delle bobine si ricordano: e' su quelle che si impara come
// il modulo risponde.
if not FAligning then
begin
SetLength(FCoilReqs, 0);
for I := 0 to High(Plan) do
if Plan[I].Kind = rqCoils then
FCoilReqs := FCoilReqs + [Plan[I]];
end;
if FWorker <> nil then
FWorker.SetPlan(Plan);
FPlanDirty := False;
end;
procedure TConsoleForm.SplitCoilRequest(const ARequest: TPollRequest);
var
I, Meta: Integer;
A, B: TPollRequest;
begin
// Una richiesta di un canale solo non si puo' dividere: quel canale non c'e'
// o non risponde, e va lasciato fallire da solo.
if (ARequest.Count < 2) or FAligning then
Exit;
for I := 0 to High(FCoilReqs) do
if (FCoilReqs[I].Kind = rqCoils) and
(FCoilReqs[I].Slave = ARequest.Slave) and
(FCoilReqs[I].First = ARequest.First) and
(FCoilReqs[I].Count = ARequest.Count) then
begin
Meta := ARequest.Count div 2;
A := ARequest;
A.Count := Meta;
B := ARequest;
B.First := ARequest.First + Meta;
B.Count := ARequest.Count - Meta;
FCoilReqs[I] := A;
FCoilReqs := FCoilReqs + [B];
FPlanDirty := True;
SetStatusFor(Format('Slave %d: la lettura delle bobine %d-%d non ' +
'riesce, la divido in %d-%d e %d-%d.',
[ARequest.Slave, ARequest.First, ARequest.First + ARequest.Count - 1,
A.First, A.First + A.Count - 1, B.First, B.First + B.Count - 1]),
False, STATUS_HOLD_MS);
Exit;
end;
end;
procedure TConsoleForm.InvalidateReadings;
var
E: TPlanciaElement;
begin
for E in FElements do
if E.Def.Kind in [ekLamp, ekGauge, ekDisplay] then
E.SetInvalid;
end;
procedure TConsoleForm.NotePollError(const AWhat, AError: string);
begin
// Il primo errore del ciclo e' quello che si mostra, con il conto di quanti
// altri ce n'erano: la barra di stato ha una riga, e una plancia con tre
// moduli assenti non deve farla lampeggiare fra tre messaggi diversi.
Inc(FPollErrors);
if FPollError = '' then
FPollError := Format('%s: %s', [AWhat, AError]);
end;
procedure TConsoleForm.InvalidateGroup(AKinds: TElementKinds;
const AGroup: TPollGroup);
var
E: TPlanciaElement;
Idx: Integer;
begin
// Solo gli elementi che quella richiesta doveva leggere: gli altri stanno
// su moduli che rispondono, e non c'e' motivo di spegnerli.
for E in FElements do
if (E.Def.Kind in AKinds) and (E.Def.Slave = AGroup.Slave) then
begin
Idx := E.Def.Channel - AGroup.First;
if (Idx >= 0) and (Idx < AGroup.Count) then
E.SetInvalid;
end;
end;
/// Come si chiama sul bus quello che una richiesta va a leggere: serve nei
/// messaggi, perche' "lettura fallita" senza dire cosa non aiuta nessuno.
function RequestName(const ARequest: TPollRequest): string;
const
NAMES: array[TRequestKind] of string =
('bobine %d-%d (FC01)', 'ingressi %d-%d (FC02)', 'registri %d-%d (FC03)');
begin
Result := Format('slave %d, ' + NAMES[ARequest.Kind],
[ARequest.Slave, ARequest.First, ARequest.First + ARequest.Count - 1]);
end;
procedure TConsoleForm.ApplyReading(const AReading: TPollReading);
var
E: TPlanciaElement;
G: TPollGroup;
Idx, Idx2: Integer;
WasOn: Boolean;
begin
G.Slave := AReading.Request.Slave;
G.First := AReading.Request.First;
G.Count := AReading.Request.Count;
if not AReading.Ok then
begin
NotePollError(RequestName(AReading.Request), AReading.Error);
// Un blocco di bobine che non risponde viene spezzato: forse una parte
// dei canali esiste e va letta lo stesso.
if AReading.Request.Kind = rqCoils then
SplitCoilRequest(AReading.Request);
// Gli strumenti passano a "dato non disponibile": un numero vecchio
// spacciato per buono e' peggio di nessun numero.
//
// Le spie no: tengono l'ultimo stato letto. Spegnerle a ogni lettura
// fallita faceva sparire un allarme che c'e' ancora, e al ritorno della
// lettura buona il fronte di salita faceva ripartire il cicalino: la spia
// lampeggiava e il suono andava a singhiozzo. Che la lettura non sia
// riuscita lo dice la barra di stato.
if AReading.Request.Kind = rqRegisters then
InvalidateGroup([ekGauge, ekDisplay], G);
Exit;
end;
for E in FElements do
begin
if E.Def.Slave <> G.Slave then
Continue;
Idx := E.Def.Channel - G.First;
case AReading.Request.Kind of
rqInputs:
if (E.Def.Kind = ekLamp) and (Idx >= 0) and
(Idx < Length(AReading.Bits)) then
begin
// L'allarme suona sul fronte: quando la spia passa da spenta ad
// accesa, non a ogni ciclo mentre resta accesa.
WasOn := E.State;
E.SyncState(AReading.Bits[Idx]);
if E.State and not WasOn then
TriggerAlarm(E)
else if WasOn and not E.State then
begin
// Allarme rientrato: il suono si ferma subito, senza aspettare
// la fine del file, e il silenzio non serve piu': la prossima
// volta si ricomincia da un minuto.
StopAlarm(E);
E.ClearAlarmMute;
end;
end;
rqRegisters:
if ElementIsAnalog(E.Def.Kind) and (Idx >= 0) and
(Idx < Length(AReading.Regs)) then
E.SetAnalogValue(E.Def.RawToEng(AReading.Regs[Idx]));
rqCoils:
begin
if (Idx < 0) or (Idx >= Length(AReading.Bits)) then
Continue;
// Risposta piu' vecchia del comando appena dato: parla di com'era
// la bobina prima, e crederle farebbe tornare indietro il comando
// per un giro, con il lampeggio che si vede premendo.
if ReadingStale(E.Def.Slave, E.Def.Channel, AReading.Sent) or
((E.Def.Kind = ekRotary) and (E.Def.CoilCount > 1) and
ReadingStale(E.Def.Slave, E.Def.RightChannel, AReading.Sent)) then
Continue;
case E.Def.Kind of
ekSwitch:
E.SyncState(AReading.Bits[Idx]);
ekButton:
// Solo all'ingresso in plancia: un pulsante momentaneo con la
// bobina chiusa va mostrato chiuso, ma dopo il suo stato
// dipende dal mouse e rileggerlo lo farebbe lampeggiare.
if FAligning then
E.SyncState(AReading.Bits[Idx]);
ekRotary:
if E.Def.CoilCount > 1 then
begin
// Serve anche la bobina di destra, che non e' detto sia
// quella subito dopo: puo' essere indicata a parte.
Idx2 := E.Def.RightChannel - G.First;
if (Idx2 < 0) or (Idx2 >= Length(AReading.Bits)) then
Continue;
if AReading.Bits[Idx] then
E.SyncRotary(-1)
else if AReading.Bits[Idx2] then
E.SyncRotary(1)
else
E.SyncRotary(0);
end
else
E.SyncRotary(Ord(AReading.Bits[Idx]));
end;
end;
end;
end;
end;
procedure TConsoleForm.FinishAligning(const AReadings: TArray<TPollReading>);
var
R: TPollReading;
E: TPlanciaElement;
Letto: Boolean;
Chiusi, Muti: Integer;
begin
if not FAligning then
Exit;
// L'allineamento vale solo se le bobine si sono davvero lette: se il modulo
// non ha risposto si riprova al giro dopo, invece di dare per aperto
// quello che non si sa.
Letto := False;
Muti := 0;
for R in AReadings do
if R.Request.Kind = rqCoils then
if R.Ok then
Letto := True
else
Inc(Muti);
if not Letto then
Exit;
FAligning := False;
// Letti i canali uno per uno, si torna alle richieste raggruppate: una sola
// richiesta per slave, che e' quello che tiene leggero il polling.
FPlanDirty := True;
if FAlignReported then
Exit;
FAlignReported := True;
Chiusi := 0;
for E in FElements do
case E.Def.Kind of
ekButton, ekSwitch:
if E.State then
Inc(Chiusi);
ekRotary:
if E.Rotary <> 0 then
Inc(Chiusi);
end;
if Muti > 0 then
// I canali che il modulo non ha (o che non risponde) vanno detti: sono
// comandi che a video restano aperti senza che nessuno lo sappia.
SetStatusFor(Format('Allineato ai rele'': %d comandi chiusi, %d canali ' +
'senza risposta su %d letti.',
[Chiusi, Muti, Length(FCoilChannels)]), False, STATUS_HOLD_MS)
else if Chiusi = 0 then
SetStatusFor(Format('Allineato ai rele'': %d canali letti, tutti i ' +
'comandi aperti.', [Length(FCoilChannels)]), True, STATUS_HOLD_MS)
else if Chiusi = 1 then
SetStatusFor(Format('Allineato ai rele'': %d canali letti, 1 comando ' +
'risulta chiuso.', [Length(FCoilChannels)]), True, STATUS_HOLD_MS)
else
SetStatusFor(Format('Allineato ai rele'': %d canali letti, %d comandi ' +
'risultano chiusi.', [Length(FCoilChannels), Chiusi]), True,
STATUS_HOLD_MS);
end;
procedure TConsoleForm.tmrPollTimer(Sender: TObject);
var
Readings: TArray<TPollReading>;
Outcomes: TArray<TWriteOutcome>;
R: TPollReading;
O: TWriteOutcome;
Msg: string;
Ok: Boolean;
begin
// Questo timer non parla col bus: ritira quello che il thread ha letto e
// scritto. Qualunque timeout e' costato tempo al thread, non a questa
// finestra.
if FWorker = nil then
Exit;
if FPlanDirty then
BuildPollPlan;
// Esiti dei comandi: una scrittura fallita e' l'unico modo di sapere che un
// rele' non ha sentito, e resta a video qualche secondo.
Outcomes := FWorker.TakeOutcomes;
for O in Outcomes do
if not O.Ok then
SetStatusFor(Format('Errore su %s: %s', [O.Caption, O.Error]), False,
STATUS_HOLD_MS);
if not FWorker.IsBusConnected then
begin
// Porta chiusa o caduta: niente piu' letture valide a video.
InvalidateReadings;
FWorker.CurrentStatus(Msg, Ok);
SetStatusFor(Msg, Ok, 0);
Exit;
end;
// Silenzi scaduti e conto di quelli in corso: va fatto a ogni giro, non
// solo quando arrivano letture nuove.
UpdateMutedAlarms;
if not FWorker.TakeReadings(Readings) then
Exit;
FPollError := '';
FPollErrors := 0;
for R in Readings do
ApplyReading(R);
FinishAligning(Readings);
// Un messaggio appena mostrato resta il tempo di leggerlo.
if GetTickCount64 < FStatusUntil then
Exit;
if FPollError = '' then
SetStatus('Connesso.', True)
else if FPollErrors > 1 then
SetStatus(Format('Lettura fallita su %d richieste, la prima: %s',
[FPollErrors, FPollError]), False)
else
SetStatus('Lettura fallita: ' + FPollError, False);
end;
end.