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; 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; FGaugePlan: TList; FSwitchPlan: TList; 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; /// Richieste delle bobine effettivamente in uso: partono raggruppate e si /// dividono da sole quando un blocco non risponde. FCoilReqs: TArray; /// Quando ogni bobina e' stata comandata l'ultima volta. FCoilWrites: TDictionary; 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; FRedo: TObjectList; /// 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); 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; 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); 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.Create; FLampPlan := TList.Create; FGaugePlan := TList.Create; FSwitchPlan := TList.Create; FPlanciaFiles := TStringList.Create; FAlarms := TAlarmPlayer.Create; FCoilWrites := TDictionary.Create; FUndo := TObjectList.Create(True); FRedo := TObjectList.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); 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; 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; 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); 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; Outcomes: TArray; 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.