unit uPlanciaConfig; { Configurazione della plancia e sua persistenza su XML. Il DOM usato e' OmniXML (Pascal puro) e non MSXML: evita la dipendenza da COM, che su un PC di bordo puo' essere un problema in meno. I numeri con virgola sono letti e scritti sempre con il punto decimale (FormatSettings invariante), altrimenti un file salvato su una macchina italiana non si riaprirebbe su una inglese e viceversa. } interface uses Winapi.Windows, System.SysUtils, System.Classes, System.Variants, System.Generics.Collections, Vcl.Graphics, Xml.XMLIntf, Xml.XMLDoc, Xml.xmldom, Xml.omnixmldom, uGauge, uImageLib, uPlanciaElements; type EPlanciaConfig = class(Exception); /// Fotografia di quello che si cambia disegnando: elementi e impostazioni /// del pannello. Serve ad annulla/ripeti. La libreria immagini non c'e': /// le immagini sono file sul disco, e una rimossa non torna con un annulla /// (gli elementi che la usavano ritrovano il nome, non il file). TPlanciaSnapshot = class private FElements: TObjectList; public Port: string; Baud: Integer; TimeoutMs: Integer; PollMs: Integer; Title: string; PanelWidth: Integer; PanelHeight: Integer; Background: string; Ink: TColor; /// Indice dell'elemento selezionato al momento della foto; -1 nessuno. Selected: Integer; constructor Create; destructor Destroy; override; end; TPlanciaConfig = class private FElements: TObjectList; FImages: TImageLibrary; public Port: string; Baud: Integer; TimeoutMs: Integer; PollMs: Integer; Title: string; PanelWidth: Integer; PanelHeight: Integer; /// Nome dell'immagine di sfondo nella libreria; '' = nessuno sfondo. Background: string; /// Colore delle scritte serigrafate; clNone = nero. Le plance a fondo /// scuro lo mettono chiaro, altrimenti le etichette sparirebbero. Ink: TColor; /// Cartella del file XML: i suoi sottoalberi (immagini, suoni) viaggiano /// con la plancia quando si copia la cartella. BaseDir: string; /// Cartella dei suoni di allarme, dentro quella dell'XML. function SoundsDir: string; constructor Create; destructor Destroy; override; procedure Clear; procedure LoadFromFile(const AFileName: string); procedure SaveToFile(const AFileName: string); /// Copia profonda dello stato attuale; la libera il chiamante. function TakeSnapshot: TPlanciaSnapshot; /// Riporta elementi e impostazioni a quelli della foto. Le definizioni /// vengono ricreate: i controlli che puntavano alle vecchie vanno rifatti. procedure RestoreSnapshot(ASnapshot: TPlanciaSnapshot); property Elements: TObjectList read FElements; property Images: TImageLibrary read FImages; end; /// Percorso del file di configurazione predefinito, accanto all'eseguibile. function DefaultConfigFile: string; /// Percorso dell'ultima plancia usata, ricordato accanto all'eseguibile, '' /// se non c'e' o il file non esiste piu'. Serve a riaprire da sola la plancia /// su cui si stava lavorando. function LoadLastFile: string; procedure SaveLastFile(const AFileName: string); /// Legge solo il titolo di una plancia, senza caricare immagini ed elementi. /// False se il file non si apre o non e' una configurazione di plancia: serve /// a elencare le plance di una cartella scartando gli altri XML. function ReadPanelTitle(const AFileName: string; out ATitle: string): Boolean; /// Colori in formato #RRGGBB, come sul web: e' quello che ci si aspetta /// scrivendo a mano l'XML, non il $00BBGGRR di Windows. function HtmlToColor(const AValue: string; ADefault: TColor): TColor; function ColorToHtml(AColor: TColor): string; implementation var FS: TFormatSettings; function DefaultConfigFile: string; begin Result := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0))) + 'plancia.xml'; end; /// Il promemoria sta accanto all'eseguibile, non nel registro: la plancia si /// trasporta copiando una cartella, e con lei quello che stava aperto. function LastFileStore: string; begin Result := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0))) + 'plancia-ultima.txt'; end; function LoadLastFile: string; var Lines: TStringList; begin Result := ''; if not FileExists(LastFileStore) then Exit; Lines := TStringList.Create; try try Lines.LoadFromFile(LastFileStore, TEncoding.UTF8); if Lines.Count > 0 then Result := Trim(Lines[0]); except // Promemoria illeggibile: si riparte dal file predefinito. Result := ''; end; finally Lines.Free; end; // Un file cancellato o su una chiavetta scollegata non vale piu'. if (Result <> '') and not FileExists(Result) then Result := ''; end; procedure SaveLastFile(const AFileName: string); var Lines: TStringList; begin Lines := TStringList.Create; try Lines.Add(ExpandFileName(AFileName)); try Lines.SaveToFile(LastFileStore, TEncoding.UTF8); except // Cartella di sola lettura: si perde solo la comodita' di riaprire. end; finally Lines.Free; end; end; function AttrStr(ANode: IXMLNode; const AName, ADefault: string): string; begin if (ANode <> nil) and ANode.HasAttribute(AName) then Result := VarToStr(ANode.Attributes[AName]) else Result := ADefault; end; /// Togli la sola lettura, se c'e'. procedure ClearReadOnly(const AFileName: string); begin if FileExists(AFileName) then SetFileAttributes(PChar(AFileName), FILE_ATTRIBUTE_NORMAL); end; /// Cancella davvero, anche un file in sola lettura. procedure ForceDelete(const AFileName: string); begin if not FileExists(AFileName) then Exit; ClearReadOnly(AFileName); DeleteFile(AFileName); end; function ReadPanelTitle(const AFileName: string; out ATitle: string): Boolean; var Doc: IXMLDocument; Root: IXMLNode; begin ATitle := ''; try Doc := TXMLDocument.Create(nil); Doc.LoadFromFile(AFileName); Doc.Active := True; Root := Doc.DocumentElement; if (Root = nil) or not SameText(Root.NodeName, 'plancia') then Exit(False); ATitle := AttrStr(Root.ChildNodes.FindNode('panel'), 'title', ''); Result := True; except Result := False; end; end; function AttrInt(ANode: IXMLNode; const AName: string; ADefault: Integer): Integer; begin Result := StrToIntDef(AttrStr(ANode, AName, ''), ADefault); end; /// Tollera le forme che viene naturale scrivere a mano in un XML. function AttrBool(ANode: IXMLNode; const AName: string; ADefault: Boolean): Boolean; var S: string; begin S := LowerCase(Trim(AttrStr(ANode, AName, ''))); if S = '' then Exit(ADefault); Result := (S = 'true') or (S = '1') or (S = 'yes') or (S = 'si'); end; function AttrFloat(ANode: IXMLNode; const AName: string; const ADefault: Double): Double; var S: string; begin S := AttrStr(ANode, AName, ''); if S = '' then Exit(ADefault); // Tollera anche i file scritti a mano con la virgola decimale. S := StringReplace(S, ',', '.', [rfReplaceAll]); Result := StrToFloatDef(S, ADefault, FS); end; function FloatAttr(const AValue: Double): string; begin Result := FloatToStr(AValue, FS); end; function HtmlToColor(const AValue: string; ADefault: TColor): TColor; var S: string; V: Integer; begin S := Trim(AValue); if S.StartsWith('#') then Delete(S, 1, 1); if (Length(S) <> 6) or not TryStrToInt('$' + S, V) then Exit(ADefault); // Da RRGGBB a $00BBGGRR. Result := TColor(((V and $FF) shl 16) or (V and $FF00) or ((V shr 16) and $FF)); end; function ColorToHtml(AColor: TColor): string; var V: Integer; begin V := ColorToRGB(AColor); Result := Format('#%.2x%.2x%.2x', [V and $FF, (V shr 8) and $FF, (V shr 16) and $FF]); end; { TPlanciaSnapshot } constructor TPlanciaSnapshot.Create; begin inherited Create; FElements := TObjectList.Create(True); Selected := -1; end; destructor TPlanciaSnapshot.Destroy; begin FElements.Free; inherited; end; function CloneDef(ASource: TElementDef): TElementDef; begin Result := TElementDef.Create(ASource.Kind); Result.AssignFrom(ASource); end; { TPlanciaConfig } function TPlanciaConfig.SoundsDir: string; begin Result := IncludeTrailingPathDelimiter(BaseDir) + 'suoni'; end; function TPlanciaConfig.TakeSnapshot: TPlanciaSnapshot; var D: TElementDef; begin Result := TPlanciaSnapshot.Create; Result.Port := Port; Result.Baud := Baud; Result.TimeoutMs := TimeoutMs; Result.PollMs := PollMs; Result.Title := Title; Result.PanelWidth := PanelWidth; Result.PanelHeight := PanelHeight; Result.Background := Background; Result.Ink := Ink; for D in FElements do Result.FElements.Add(CloneDef(D)); end; procedure TPlanciaConfig.RestoreSnapshot(ASnapshot: TPlanciaSnapshot); var D: TElementDef; begin Port := ASnapshot.Port; Baud := ASnapshot.Baud; TimeoutMs := ASnapshot.TimeoutMs; PollMs := ASnapshot.PollMs; Title := ASnapshot.Title; PanelWidth := ASnapshot.PanelWidth; PanelHeight := ASnapshot.PanelHeight; Background := ASnapshot.Background; Ink := ASnapshot.Ink; FElements.Clear; for D in ASnapshot.FElements do FElements.Add(CloneDef(D)); end; constructor TPlanciaConfig.Create; begin inherited Create; FElements := TObjectList.Create(True); FImages := TImageLibrary.Create; Clear; end; destructor TPlanciaConfig.Destroy; begin FImages.Free; FElements.Free; inherited; end; procedure TPlanciaConfig.Clear; begin FElements.Clear; FImages.Clear; Background := ''; BaseDir := ExtractFilePath(ParamStr(0)); Ink := clNone; Port := 'COM1'; Baud := 9600; TimeoutMs := 500; PollMs := 500; Title := 'Plancia'; PanelWidth := 1000; PanelHeight := 640; end; procedure TPlanciaConfig.LoadFromFile(const AFileName: string); var Doc: IXMLDocument; Root, Node, Els, El: IXMLNode; I: Integer; Kind: TElementKind; Def: TElementDef; begin if not FileExists(AFileName) then raise EPlanciaConfig.CreateFmt('File di configurazione non trovato: %s', [AFileName]); Clear; Doc := TXMLDocument.Create(nil); Doc.LoadFromFile(AFileName); Doc.Active := True; Root := Doc.DocumentElement; if (Root = nil) or not SameText(Root.NodeName, 'plancia') then raise EPlanciaConfig.CreateFmt( '%s non e'' una configurazione di plancia (manca il nodo ).', [ExtractFileName(AFileName)]); Node := Root.ChildNodes.FindNode('connection'); if Node <> nil then begin Port := AttrStr(Node, 'port', Port); Baud := AttrInt(Node, 'baud', Baud); TimeoutMs := AttrInt(Node, 'timeoutMs', TimeoutMs); PollMs := AttrInt(Node, 'pollMs', PollMs); end; Node := Root.ChildNodes.FindNode('panel'); if Node <> nil then begin Title := AttrStr(Node, 'title', Title); PanelWidth := AttrInt(Node, 'width', PanelWidth); PanelHeight := AttrInt(Node, 'height', PanelHeight); Background := AttrStr(Node, 'background', ''); Ink := HtmlToColor(AttrStr(Node, 'ink', ''), clNone); end; // I percorsi delle immagini sono relativi alla cartella dell'XML. BaseDir := ExtractFilePath(ExpandFileName(AFileName)); FImages.SetBaseDir(BaseDir); Els := Root.ChildNodes.FindNode('images'); if Els <> nil then for I := 0 to Els.ChildNodes.Count - 1 do begin El := Els.ChildNodes[I]; if SameText(El.NodeName, 'image') then FImages.AddEntry(AttrStr(El, 'name', ''), AttrStr(El, 'file', '')); end; Els := Root.ChildNodes.FindNode('elements'); if Els = nil then Exit; for I := 0 to Els.ChildNodes.Count - 1 do begin El := Els.ChildNodes[I]; if not SameText(El.NodeName, 'element') then Continue; if not ElementKindFromId(AttrStr(El, 'kind', ''), Kind) then raise EPlanciaConfig.CreateFmt( 'Tipo di elemento sconosciuto "%s" nel file %s.', [AttrStr(El, 'kind', ''), ExtractFileName(AFileName)]); Def := TElementDef.Create(Kind); FElements.Add(Def); Def.Caption := AttrStr(El, 'caption', Def.Caption); Def.Slave := AttrInt(El, 'slave', Def.Slave); Def.Channel := AttrInt(El, 'channel', Def.Channel); Def.Left := AttrInt(El, 'left', Def.Left); Def.Top := AttrInt(El, 'top', Def.Top); Def.Width := AttrInt(El, 'width', Def.Width); Def.Height := AttrInt(El, 'height', Def.Height); Def.FontSize := AttrInt(El, 'fontSize', Def.FontSize); if Kind = ekDisplay then begin Def.Digits := AttrInt(El, 'digits', Def.Digits); Def.Decimals := AttrInt(El, 'decimals', Def.Decimals); end; if Kind = ekRotary then begin Def.Positions := AttrInt(El, 'positions', Def.Positions); Def.Legend := AttrStr(El, 'legend', Def.Legend); // Assente = la bobina dopo `channel`, che e' il caso normale. Def.Channel2 := AttrInt(El, 'channel2', Def.Channel2); Def.Momentary := AttrBool(El, 'momentary', Def.Momentary); end; if Kind = ekLamp then begin Def.Alarm := AttrBool(El, 'alarm', Def.Alarm); Def.Sound := AttrStr(El, 'sound', Def.Sound); end; if Kind in [ekLamp, ekButton, ekSwitch, ekLabel] then begin Def.OnColor := HtmlToColor(AttrStr(El, 'color', ''), Def.OnColor); Def.OffColor := HtmlToColor(AttrStr(El, 'colorOff', ''), Def.OffColor); end; Def.CaptionPos := CaptionPosFromId(AttrStr(El, 'captionPos', ''), Def.CaptionPos); Def.Shape := ShapeFromId(AttrStr(El, 'shape', ''), Def.Shape); Def.FontName := AttrStr(El, 'fontName', Def.FontName); Def.Spacing := AttrInt(El, 'spacing', Def.Spacing); if Kind = ekLabel then begin Def.Frame := FrameFromId(AttrStr(El, 'frame', ''), Def.Frame); Def.FrameWidth := AttrInt(El, 'frameWidth', Def.FrameWidth); end; if Kind = ekImage then Def.ImageOff := AttrStr(El, 'image', '') else if ElementUsesImages(Kind) then begin Def.ImageOff := AttrStr(El, 'imageOff', ''); Def.ImageOn := AttrStr(El, 'imageOn', ''); end; if ElementIsAnalog(Kind) then begin Def.RawMin := AttrInt(El, 'rawMin', Def.RawMin); Def.RawMax := AttrInt(El, 'rawMax', Def.RawMax); Def.EngMin := AttrFloat(El, 'engMin', Def.EngMin); Def.EngMax := AttrFloat(El, 'engMax', Def.EngMax); Def.Units := AttrStr(El, 'units', Def.Units); Def.WarnBelow := AttrFloat(El, 'warnBelow', Def.WarnBelow); Def.WarnAbove := AttrFloat(El, 'warnAbove', Def.WarnAbove); end; if Kind = ekGauge then begin Def.GaugeStyle := GaugeStyleFromId(AttrStr(El, 'style', ''), Def.GaugeStyle); // Il quadrante ha una lettura in cifre, e i decimali contano come sul // display. Def.Decimals := AttrInt(El, 'decimals', Def.Decimals); end; end; end; procedure TPlanciaConfig.SaveToFile(const AFileName: string); var Doc: IXMLDocument; Root, Node, Els, El: IXMLNode; Def: TElementDef; I: Integer; Temp, Backup: string; begin // Salvando in un'altra cartella i percorsi relativi vanno ricalcolati, // altrimenti punterebbero al vuoto. BaseDir := ExtractFilePath(ExpandFileName(AFileName)); FImages.Rebase(BaseDir); Doc := TXMLDocument.Create(nil); Doc.Active := True; Doc.Version := '1.0'; Doc.Encoding := 'UTF-8'; Doc.Options := Doc.Options + [doNodeAutoIndent]; Root := Doc.AddChild('plancia'); Root.Attributes['version'] := '1'; Node := Root.AddChild('connection'); Node.Attributes['port'] := Port; Node.Attributes['baud'] := Baud; Node.Attributes['timeoutMs'] := TimeoutMs; Node.Attributes['pollMs'] := PollMs; Node := Root.AddChild('panel'); Node.Attributes['title'] := Title; Node.Attributes['width'] := PanelWidth; Node.Attributes['height'] := PanelHeight; if Background <> '' then Node.Attributes['background'] := Background; if Ink <> clNone then Node.Attributes['ink'] := ColorToHtml(Ink); if FImages.Count > 0 then begin Els := Root.AddChild('images'); for I := 0 to FImages.Count - 1 do begin El := Els.AddChild('image'); El.Attributes['name'] := FImages.Item(I).Name; El.Attributes['file'] := FImages.Item(I).FileName; end; end; Els := Root.AddChild('elements'); for Def in FElements do begin El := Els.AddChild('element'); El.Attributes['kind'] := ELEMENT_IDS[Def.Kind]; El.Attributes['caption'] := Def.Caption; if ElementHasChannel(Def.Kind) then begin El.Attributes['slave'] := Def.Slave; El.Attributes['channel'] := Def.Channel; end; El.Attributes['left'] := Def.Left; El.Attributes['top'] := Def.Top; El.Attributes['width'] := Def.Width; El.Attributes['height'] := Def.Height; if Def.FontSize > 0 then El.Attributes['fontSize'] := Def.FontSize; if Def.Kind = ekDisplay then begin El.Attributes['digits'] := Def.Digits; El.Attributes['decimals'] := Def.Decimals; end; if Def.Kind = ekRotary then begin El.Attributes['positions'] := Def.Positions; El.Attributes['legend'] := Def.Legend; if (Def.Positions >= 3) and (Def.Channel2 >= 0) then El.Attributes['channel2'] := Def.Channel2; if Def.Momentary then El.Attributes['momentary'] := 'true'; end; if Def.Kind = ekLamp then begin // Il nome del suono si salva anche con l'allarme spento: si spunta e si // rispunta la casella senza dover riscegliere il file. if Def.Alarm then El.Attributes['alarm'] := 'true'; if Def.Sound <> '' then El.Attributes['sound'] := Def.Sound; end; if Def.Kind in [ekLamp, ekButton, ekSwitch, ekLabel] then begin if Def.OnColor <> TElementDef.DefaultOnColor(Def.Kind) then El.Attributes['color'] := ColorToHtml(Def.OnColor); if Def.OffColor <> clNone then El.Attributes['colorOff'] := ColorToHtml(Def.OffColor); end; // Confronto con il predefinito del tipo, non con "center": una spia con // l'etichetta al centro, salvata senza attributo, si riaprirebbe sotto. if Def.CaptionPos <> TElementDef.DefaultCaptionPos(Def.Kind) then El.Attributes['captionPos'] := CAPTION_POS_IDS[Def.CaptionPos]; if Def.Shape <> ksAuto then El.Attributes['shape'] := SHAPE_IDS[Def.Shape]; if Def.FontName <> '' then El.Attributes['fontName'] := Def.FontName; if Def.Spacing <> 0 then El.Attributes['spacing'] := Def.Spacing; if Def.Kind = ekLabel then begin if Def.Frame <> fkNone then begin El.Attributes['frame'] := FRAME_IDS[Def.Frame]; El.Attributes['frameWidth'] := Def.FrameWidth; end; end; if Def.Kind = ekImage then begin if Def.ImageOff <> '' then El.Attributes['image'] := Def.ImageOff; end else if ElementUsesImages(Def.Kind) then begin if Def.ImageOff <> '' then El.Attributes['imageOff'] := Def.ImageOff; if Def.ImageOn <> '' then El.Attributes['imageOn'] := Def.ImageOn; end; if ElementIsAnalog(Def.Kind) then begin El.Attributes['rawMin'] := Def.RawMin; El.Attributes['rawMax'] := Def.RawMax; El.Attributes['engMin'] := FloatAttr(Def.EngMin); El.Attributes['engMax'] := FloatAttr(Def.EngMax); El.Attributes['units'] := Def.Units; El.Attributes['warnBelow'] := FloatAttr(Def.WarnBelow); El.Attributes['warnAbove'] := FloatAttr(Def.WarnAbove); end; if (Def.Kind = ekGauge) and (Def.GaugeStyle <> gsBar) then begin El.Attributes['style'] := GAUGE_STYLE_IDS[Def.GaugeStyle]; El.Attributes['decimals'] := Def.Decimals; end; end; // Non si scrive mai direttamente sopra il file buono: si scrive accanto e // poi si scambia. Un programma chiuso male, un disco pieno o due copie del // programma che salvano insieme lascerebbero altrimenti un XML troncato, // che alla riapertura non si legge piu' e porta via la plancia. La copia // precedente resta come .bak, che e' la via di scampo se succede comunque. Temp := AFileName + '.tmp'; Backup := ''; ForceDelete(Temp); Doc.SaveToFile(Temp); if FileExists(AFileName) then begin Backup := AFileName + '.bak'; ForceDelete(Backup); // La sola lettura su un file di configurazione e' quasi sempre un residuo // di una copia (da uno zip, da una chiavetta): chi salva vuole salvare, e // la versione di prima resta comunque nel .bak. ClearReadOnly(AFileName); // Se il rinomina non riesce (file aperto da un altro programma) si // procede comunque: meglio salvare senza copia di sicurezza che non // salvare. if not RenameFile(AFileName, Backup) then begin Backup := ''; ForceDelete(AFileName); end; end; if not RenameFile(Temp, AFileName) then begin // Non si e' riusciti a mettere il nuovo file al suo posto: si rimette // dov'era quello di prima, invece di lasciare la plancia senza file. if (Backup <> '') and FileExists(Backup) then RenameFile(Backup, AFileName); DeleteFile(Temp); raise EPlanciaConfig.CreateFmt( 'Non riesco a scrivere %s: la versione precedente e'' stata rimessa ' + 'al suo posto.', [ExtractFileName(AFileName)]); end; end; initialization FS := TFormatSettings.Invariant; DefaultDOMVendor := sOmniXmlVendor; end.