# HG changeset patch # User Edouard Tisserant # Date 1630611389 -7200 # Node ID c89fc366bebd7237593efd979981670a409ee38f # Parent 577118ebd179262d059224453170d3f3fd39bb4d# Parent 95fe62bfe9204eb2ede59bea45d8e0d605db959f Merge SVGHMI in default diff -r 577118ebd179 -r c89fc366bebd .hgignore --- a/.hgignore Wed Jun 30 15:44:32 2021 +0200 +++ b/.hgignore Thu Sep 02 21:36:29 2021 +0200 @@ -1,5 +1,7 @@ .project +.svghmithumbs + .directory .pytest_cache .cache diff -r 577118ebd179 -r c89fc366bebd Beremiz_service.py --- a/Beremiz_service.py Wed Jun 30 15:44:32 2021 +0200 +++ b/Beremiz_service.py Thu Sep 02 21:36:29 2021 +0200 @@ -31,6 +31,7 @@ import getopt import threading import shlex +import traceback from threading import Thread, Semaphore, Lock, currentThread from builtins import str as text from past.builtins import execfile @@ -614,8 +615,11 @@ pyro_thread.join() plcobj = runtime.GetPLCObjectSingleton() -plcobj.StopPLC() -plcobj.UnLoadPLC() +try: + plcobj.StopPLC() + plcobj.UnLoadPLC() +except: + print(traceback.format_exc()) if havetwisted: reactor.stop() diff -r 577118ebd179 -r c89fc366bebd ProjectController.py --- a/ProjectController.py Wed Jun 30 15:44:32 2021 +0200 +++ b/ProjectController.py Thu Sep 02 21:36:29 2021 +0200 @@ -36,7 +36,6 @@ import shutil import re import tempfile -from threading import Timer from datetime import datetime from weakref import WeakKeyDictionary from functools import reduce diff -r 577118ebd179 -r c89fc366bebd XSLTransform.py --- a/XSLTransform.py Wed Jun 30 15:44:32 2021 +0200 +++ b/XSLTransform.py Thu Sep 02 21:36:29 2021 +0200 @@ -17,8 +17,8 @@ etree.XMLParser()), extensions={("beremiz", name): call for name, call in xsltext}) - def transform(self, root, **kwargs): - res = self.xslt(root, **{k: etree.XSLT.strparam(v) for k, v in kwargs.iteritems()}) + def transform(self, root, profile_run=False, **kwargs): + res = self.xslt(root, profile_run=profile_run, **{k: etree.XSLT.strparam(v) for k, v in kwargs.iteritems()}) # print(self.xslt.error_log) return res diff -r 577118ebd179 -r c89fc366bebd controls/DebugVariablePanel/DebugVariableItem.py --- a/controls/DebugVariablePanel/DebugVariableItem.py Wed Jun 30 15:44:32 2021 +0200 +++ b/controls/DebugVariablePanel/DebugVariableItem.py Thu Sep 02 21:36:29 2021 +0200 @@ -152,7 +152,7 @@ else 0) end_idx = (self.GetNearestData(end_tick, 1) if end_tick is not None - else len(self.Data)) + else self.Data.count) # Return data between indexes return self.Data.view[start_idx:end_idx] diff -r 577118ebd179 -r c89fc366bebd controls/VariablePanel.py --- a/controls/VariablePanel.py Wed Jun 30 15:44:32 2021 +0200 +++ b/controls/VariablePanel.py Thu Sep 02 21:36:29 2021 +0200 @@ -956,7 +956,8 @@ var_name = self.Table.GetValueByName(row, "Name") var_class = self.Table.GetValueByName(row, "Class") var_type = self.Table.GetValueByName(row, "Type") - data = wx.TextDataObject(str((var_name, var_class, var_type, self.TagName))) + var_doc = self.Table.GetValueByName(row, "Documentation") + data = wx.TextDataObject(str((var_name, var_class, var_type, self.TagName, var_doc))) dragSource = wx.DropSource(self.VariablesGrid) dragSource.SetData(data) dragSource.DoDragDrop() diff -r 577118ebd179 -r c89fc366bebd docutil/docsvg.py --- a/docutil/docsvg.py Wed Jun 30 15:44:32 2021 +0200 +++ b/docutil/docsvg.py Thu Sep 02 21:36:29 2021 +0200 @@ -24,61 +24,38 @@ from __future__ import absolute_import -import os +import wx import subprocess -import wx - def get_inkscape_path(): - """ Return the Inkscape path """ + """ Return the Inkscape binary path """ + if wx.Platform == '__WXMSW__': from six.moves import winreg + inkcmd = None try: - svgexepath = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, - 'Software\\Classes\\svgfile\\shell\\Inkscape\\command') + inkcmd = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, + 'Software\\Classes\\svgfile\\shell\\Inkscape\\command') except OSError: try: - svgexepath = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, + inkcmd = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, 'Software\\Classes\\inkscape.svg\\shell\\open\\command') - except Exception: + except OSError: return None - svgexepath = svgexepath.replace('"%1"', '').strip() - return svgexepath.replace('"', '') + return inkcmd.replace('"%1"', '').strip().replace('"', '') + else: - # TODO: search for inkscape in $PATH - svgexepath = os.path.join("/usr/bin", "inkscape") - if os.path.exists(svgexepath): - return svgexepath - return None - - -def open_win_svg(svgexepath, svgfile): - """ Open Inkscape on Windows platform """ - popenargs = [svgexepath] - if svgfile is not None: - popenargs.append(svgfile) - subprocess.Popen(popenargs) - - -def open_lin_svg(svgexepath, svgfile): - """ Open Inkscape on Linux platform """ - if os.path.isfile("/usr/bin/inkscape"): - os.system("%s %s &" % (svgexepath, svgfile)) - + try: + return subprocess.check_output("command -v inkscape", shell=True).strip() + except subprocess.CalledProcessError: + return None def open_svg(svgfile): """ Generic function to open SVG file """ - if wx.Platform == '__WXMSW__': - try: - open_win_svg(get_inkscape_path(), svgfile) - except Exception: - wx.MessageBox("Inkscape is not found or installed !") - return None + + inkpath = get_inkscape_path() + if inkpath is None: + wx.MessageBox("Inkscape is not found or installed !") else: - svgexepath=get_inkscape_path() - if os.path.isfile(svgexepath): - open_lin_svg(svgexepath, svgfile) - else: - wx.MessageBox("Inkscape is not found or installed !") - return None + subprocess.Popen([inkpath,svgfile]) diff -r 577118ebd179 -r c89fc366bebd editors/ConfTreeNodeEditor.py --- a/editors/ConfTreeNodeEditor.py Wed Jun 30 15:44:32 2021 +0200 +++ b/editors/ConfTreeNodeEditor.py Thu Sep 02 21:36:29 2021 +0200 @@ -385,7 +385,7 @@ element_infos["children"], element_path) else: - boxsizer = wx.FlexGridSizer(cols=3, rows=1) + boxsizer = wx.FlexGridSizer(cols=4, rows=1) boxsizer.AddGrowableCol(1) flags = (wx.GROW | wx.BOTTOM | wx.LEFT | wx.RIGHT) if first: @@ -472,8 +472,8 @@ else: if element_infos["type"] == "boolean": - checkbox = wx.CheckBox(self.ParamsEditor, size=wx.Size(17, 25)) - boxsizer.AddWindow(checkbox) + checkbox = wx.CheckBox(self.ParamsEditor) + boxsizer.AddWindow(checkbox, flag=wx.ALIGN_CENTER_VERTICAL | wx.RIGHT) if element_infos["value"] is not None: checkbox.SetValue(element_infos["value"]) checkbox.Bind(wx.EVT_CHECKBOX, @@ -526,6 +526,16 @@ textctrl.Bind(wx.EVT_TEXT_ENTER, callback) textctrl.Bind(wx.EVT_TEXT, callback) textctrl.Bind(wx.EVT_KILL_FOCUS, callback) + + if not isinstance(element_infos["type"], list) and element_infos["use"] == "optional": + bt = wx.BitmapButton(self.ParamsEditor, + bitmap=wx.ArtProvider.GetBitmap(wx.ART_UNDO, wx.ART_TOOLBAR, (16,16)), + style=wx.BORDER_NONE) + self.Bind(wx.EVT_BUTTON, + self.GetResetFunction(element_path), + bt) + + boxsizer.AddWindow(bt, border=5, flag=wx.ALIGN_CENTER_VERTICAL | wx.LEFT) first = False sizer.Layout() self.RefreshScrollbars() @@ -590,6 +600,13 @@ event.Skip() return OnTextCtrlChanged + def GetResetFunction(self, path): + def OnResetBt(event): + res = self.SetConfNodeParamsAttribute(path, None) + wx.CallAfter(self.RefreshView) + event.Skip() + return OnResetBt + def GetCheckBoxCallBackFunction(self, chkbx, path): def OnCheckBoxChanged(event): res = self.SetConfNodeParamsAttribute(path, chkbx.IsChecked()) diff -r 577118ebd179 -r c89fc366bebd editors/Viewer.py --- a/editors/Viewer.py Wed Jun 30 15:44:32 2021 +0200 +++ b/editors/Viewer.py Thu Sep 02 21:36:29 2021 +0200 @@ -389,8 +389,8 @@ elif var_name.upper() in [name.upper() for name in self.ParentWindow.Controler.GetProjectPouNames(self.ParentWindow.Debug)]: message = _("\"%s\" pou already exists!") % var_name elif not var_name.upper() in [name.upper() for name in self.ParentWindow.Controler.GetEditedElementVariables(tagname, self.ParentWindow.Debug)]: - print(values) - self.ParentWindow.Controler.AddEditedElementPouExternalVar(tagname, values[2], var_name, description=values[4]) + kwargs = dict(description=values[4]) if len(values)>4 else {} + self.ParentWindow.Controler.AddEditedElementPouExternalVar(tagname, values[2], var_name, **kwargs) self.ParentWindow.RefreshVariablePanel() self.ParentWindow.ParentWindow.RefreshPouInstanceVariablesPanel() self.ParentWindow.AddVariableBlock(x, y, scaling, INPUT, var_name, values[2]) diff -r 577118ebd179 -r c89fc366bebd features.py --- a/features.py Wed Jun 30 15:44:32 2021 +0200 +++ b/features.py Thu Sep 02 21:36:29 2021 +0200 @@ -12,7 +12,8 @@ ('Native', 'NativeLib.NativeLibrary', True), ('Python', 'py_ext.PythonLibrary', True), ('Etherlab', 'etherlab.EthercatMaster.EtherlabLibrary', False), - ('SVGUI', 'svgui.SVGUILibrary', False)] + ('SVGUI', 'svgui.SVGUILibrary', False), + ('SVGHMI', 'svghmi.SVGHMILibrary', False)] catalog = [ ('canfestival', _('CANopen support'), _('Map located variables over CANopen'), 'canfestival.canfestival.RootClass'), @@ -22,6 +23,7 @@ ('c_ext', _('C extension'), _('Add C code accessing located variables synchronously'), 'c_ext.CFile'), ('py_ext', _('Python file'), _('Add Python code executed asynchronously'), 'py_ext.PythonFile'), ('wxglade_hmi', _('WxGlade GUI'), _('Add a simple WxGlade based GUI.'), 'wxglade_hmi.WxGladeHMI'), - ('svgui', _('SVGUI'), _('Experimental web based HMI'), 'svgui.SVGUI')] + ('svgui', _('SVGUI'), _('Experimental web based HMI'), 'svgui.SVGUI'), + ('svghmi', _('SVGHMI'), _('SVG based HMI'), 'svghmi.SVGHMI')] file_editors = [] diff -r 577118ebd179 -r c89fc366bebd images/AddFont.png Binary file images/AddFont.png has changed diff -r 577118ebd179 -r c89fc366bebd images/DelFont.png Binary file images/DelFont.png has changed diff -r 577118ebd179 -r c89fc366bebd images/EditPO.png Binary file images/EditPO.png has changed diff -r 577118ebd179 -r c89fc366bebd images/EditSVG.png Binary file images/EditSVG.png has changed diff -r 577118ebd179 -r c89fc366bebd images/ImportSVG.png Binary file images/ImportSVG.png has changed diff -r 577118ebd179 -r c89fc366bebd images/OpenPOT.png Binary file images/OpenPOT.png has changed diff -r 577118ebd179 -r c89fc366bebd images/SVGHMI.png Binary file images/SVGHMI.png has changed diff -r 577118ebd179 -r c89fc366bebd images/icons.svg --- a/images/icons.svg Wed Jun 30 15:44:32 2021 +0200 +++ b/images/icons.svg Thu Sep 02 21:36:29 2021 +0200 @@ -15,7 +15,7 @@ height="1052.3622" id="svg2" sodipodi:version="0.32" - inkscape:version="0.91 r13725" + inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)" sodipodi:docname="icons.svg" inkscape:output_extension="org.inkscape.output.svg.inkscape"> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + sodipodi:cx="38.638336" /> - - + + sodipodi:cx="38.638336" /> %% Build Clean editPLC HMIEditor ImportFile ManageFolder ImportDEF ImportSVG NetworkEdit ShowMaster ExportSlave Run ShowIECcode Stop Unknown %% + style="font-size:12.76095104px;line-height:1.25">%% Build Clean editPLC HMIEditor ImportFile ManageFolder ImportSVG NetworkEdit ShowMaster ExportSlave Run ShowIECcode Stop EditSVG OpenPOT EditPO AddFont DelFont %% @@ -87820,7 +88668,7 @@ id="g5077" transform="matrix(0.09090112,0,0,0.09090112,-708.74866,934.66705)"> @@ -87889,7 +88737,7 @@ y="892.03345" x="633.36249">Master @@ -87897,7 +88745,7 @@ id="g8206" transform="matrix(-0.05230834,0,0,0.05230834,-370.7166,804.48617)"> @@ -87966,7 +88814,7 @@ y="462.98654" x="-1088.8175">Slave @@ -87974,7 +88822,7 @@ id="g8301" transform="matrix(-0.05230834,0,0,0.05230834,-450.7166,836.48617)"> @@ -88047,31 +88895,31 @@ id="g9184" transform="matrix(0.7769546,0,0,0.7769546,-2279.9093,796.92596)"> + transform="translate(1848.9892,-430.1329)"> + style="display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:0;fill-rule:evenodd;stroke:none;stroke-width:1;marker:none;enable-background:accumulate" /> ST @@ -88163,10 +89011,10 @@ style="opacity:0.47906979;fill:url(#linearGradient19984);stroke:none" inkscape:connector-curvature="0" /> - - - D - E - F - - - - - - - - - + style="display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:0;fill-rule:evenodd;stroke:none;stroke-width:1;marker:none;enable-background:accumulate" /> @@ -88362,23 +89134,19 @@ transform="matrix(1.824846,0,0,1.824846,58.301023,-6.9917586)" id="g2837"> - - @@ -89273,60 +90031,60 @@ id="g19199" transform="matrix(5.3097304e-2,0,0,5.3097304e-2,247.38564,260.36282)"> + cx="0" + sodipodi:ry="226" + sodipodi:rx="226" + sodipodi:cy="0" + sodipodi:cx="0" /> + cx="0" + sodipodi:ry="169.5" + sodipodi:rx="169.5" + sodipodi:cy="0" + sodipodi:cx="0" /> - + - + + cx="0" + sodipodi:ry="106.5" + sodipodi:rx="106.5" + sodipodi:cy="0" + sodipodi:cx="0" /> - + sodipodi:cx="91.923882" /> + transform="translate(1653.0897,-400.03854)"> + style="display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:0;fill-rule:evenodd;stroke:none;stroke-width:1;marker:none;enable-background:accumulate" /> + style="display:inline;overflow:visible;visibility:visible;fill:#3d993d;fill-opacity:1;fill-rule:nonzero;stroke:#3d993d;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10.43299961;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none" /> + style="display:inline;overflow:visible;visibility:visible;fill:url(#linearGradient16607);fill-opacity:1;fill-rule:nonzero;stroke:#3d993d;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10.43299961;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none" /> + style="display:inline;overflow:visible;visibility:visible;fill:url(#linearGradient16609);fill-opacity:1;fill-rule:nonzero;stroke:#3d993d;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10.43299961;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none" /> @@ -91056,15 +91813,15 @@ @@ -91089,8 +91846,8 @@ style="fill:url(#linearGradient16647)" inkscape:connector-curvature="0" /> @@ -91262,7 +92019,7 @@ x="166.52481" id="tspan16195-0" sodipodi:role="line" - style="font-size:12.76000023px;line-height:1.25">%% Extension Cfile Pyfile wxGlade SVGUI FOLDER FILE %% + style="font-size:12.76000023px;line-height:1.25">%% Extension Cfile Pyfile wxGlade SVGHMI FOLDER FILE %% + inkscape:export-ydpi="90" + inkscape:label="svghmi"> @@ -92205,16 +92963,16 @@ transform="matrix(5.0027792,0,0,5.0027792,-215.17835,-168.84627)" id="g39828-7"> + style="fill:#84c225;fill-rule:evenodd;stroke:#5d9d35;stroke-width:2.82220006" + sodipodi:ry="34.144001" + sodipodi:rx="34.144001" + sodipodi:cy="110.081" + sodipodi:cx="100.287" /> + transform="translate(1733.0897,-400.03854)"> + style="display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:0;fill-rule:evenodd;stroke:none;stroke-width:1;marker:none;enable-background:accumulate" /> + style="display:inline;overflow:visible;visibility:visible;fill:#3d993d;fill-opacity:1;fill-rule:nonzero;stroke:#3d993d;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10.43299961;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none" /> + style="display:inline;overflow:visible;visibility:visible;fill:url(#linearGradient16607-0);fill-opacity:1;fill-rule:nonzero;stroke:#3d993d;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10.43299961;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none" /> + style="display:inline;overflow:visible;visibility:visible;fill:url(#linearGradient16609-7);fill-opacity:1;fill-rule:nonzero;stroke:#3d993d;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10.43299961;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none" /> @@ -92323,36 +93081,36 @@ transform="matrix(0.70713063,0,0,0.70713063,-1523.2675,436.54273)"> + d="m 701.87431,209.89048 v 7.46812 h 0.62169 c 0.90146,0 1.54388,-0.28235 1.92726,-0.84706 0.38337,-0.56989 0.57506,-1.5361 0.57507,-2.89866 -10e-6,-1.35218 -0.1917,-2.31063 -0.57507,-2.87534 -0.38338,-0.5647 -1.0258,-0.84705 -1.92726,-0.84706 h -0.62169 m -2.29251,-2.06714 h 2.4557 c 1.89617,1e-5 3.26131,0.45333 4.09542,1.35996 0.8341,0.90147 1.25116,2.37799 1.25117,4.42958 -10e-6,2.05678 -0.41707,3.54108 -1.25117,4.4529 -0.83411,0.90664 -2.19925,1.35996 -4.09542,1.35996 h -2.4557 v -11.6024" /> + d="m 698.82892,219.42574 h -7.22722 v -11.6024 h 7.22722 v 2.02051 h -4.93471 v 2.50233 h 4.46843 v 2.02051 h -4.46843 v 3.03853 h 4.93471 v 2.02052" /> + d="m 710.72295,214.3356 c -1.15532,-0.44036 -1.93762,-0.90145 -2.3469,-1.38327 -0.40928,-0.48699 -0.61392,-1.13977 -0.61392,-1.95834 0,-1.05169 0.33675,-1.87803 1.01025,-2.47901 0.6735,-0.60096 1.59828,-0.90145 2.77432,-0.90146 0.53362,1e-5 1.06724,0.0622 1.60087,0.18651 0.53361,0.11917 1.06205,0.2979 1.58532,0.53621 v 2.2381 c -0.49218,-0.3471 -0.99213,-0.61132 -1.49984,-0.79266 -0.50772,-0.18132 -1.01026,-0.27198 -1.50761,-0.27199 -0.55435,1e-5 -0.97918,0.1114 -1.27448,0.33416 -0.29531,0.22278 -0.44296,0.5414 -0.44296,0.95586 0,0.32122 0.10621,0.58803 0.31862,0.80043 0.21759,0.20724 0.66832,0.43779 1.35219,0.69164 l 0.98694,0.37302 c 0.93254,0.34193 1.61899,0.79525 2.05937,1.35995 0.44036,0.56472 0.66054,1.27708 0.66055,2.13708 -10e-6,1.17087 -0.34712,2.04642 -1.04134,2.62667 -0.68905,0.57507 -1.73298,0.8626 -3.13179,0.8626 -0.57507,0 -1.15273,-0.0699 -1.73298,-0.20982 -0.57507,-0.1347 -1.132,-0.33675 -1.67081,-0.60616 v -2.37021 c 0.61134,0.43519 1.20195,0.75899 1.77184,0.9714 0.57506,0.21241 1.14236,0.31862 1.70189,0.31862 0.5647,0 1.00248,-0.12693 1.31333,-0.38079 0.31084,-0.25904 0.46627,-0.6191 0.46627,-1.0802 0,-0.34711 -0.10362,-0.65018 -0.31084,-0.90923 -0.20724,-0.26421 -0.50773,-0.47144 -0.90146,-0.62169 l -1.12683,-0.42742" /> + d="m 673.1875,219.4375 c -1.11263,1e-5 -1.98781,0.33354 -2.625,0.96875 -0.63719,0.63523 -0.96875,1.51337 -0.96875,2.625 0,0.86523 0.20653,1.54776 0.59375,2.0625 0.0504,0.0663 0.12517,0.12174 0.1875,0.1875 1.15377,-0.13682 2.29363,-0.30772 3.40625,-0.6875 l -0.46875,-0.1875 c -0.647,-0.26832 -1.07539,-0.53095 -1.28125,-0.75 -0.20096,-0.22451 -0.3125,-0.50423 -0.3125,-0.84375 0,-0.43808 0.15811,-0.76452 0.4375,-1 0.27938,-0.23546 0.66304,-0.37499 1.1875,-0.375 0.47053,1e-5 0.95715,0.12085 1.4375,0.3125 0.48034,0.19167 0.94061,0.44561 1.40625,0.8125 v -2.34375 c -0.49505,-0.25189 -0.99516,-0.43654 -1.5,-0.5625 -0.50485,-0.13141 -0.99516,-0.21874 -1.5,-0.21875 z m -18.875,0.21875 v 3.40625 c 0.72471,0.36031 1.42932,0.69241 2.15625,0.96875 v -2.21875 h 4.6875 v -2.15625 z m 7.5625,0 v 5.75 c 0.71635,0.10104 1.43195,0.17048 2.15625,0.21875 v -3.78125 h 0.59375 c 0.85285,1e-5 1.44979,0.30937 1.8125,0.90625 0.34855,0.57361 0.54756,1.53331 0.5625,2.875 0.74095,-0.0726 1.48307,-0.15174 2.21875,-0.21875 -0.0467,-1.962 -0.41653,-3.41931 -1.15625,-4.3125 -0.78914,-0.9583 -2.08108,-1.43749 -3.875,-1.4375 z m -4.21875,4.78125 c 1.01035,0.32447 2.0418,0.56004 3.0625,0.75 v -0.75 z" + style="color:#000000;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:15.91540909px;line-height:125%;font-family:'Bitstream Vera Sans Mono';text-align:start;writing-mode:lr-tb;text-anchor:start;display:inline;overflow:visible;visibility:visible;opacity:0.31627909;fill:url(#linearGradient62885);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.04116011;marker:none;enable-background:accumulate" /> + style="display:block;fill:url(#linearGradient62692);stroke:url(#linearGradient62694);stroke-linecap:round;stroke-linejoin:round" + d="m 17.5,7.4997 6,5.3693 -6,5.631 V 15.4994 H 7.5004 v -4.999 H 17.5 V 7.4998 Z" /> @@ -93020,28 +93778,28 @@ inkscape:radius="1" inkscape:original="M 108 192.36133 L 108 214.36133 L 110 214.36133 L 110 203.36133 L 111 203.36133 L 111 213.36133 L 113 213.36133 L 113 201.36133 L 114 201.36133 L 114 214.36133 L 117 214.36133 L 117 202.36133 L 118 202.36133 L 118 215.36133 L 120 215.36133 L 120 201.36133 L 121 201.36133 L 121 212.36133 L 123 212.36133 L 123 202.36133 L 124 202.36133 L 124 214.36133 L 125 214.36133 L 125 197.36133 L 120 192.36133 L 108 192.36133 z " xlink:href="#path18406" - style="color:#000000;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" + style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;marker:none;enable-background:accumulate" id="path18446" inkscape:href="#path18406" - d="m 127.90625,191.375 a 1.0001,1.0001 0 0 0 -0.90625,1 l 0,22 a 1.0001,1.0001 0 0 0 1,1 l 2,0 a 1.0001,1.0001 0 0 0 1,-1 l 2,0 a 1.0001,1.0001 0 0 0 1,1 l 1,0 a 1.0001,1.0001 0 0 0 1,-1 l 0,-5 1,0 0,5 a 1.0001,1.0001 0 0 0 1,1 l 2,0 a 1.0001,1.0001 0 0 0 0.46875,-0.125 1.0001,1.0001 0 0 0 0.0312,0 1.0001,1.0001 0 0 0 0.5,0.125 l 2,0 a 1.0001,1.0001 0 0 0 0.46875,-0.125 1.0001,1.0001 0 0 0 0.0312,0 1.0001,1.0001 0 0 0 0.5,0.125 l 1,0 a 1.0001,1.0001 0 0 0 1,-1 l 0,-17 a 1.0001,1.0001 0 0 0 -0.28125,-0.71875 l -5,-5 A 1.0001,1.0001 0 0 0 140,191.375 l -12,0 a 1.0001,1.0001 0 0 0 -0.0937,0 z" /> + d="m 108,191.36133 a 1.0001,1.0001 0 0 0 -1,1 v 22 a 1.0001,1.0001 0 0 0 1,1 h 2 a 1.0001,1.0001 0 0 0 1,-1 h 2 a 1.0001,1.0001 0 0 0 1,1 h 3 a 1.0001,1.0001 0 0 0 1,1 h 2 a 1.0001,1.0001 0 0 0 1,-1 v -2 h 2 v 1 a 1.0001,1.0001 0 0 0 1,1 h 1 a 1.0001,1.0001 0 0 0 1,-1 v -17 a 1.0001,1.0001 0 0 0 -0.29297,-0.70703 l -5,-5 A 1.0001,1.0001 0 0 0 120,191.36133 Z" /> + d="m 108,192.36218 v 22 h 2 v -11 h 1 v 10 h 2 v -12 h 1 v 13 h 3 v -12 h 1 v 13 h 2 v -14 h 1 v 11 h 2 v -10 h 1 v 12 h 1 v -17 l -5,-5 z" + style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;marker:none;enable-background:accumulate" /> + style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#cccccc;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10;marker:none;enable-background:accumulate" + d="m 118.43116,207.75778 v 1 h 1 v -1 z m 1,1 v 4 h 1 v -4 z m 0,4 h -1 v 1 h 1 z m -1,0 v -4 h -1 v 4 z m 3,-4 h 1 v -1 h 1 v 5 h 1 v 1 h -3 v -1 h 1 v -3 h -1 v -1 m -11,-1 v 1 h 1 v -1 z m 1,1 v 4 h 1 v -4 z m 0,4 h -1 v 1 h 1 z m -1,0 v -4 h -1 v 4 z m 3,-4 h 1 v -1 h 1 v 5 h 1 v 1 h -3 v -1 h 1 v -3 h -1 v -1 m 9,-9 v 1 h 1 v -1 z m 1,1 v 4 h 1 v -4 z m 0,4 h -1 v 1 h 1 z m -1,0 v -4 h -1 v 4 z m -5,-4 h 1 v -1 h 1 v 5 h 1 v 1 h -3 v -1 h 1 v -3 h -1 v -1 m -7,-1 v 1 h 1 v -1 z m 1,1 v 4 h 1 v -4 z m 0,4 h -1 v 1 h 1 z m -1,0 v -4 h -1 v 4 z m 3,-4 h 1 v -1 h 1 v 5 h 1 v 1 h -3 v -1 h 1 v -3 h -1 v -1" /> @@ -93367,9 +94125,9 @@ transform="matrix(0.27063582,0.04354624,-0.04354624,0.27063582,790.21268,127.46614)"> + d="m 64.661,6.0611 c -4.831,1.9764 -8.619,6.2529 -9.679,11.757 -0.673,3.493 -0.092,6.917 1.339,9.901 -3.895,8.12 -19.113,29.069 -26.331,36.587 -6.581,0.93 -12.171,5.856 -13.497,12.74 -1.006,5.227 0.712,10.321 4.096,13.932 l 2.098,-10.894 c 0.523,-2.72 3.135,-4.488 5.855,-3.964 l 7.917,1.525 c 2.72,0.523 4.488,3.135 3.964,5.855 l -2.186,11.354 c 4.83,-1.977 8.619,-6.253 9.679,-11.757 0.842,-4.372 -0.202,-8.667 -2.544,-12.074 5.389,-9.026 18.947,-28.336 26.036,-34.225 7.218,-0.328 13.571,-5.527 14.996,-12.929 1.007,-5.228 -0.711,-10.321 -4.095,-13.932 l -2.098,10.894 c -0.524,2.72 -3.135,4.488 -5.855,3.964 l -7.917,-1.525 c -2.721,-0.524 -4.489,-3.135 -3.965,-5.855 z" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd runtime/PLCObject.py --- a/runtime/PLCObject.py Wed Jun 30 15:44:32 2021 +0200 +++ b/runtime/PLCObject.py Thu Sep 02 21:36:29 2021 +0200 @@ -113,7 +113,9 @@ if autostart: if self.LoadPLC(): self.StartPLC() - return + else: + self._fail(_("Problem autostarting PLC : can't load PLC")) + return except Exception: self.PLCStatus = PlcStatus.Empty self.CurrentPLCFilename = None @@ -269,7 +271,12 @@ def LoadPLC(self): res = self._LoadPLC() if res: - self.PythonRuntimeInit() + try: + self.PythonRuntimeInit() + except Exception: + self._loading_error = traceback.format_exc() + PLCprint(self._loading_error) + return False else: self._FreePLC() @@ -680,9 +687,9 @@ if self.LoadPLC(): self.PLCStatus = PlcStatus.Stopped + self.StatusChange() else: - self.PLCStatus = PlcStatus.Broken - self.StatusChange() + self._fail(_("Problem installing new PLC : can't load PLC")) return self.PLCStatus == PlcStatus.Stopped return False diff -r 577118ebd179 -r c89fc366bebd runtime/WampClient.py --- a/runtime/WampClient.py Wed Jun 30 15:44:32 2021 +0200 +++ b/runtime/WampClient.py Thu Sep 02 21:36:29 2021 +0200 @@ -198,9 +198,15 @@ def GetConfiguration(): global lastKnownConfig + WampClientConf = None + if os.path.exists(_WampConf): - WampClientConf = json.load(open(_WampConf)) - else: + try: + WampClientConf = json.load(open(_WampConf)) + except ValueError: + pass + + if WampClientConf is None: WampClientConf = defaultWampConfig.copy() for itemName in mandatoryConfigItems: diff -r 577118ebd179 -r c89fc366bebd runtime/spawn_subprocess.py --- a/runtime/spawn_subprocess.py Wed Jun 30 15:44:32 2021 +0200 +++ b/runtime/spawn_subprocess.py Thu Sep 02 21:36:29 2021 +0200 @@ -7,6 +7,7 @@ from __future__ import absolute_import import os import signal +import shlex import posix_spawn PIPE = "42" @@ -104,9 +105,9 @@ cmd = [] if isinstance(args[0], str): if len(args) == 1: - # oversimplified splitting of arguments, - # TODO: care about use of simple and double quotes - cmd = args[0].split() + # splitting of arguments that cares about + # use of simple and double quotes + cmd = shlex.split(args[0]) else: cmd = args elif isinstance(args[0], list) and len(args) == 1: diff -r 577118ebd179 -r c89fc366bebd setup.py --- a/setup.py Wed Jun 30 15:44:32 2021 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,15 +0,0 @@ -""" -Install Beremiz -""" -from setuptools import setup - -setup( - name='beremiz', - version='0.1', - install_requires=["Twisted == 20.3.0", "attrs == 19.2.0", "Automat == 0.3.0", - "zope.interface == 4.4.2", "Nevow == 0.14.5", "PyHamcrest == 2.0.2", - "Pygments == 2.9.0", "Pyro == 3.16", "constantly == 15.1.0", - "future == 0.18.2", "hyperlink == 21.0.0", "incremental == 21.3.0", - "pathlib == 1.0.1", "prompt-toolkit == 3.0.19", "zeroconf-py2compat == 0.19.10", - "idna == 2.10"] -) diff -r 577118ebd179 -r c89fc366bebd svghmi/Makefile --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/Makefile Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,26 @@ +#! gmake + +# Makefile to generate XSLT stylesheets from ysl2 files in the same directory + +# This uses YML2. +# hg clone https://pep.foundation/dev/repos/yml2/ + +# It should be just fine if yml2 is cloned just asside beremiz +# otherwise, point yml2path to yml2 source directory +# make yml2path=path/to/yml/dir + +yml2path ?= $(abspath ../../yml2) + +ysl2files := gen_index_xhtml.ysl2 gen_dnd_widget_svg.ysl2 analyse_widget.ysl2 +ysl2includes := $(filter-out $(ysl2files), $(wildcard *.ysl2)) +xsltfiles := $(patsubst %.ysl2, %.xslt, $(ysl2files)) + +all:$(xsltfiles) + +%.xslt: %.ysl2 $(ysl2includes) svghmi.js ../yslt_noindent.yml2 + $(yml2path)/yml2c -I $(yml2path):../ $< -o $@.tmp + xmlstarlet fo $@.tmp > $@ + rm $@.tmp + +clean: + rm -f $(xsltfiles) diff -r 577118ebd179 -r c89fc366bebd svghmi/README --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/README Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,1 @@ +SVG HMI diff -r 577118ebd179 -r c89fc366bebd svghmi/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/__init__.py Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,10 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz +# Copyright (C) 2019: Edouard TISSERANT +# +# See COPYING file for copyrights details. + +from __future__ import absolute_import +from svghmi.svghmi import * diff -r 577118ebd179 -r c89fc366bebd svghmi/analyse_widget.xslt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/analyse_widget.xslt Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,678 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Widget id: + + label: + + has wrong syntax of path section + + + + + + + + + PAGE_LOCAL + + + + + HMI_LOCAL + + + + + + + + Widget id: + + label: + + path section + + use min and max on non mumeric value + + + + + + + + + + + + + + + + + + + + + + + + + + : + + + + @ + + + , + + , + + + + + HMI: + + + + + + + + + + + AnimateRotation - DEPRECATED, do not use. + + Doesn't follow WYSIWYG principle, and forces user to add animateTransform tag in SVG (using inkscape XML editor for exemple) + + + + AnimateRotation - DEPRECATED + + + speed + + + + + + + + Back widget brings focus back to previous page in history when clicked. + + + + Jump to previous page + + + + + + + + Button widget takes one boolean variable path, and reflect current true + + or false value by showing "active" or "inactive" labeled element + + respectively. Pressing and releasing button changes variable to true and + + false respectively. Potential inconsistency caused by quick consecutive + + presses on the button is mitigated by using a state machine that wait for + + previous state change to be reflected on variable before applying next one. + + + + Push button reflecting consistently given boolean variable + + + Boolean variable + + + + + + + + CircularBar widget changes the end angle of a "path" labeled arc according + + to value of the single accepted variable. + + + + If "min" a "max" labeled texts are provided, then they are used as + + respective minimum and maximum value. Otherwise, value is expected to be + + in between 0 and 100. + + + + If "value" labeled text is found, then its content is replaced by value. + + + + Change end angle of Inkscape's arc + + + Value to display + + + + + + + + CircularSlider - DEPRECATED, to be replaced by PathSlider + + This widget moves "handle" labeled group along "range" labeled + + arc, according to value of the single accepted variable. + + + + If "min" a "max" labeled texts are provided, or if first and second + + argument are given, then they are used as respective minimum and maximum + + value. Otherwise, value is expected to be in between 0 and 100. + + + + If "value" labeled text is found, then its content is replaced by value. + + During drag, "setpoint" labeled group is moved to position defined by user + + while "handle" reflects current value from variable. + + + + CircularSlider - DEPRECATED + + + minimum value + + + maximum value + + + Value to display + + + + + + + + CustomHtml widget allows insertion of HTML code in a svg:foreignObject. + + Widget content is replaced by foreignObject. HTML code is obtained from + + "code" labeled text content. HTML insert position and size is given with + + "container" labeled element. + + + + Custom HTML insert + + + + + + + + If Display widget is a svg:text element, then text content is replaced by + + value of given variables, space separated. + + + + Otherwise, if Display widget is a group containing a svg:text element + + labelled "format", then text content is replaced by printf-like formated + + string. In other words, if "format" labeled text is "%d %s %f", then 3 + + variables paths are expected : HMI_IN, HMI_STRING and HMI_REAL. + + + + In case Display widget is a svg::text element, it is also possible to give + + format string as first argument. + + + + Printf-like formated text display + + + printf-like format string when not given as svg:text + + + variables to be displayed + + + + + + + + DropDown widget let user select an entry in a list of texts, given as + + arguments. Single variable path is index of selection. + + + + It needs "text" (svg:text), "box" (svg:rect), "button" (svg:*), + + and "highlight" (svg:rect) labeled elements. + + + + When user clicks on "button", "text" is duplicated to display enties in the + + limit of available space in page, and "box" is extended to contain all + + texts. "highlight" is moved over pre-selected entry. + + + + When only one argument is given, and argment contains "#langs" then list of + + texts is automatically set to the list of human-readable languages supported + + by this HMI. + + + + Let user select text entry in a drop-down menu + + + drop-down menu entries + + + selection index + + + + + + + + ForEach widget is used to span a small set of widget over a larger set of + + repeated HMI_NODEs. + + + + Idea is somewhat similar to relative page, but it all happens inside the + + ForEach widget, no page involved. + + + + Together with relative Jump widgets it can be used to build a menu to reach + + relative pages covering many identical HMI_NODES siblings. + + + + ForEach widget takes a HMI_CLASS name as argument and a HMI_NODE path as + + variable. + + + + Direct sub-elements can be either groups of widget to be spanned, labeled + + "ClassName:offset", or buttons to control the spanning, labeled + + "ClassName:+/-number". + + + + span widgets over a set of repeated HMI_NODEs + + + HMI_CLASS name + + + where to find HMI_NODEs whose HMI_CLASS is class_name + + + + + + + + Input widget takes one variable path, and displays current value in + + optional "value" labeled sub-element. + + + + Click on optional "edit" labeled element opens keypad to edit value. + + + + Operation on current value is performed when click on sub-elements with + + label starting with '=', '+' or '-' sign. Value after sign is used as + + operand. + + + + Input field with predefined operation buttons + + + optional printf-like format + + + single variable to edit + + + + + + + + Send given variables as POST to http URL argument, spread returned JSON in + + SVG sub-elements of "data" labeled element. + + + + Documentation to be written. see svbghmi exemple. + + + + Http POST variables, spread JSON back + + + + + + single variable to edit + + + + + + + + Jump widget brings focus to a different page. Mandatory single argument + + gives name of the page. + + + + Optional single path is used as new reference when jumping to a relative + + page, it must point to a HMI_NODE. + + + + "active"+"inactive" labeled elements can be provided and reflect current + + page being shown. + + + + "disabled" labeled element, if provided, is shown instead of "active" or + + "inactive" widget when pointed HMI_NODE is null. + + + + Jump to given page + + + name of page to jump to + + + reference for relative jump + + + + + + + + Keypad - to be written + + + + Keypad + + + keypad can input those types + + + + + + + + + + + + + Meter widget moves the end of "needle" labeled path along "range" labeled + + path, according to value of the single accepted variable. + + + + Needle is reduced to a single segment. If "min" a "max" labeled texts + + are provided, or if first and second argument are given, then they are used + + as respective minimum and maximum value. Otherwise, value is expected to be + + in between 0 and 100. + + + + If "value" labeled text is found, then its content is replaced by value. + + + + Moves "needle" along "range" + + + minimum value + + + maximum value + + + Value to display + + + + + + + + ScrollBar - documentation to be written + + + + ScrollBar + + + value + + + range + + + visible + + + + + + + + Slider - DEPRECATED - use ScrollBar or PathSlider instead + + + + Slider - DEPRECATED - use ScrollBar instead + + + value + + + range + + + visible + + + + + + + + Switch widget hides all subelements whose label do not match given + + variable current value representation. For exemple if given variable type + + is HMI_INT and value is 1, then elements with label '1' will be displayed. + + Label can have comments, so '1#some comment' would also match. If matching + + variable of type HMI_STRING, then double quotes must be used. For exemple, + + '"hello"' or '"hello"#another comment' match HMI_STRING 'hello'. + + + + Show elements whose label match value. + + + value to compare to labels + + + + + + + + Button widget takes one boolean variable path, and reflect current true + + or false value by showing "active" or "inactive" labeled element + + respectively. Clicking or touching button toggles variable. + + + + Toggle button reflecting given boolean variable + + + Boolean variable + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/analyse_widget.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/analyse_widget.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,49 @@ +include yslt_noindent.yml2 + +in xsl decl widget_desc(%name, match="widget[@type='%name']", mode="widget_desc") alias template { + type > «@type» + content; +}; + +decl nothing alias - ; +decl widget_class(%name) alias - {nothing}; +decl widget_defs(%name) alias - {nothing}; +decl widget_page(%name) alias - {nothing}; +decl gen_index_xhtml alias - {nothing}; +decl emit(*name) alias - {nothing}; + +istylesheet + /* From Inkscape */ + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + + extension-element-prefixes="ns func exsl regexp str dyn" + exclude-result-prefixes="ns func exsl regexp str dyn svg inkscape" { + + const "indexed_hmitree", "/.."; // compatibility with parse_labels.ysl2 + include parse_labels.ysl2 + + const "hmi_elements", "//svg:*[starts-with(@inkscape:label, 'HMI:')]"; + + include widget_*.ysl2 + + template "@* | node()", mode="document" { + xsl:copy apply "@* | node()", mode="document"; + } + + template "widget", mode="document" { + xsl:copy { + apply "@* | node()", mode="document"; + defs apply ".", mode="widget_desc"; + } + } + + template "/" { + const "widgets" + apply "$hmi_elements", mode="parselabel"; + const "widget_ns", "exsl:node-set($widgets)"; + widgets + apply "$widget_ns", mode="document"; + } + +} diff -r 577118ebd179 -r c89fc366bebd svghmi/default.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/default.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,28 @@ + + + + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/detachable_pages.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/detachable_pages.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,225 @@ +// detachable_pages.ysl2 +// +// compute what elements are required by pages +// and decide where to cut when removing/attaching +// pages elements on page switch + +const "hmi_pages_descs", "$parsed_widgets/widget[@type = 'Page']"; +const "hmi_pages", "$hmi_elements[@id = $hmi_pages_descs/@id]"; + +const "default_page" choose { + when "count($hmi_pages) > 1" { + choose { + when "$hmi_pages_descs/arg[1]/@value = 'Home'" > Home + otherwise { + error > No Home page defined! + } + } + } + when "count($hmi_pages) = 0" { + error > No page defined! + } + otherwise > «func:widget($hmi_pages/@id)/arg[1]/@value» +} + +emit "preamble:default-page" { + | + | var default_page = "«$default_page»"; +} + +const "keypads_descs", "$parsed_widgets/widget[@type = 'Keypad']"; +const "keypads", "$hmi_elements[@id = $keypads_descs/@id]"; + +// returns all directly or indirectly refered elements +def "func:refered_elements" { + param "elems"; + const "descend", "$elems/descendant-or-self::svg:*"; + const "clones", "$descend[self::svg:use]"; + const "originals", "//svg:*[concat('#',@id) = $clones/@xlink:href]"; + choose { + when "$originals" + result "$descend | func:refered_elements($originals)"; + otherwise + result "$descend"; + } +} + +// variable "overlapping_geometry" was added for optimization. +// It avoids calling func:overlapping_geometry 3 times for each page +// (apparently libxml doesn't cache exslt function results) +// in order to optimize further, func:overlapping_geometry +// should be implemented in python or even C, +// as this is still the main bottleneck here +const "_overlapping_geometry" { + foreach "$hmi_pages | $keypads" { + const "k", "concat('overlapping:', @id)"; + value "ns:ProgressStart($k, concat('collecting membership of ', @inkscape:label))"; + elt { + attrib "id" > «@id» + copy "func:overlapping_geometry(.)"; + } + value "ns:ProgressEnd($k)"; + } +} + +const "overlapping_geometry", "exsl:node-set($_overlapping_geometry)"; + +def "func:all_related_elements" { + param "page"; + const "page_overlapping_geometry", "$overlapping_geometry/elt[@id = $page/@id]/*"; + const "page_overlapping_elements", "//svg:*[@id = $page_overlapping_geometry/@Id]"; + const "page_sub_elements", "func:refered_elements($page | $page_overlapping_elements)"; + result "$page_sub_elements"; +} + + +def "func:required_elements" { + param "pages"; + choose{ + when "$pages"{ + result """func:all_related_elements($pages[1]) + | func:required_elements($pages[position()!=1])"""; + }otherwise{ + result "/.."; + } + } +} + +const "required_page_elements", + "func:required_elements($hmi_pages | $keypads)/ancestor-or-self::svg:*"; + +const "hmi_lists_descs", "$parsed_widgets/widget[@type = 'List']"; +const "hmi_lists", "$hmi_elements[@id = $hmi_lists_descs/@id]"; + +const "required_list_elements", "func:refered_elements($hmi_lists[@id = $required_page_elements/@id])"; + +const "required_elements", "$defs | $required_list_elements | $required_page_elements"; + +const "discardable_elements", "//svg:*[not(@id = $required_elements/@id)]"; + +def "func:sumarized_elements" { + param "elements"; + const "short_list", "$elements[not(ancestor::*/@id = $elements/@id)]"; + const "filled_groups", """$short_list/parent::*[ + not(child::*[ + not(@id = $discardable_elements/@id) and + not(@id = $short_list/@id) + ])]"""; + const "groups_to_add", "$filled_groups[not(ancestor::*/@id = $filled_groups/@id)]"; + result "$groups_to_add | $short_list[not(ancestor::*/@id = $filled_groups/@id)]"; +} + +def "func:detachable_elements" { + param "pages"; + choose{ + when "$pages"{ + result """func:sumarized_elements(func:all_related_elements($pages[1])) + | func:detachable_elements($pages[position()!=1])"""; + }otherwise{ + result "/.."; + } + } +} + +// Avoid nested detachables +const "_detachable_elements", "func:detachable_elements($hmi_pages | $keypads)"; +const "detachable_elements", "$_detachable_elements[not(ancestor::*/@id = $_detachable_elements/@id)]"; + +emit "declarations:detachable-elements" { + | + | var detachable_elements = { + foreach "$detachable_elements"{ + | "«@id»":[id("«@id»"), id("«../@id»")]`if "position()!=last()" > ,` + } + | } +} + +const "forEach_widgets_ids", "$parsed_widgets/widget[@type = 'ForEach']/@id"; +const "forEach_widgets", "$hmi_widgets[@id = $forEach_widgets_ids]"; +const "in_forEach_widget_ids", "func:refered_elements($forEach_widgets)[not(@id = $forEach_widgets_ids)]/@id"; + +template "svg:*", mode="page_desc" { + if "ancestor::*[@id = $hmi_pages/@id]" error > HMI:Page «@id» is nested in another HMI:Page + + + const "desc", "func:widget(@id)"; + const "pagename", "$desc/arg[1]/@value"; + const "msg", "concat('generating page description ', $pagename)"; + value "ns:ProgressStart($pagename, $msg)"; + const "page", "."; + const "p", "$geometry[@Id = $page/@id]"; + + const "page_all_elements", "func:all_related_elements($page)"; + + const "all_page_widgets","$hmi_widgets[@id = $page_all_elements/@id and @id != $page/@id]"; + const "page_managed_widgets","$all_page_widgets[not(@id=$in_forEach_widget_ids)]"; + const "page_relative_widgets", + "$page_managed_widgets[func:is_descendant_path(func:widget(@id)/path/@value, $desc/path/@value)]"; + + // Take closest ancestor in detachable_elements + // since nested detachable elements are filtered out + const "sumarized_page", + """func:sumarized_elements($page_all_elements)"""; + + const "required_detachables", + """$sumarized_page/ + ancestor-or-self::*[@id = $detachable_elements/@id]"""; + + | "«$pagename»": { + //| widget: hmi_widgets["«@id»"], + | bbox: [«$p/@x», «$p/@y», «$p/@w», «$p/@h»], + if "$desc/path/@value" { + if "count($desc/path/@index)=0" + warning > Page id="«$page/@id»" : No match for path "«$desc/path/@value»" in HMI tree + | page_index: «$desc/path/@index», + } + | widgets: [ + foreach "$page_managed_widgets" { + const "widget_paths_relativeness" + foreach "func:widget(@id)/path" { + value "func:is_descendant_path(@value, $desc/path/@value)"; + if "position()!=last()" > , + } + | [hmi_widgets["«@id»"], [«$widget_paths_relativeness»]]`if "position()!=last()" > ,` + } + | ], + | jumps: [ + foreach "$parsed_widgets/widget[@id = $all_page_widgets/@id and @type='Jump']" { + | hmi_widgets["«@id»"]`if "position()!=last()" > ,` + } + | ], + | required_detachables: { + foreach "$required_detachables" { + | "«@id»": detachable_elements["«@id»"]`if "position()!=last()" > ,` + } + | } + apply "$parsed_widgets/widget[@id = $all_page_widgets/@id]", mode="widget_page"{ + with "page_desc", "$desc"; + } + | }`if "position()!=last()" > ,` + value "ns:ProgressEnd($pagename)"; +} + +emit "definitions:page-desc" { + | + | var page_desc = { + apply "$hmi_pages", mode="page_desc"; + | } +} + +template "*", mode="widget_page"; + + +emit "debug:detachable-pages" { + | + | DETACHABLES: + foreach "$detachable_elements"{ + | «@id» + } + | In Foreach: + foreach "$in_forEach_widget_ids"{ + | «.» + } + | Overlapping + apply "$overlapping_geometry", mode="testtree"; +} diff -r 577118ebd179 -r c89fc366bebd svghmi/fonts.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/fonts.py Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz +# Copyright (C) 2021: Edouard TISSERANT +# +# See COPYING file for copyrights details. + +from __future__ import print_function +import sys +from base64 import b64encode + +from fontTools import ttLib + +# Inkscape seems to refer to font with different family name depending on platform. +# this are heuristics that would need extensive testing to be sure. +FamilyNameIDs = [1] if sys.platform.startswith('win') else [1, 16] + +def GetFontTypeAndFamilyName(filename): + """ + Getting font family, format and MIME type + """ + + familyname = None + uniquename = None + formatname = None + mimetype = None + + font = ttLib.TTFont(filename) + # https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6name.html + for name in font["name"].names: + #print(name.nameID, name.platformID, name.langID, name.toUnicode()) + if name.nameID in FamilyNameIDs and name.platformID==3 and name.langID==1033: + familyname = name.toUnicode() + if name.nameID==4 and name.platformID==3 and name.langID==1033: + uniquename = name.toUnicode() + + if font.flavor : + # woff and woff2 + formatname = font.flavor + mimetype = "font/" + formatname + # conditions on sfntVersion was deduced from fontTools.ttLib.sfnt + elif font.sfntVersion in ("\x00\x01\x00\x00", "true"): + formatname = "truetype" + mimetype = "font/ttf" + elif font.sfntVersion == "OTTO": + formatname = "opentype" + mimetype = "font/otf" + + return familyname,uniquename,formatname,mimetype + +def DataURIFromFile(filename, mimetype): + with open(filename, "rb") as fp: + data = fp.read() + return "".join([ + "data:", + mimetype, + ";base64,", + b64encode(data).strip()]) + +def GetCSSFontFaceFromFontFile(filename): + familyname, uniquename, formatname, mimetype = GetFontTypeAndFamilyName(filename) + data_uri = DataURIFromFile(filename, mimetype) + css_font_face = \ + """ + @font-face {{ + font-family: "{}"; + src: url("{}") format("{}") + }} + """.format(familyname, data_uri, formatname) + return css_font_face + + +# tests +if __name__ == '__main__': + print(GetCSSFontFaceFromFontFile("/usr/share/matplotlib/mpl-data/fonts/ttf/DejaVuSans.ttf")) + print(GetCSSFontFaceFromFontFile("/usr/share/fonts/opentype/urw-base35/NimbusSans-Regular.otf")) + print(GetCSSFontFaceFromFontFile("/usr/share/yelp/mathjax/fonts/HTML-CSS/TeX/woff/MathJax_SansSerif-Regular.woff")) diff -r 577118ebd179 -r c89fc366bebd svghmi/gen_dnd_widget_svg.xslt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/gen_dnd_widget_svg.xslt Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Widget id: + + label: + + has wrong syntax of path section + + + + + + + + + PAGE_LOCAL + + + + + HMI_LOCAL + + + + + + + + Widget id: + + label: + + path section + + use min and max on non mumeric value + + + + + + + + + + + + + + + + + + + + + + + + + + : + + + + @ + + + , + + , + + + + + HMI: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Widget dropped in Inkscape from Beremiz + + + + + No widget detected on selected SVG + + + + + Multiple widget DnD not yet supported + + + + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/gen_dnd_widget_svg.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/gen_dnd_widget_svg.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,79 @@ +include yslt_noindent.yml2 + +in xsl decl svgtmpl(match, xmlns="http://www.w3.org/2000/svg") alias template; + +istylesheet + /* From Inkscape */ + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + + /* Namespace to invoke python code */ + xmlns:ns="beremiz" + + extension-element-prefixes="ns func exsl regexp str dyn" + exclude-result-prefixes="ns func exsl regexp str dyn" { + + const "hmi_elements", "//svg:*[starts-with(@inkscape:label, 'HMI:')]"; + const "widgetparams", "ns:GetWidgetParams()"; + + const "indexed_hmitree", "/.."; // compatibility with parse_labels.ysl2 + include parse_labels.ysl2 + const "_parsed_widgets" apply "$hmi_elements", mode="parselabel"; + const "parsed_widgets","exsl:node-set($_parsed_widgets)"; + + const "svg_widget", "$parsed_widgets/widget[1]"; + const "svg_widget_type", "$svg_widget/@type"; + const "svg_widget_path", "$svg_widget/@path"; + const "svg_widget_count", "count($parsed_widgets/widget)"; + + // Templates to change label paths(s) + template "@* | node()", mode="replace_params" { + xsl:copy apply "@* | node()", mode="replace_params"; + } + + template "arg", mode="replace_params"; + template "path", mode="replace_params"; + template "widget", mode="replace_params" { + xsl:copy { + apply "@* | node()", mode="replace_params"; + copy "$widgetparams/*"; + }; + } + + // all attribs are usually copied + svgtmpl "@*", mode="inline_svg" xsl:copy; + + // except labels, ignored + svgtmpl "@inkscape:label[starts-with(., 'HMI:')]", mode="inline_svg"; + + template "node()", mode="inline_svg" xsl:copy { + + // in case this node widget's main element inject label + if "@id = $svg_widget/@id" { + const "substituted_widget" apply "$svg_widget", mode="replace_params"; + const "substituted_widget_ns", "exsl:node-set($substituted_widget)"; + const "new_label" apply "$substituted_widget_ns", mode="genlabel"; + attrib "inkscape:label" > «$new_label» + } + // all nodes are copied as well + apply "@* | node()", mode="inline_svg"; + } + + template "/" { + comment > Widget dropped in Inkscape from Beremiz + + choose { + when "$svg_widget_count < 1" + error > No widget detected on selected SVG + when "$svg_widget_count > 1" + error > Multiple widget DnD not yet supported + } + + apply "/", mode="inline_svg"; + } +} diff -r 577118ebd179 -r c89fc366bebd svghmi/gen_index_xhtml.xslt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/gen_index_xhtml.xslt Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,8567 @@ + + + + + + + + + HMI_PLC_STATUS + + + HMI_CURRENT_PAGE + + + + + + + + + + + + /* + + */ + + + + var hmi_hash = [ + + ]; + + + + var heartbeat_index = + + ; + + + + var hmitree_types = [ + + + /* + + */ " + + " + + , + + + + + ]; + + + + var hmitree_paths = [ + + + /* + + */ " + + " + + , + + + + + ]; + + + + + + + + + + + + + + / + + + / + + + + + / + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Widget id: + + label: + + has wrong syntax of path section + + + + + + + + + PAGE_LOCAL + + + + + HMI_LOCAL + + + + + + + + Widget id: + + label: + + path section + + use min and max on non mumeric value + + + + + + + + + + + + + + + + + + + + + + + + + + : + + + + @ + + + , + + , + + + + + HMI: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + =" + + " + + + + + + + + + + + + + + /* + + */ + + + + Raw HMI tree + + + + + Indexed HMI tree + + + + + Parsed Widgets + + + + + + + + + + + + + + /* + + */ + + + + ID, x, y, w, h + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Home + + + + No Home page defined! + + + + + + + No page defined! + + + + + + + + + + + + /* + + */ + + + + + + var default_page = " + + "; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /* + + */ + + + + + + var detachable_elements = { + + + " + + ":[id(" + + "), id(" + + ")] + + , + + + + + } + + + + + + + + + + + HMI:Page + + is nested in another HMI:Page + + + + + + + + + + + + + + + " + + ": { + + bbox: [ + + , + + , + + , + + ], + + + + + Page id=" + + " : No match for path " + + " in HMI tree + + + page_index: + + , + + + widgets: [ + + + + + + + , + + + + [hmi_widgets[" + + "], [ + + ]] + + , + + + + + ], + + jumps: [ + + + hmi_widgets[" + + "] + + , + + + + + ], + + required_detachables: { + + + " + + ": detachable_elements[" + + "] + + , + + + + + } + + + + + } + + , + + + + + + + + + + /* + + */ + + + + + + var page_desc = { + + + } + + + + + + + + + + /* + + */ + + + + + + DETACHABLES: + + + + + + + + In Foreach: + + + + + + + + Overlapping + + + + + + + + + + + + + + + + + + + + + none + + + 100vh + + + 100vw + + + + + + + ViewBox settings other than X=0, Y=0 and Scale=1 are not supported + + + + + All units must be set to "px" in Inkscape's document properties + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + href + + + width + + + height + + + x + + + y + + + id + + + + + + transform + + + style + + + + + + + + + + + + _ + + + + + + + + + + + + + + + + + + + + + + + + + + ; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + _ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /* + + */ + + + + let id = document.getElementById.bind(document); + + var svg_root = id(" + + "); + + + + + + + + + /* + + */ + + + + + + Unlinked : + + + + + + + Not to unlink : + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /* + + */ + + + + + var langs = [ ["Default", "C"], + + [" + + "," + + "] + + , + + + ]; + + var translations = [ + + + + + + [[ + + id(" + + ") + + , + + + ],[ + + " + + + + \n + + + " + + , + + + ]] + + , + + + + + ] + + + + + + + + + + " + + " + + , + + + + + + + + + + + Widget + + id=" + + " : No match for path " + + " in HMI tree + + undefined + + + " + + " + + + hmi_local_index(" + + ") + + + + Internal error while processing widget's non indexed HMI tree path : unknown type + + + + + + + + + + , + + + + + + + + [ + + , + + ] + + + undefined + + + + , + + + + " + + ": new + + Widget (" + + ",[ + + ],[ + + ],[ + + ],{ + + + + + }) + + , + + + + + + + + + /* + + */ + + + + + + let hmi_locals = {}; + + var last_remote_index = hmitree_types.length - 1; + + var next_available_index = hmitree_types.length; + + let cookies = new Map(document.cookie.split("; ").map(s=>s.split("="))); + + + + const local_defaults = { + + + + + VarInit + + must have only one variable given. + + + + + VarInit + + only applies to HMI variable. + + + " + + ": + + + cookies.has(" + + ")?cookies.get(" + + "): + + + + + + + + + + , + + + }; + + + + const persistent_locals = new Set([ + + + " + + " + + , + + + + + ]); + + var persistent_indexes = new Map(); + + var cache = hmitree_types.map(_ignored => undefined); + + var updates = new Map(); + + + + function page_local_index(varname, pagename){ + + let pagevars = hmi_locals[pagename]; + + let new_index; + + if(pagevars == undefined){ + + new_index = next_available_index++; + + hmi_locals[pagename] = {[varname]:new_index} + + } else { + + let result = pagevars[varname]; + + if(result != undefined) { + + return result; + + } + + + + new_index = next_available_index++; + + pagevars[varname] = new_index; + + } + + let defaultval = local_defaults[varname]; + + if(defaultval != undefined) { + + cache[new_index] = defaultval; + + updates.set(new_index, defaultval); + + if(persistent_locals.has(varname)) + + persistent_indexes.set(new_index, varname); + + } + + return new_index; + + } + + + + function hmi_local_index(varname){ + + return page_local_index(varname, "HMI_LOCAL"); + + } + + + + + + + + + /* + + */ + + + + var pending_widget_animates = []; + + + + class Widget { + + offset = 0; + + frequency = 10; /* FIXME arbitrary default max freq. Obtain from config ? */ + + unsubscribable = false; + + pending_animate = false; + + + + constructor(elt_id,args,indexes,minmaxes,members){ + + this.element_id = elt_id; + + this.element = id(elt_id); + + this.args = args; + + this.indexes = indexes; + + this.minmaxes = minmaxes; + + Object.keys(members).forEach(prop => this[prop]=members[prop]); + + this.lastapply = indexes.map(() => undefined); + + this.inhibit = indexes.map(() => undefined); + + this.pending = indexes.map(() => undefined); + + this.bound_unhinibit = this.unhinibit.bind(this); + + } + + + + unsub(){ + + /* remove subsribers */ + + if(!this.unsubscribable) + + for(let i = 0; i < this.indexes.length; i++) { + + /* flush updates pending because of inhibition */ + + let inhibition = this.inhibit[i]; + + if(inhibition != undefined){ + + clearTimeout(inhibition); + + this.lastapply[i] = undefined; + + this.unhinibit(i); + + } + + let index = this.indexes[i]; + + if(this.relativeness[i]) + + index += this.offset; + + subscribers(index).delete(this); + + } + + this.offset = 0; + + this.relativeness = undefined; + + } + + + + sub(new_offset=0, relativeness, container_id){ + + this.offset = new_offset; + + this.relativeness = relativeness; + + this.container_id = container_id ; + + /* add this's subsribers */ + + if(!this.unsubscribable) + + for(let i = 0; i < this.indexes.length; i++) { + + let index = this.get_variable_index(i); + + if(index == undefined) continue; + + subscribers(index).add(this); + + } + + need_cache_apply.push(this); + + } + + + + apply_cache() { + + if(!this.unsubscribable) for(let index in this.indexes){ + + /* dispatch current cache in newly opened page widgets */ + + let realindex = this.get_variable_index(index); + + if(realindex == undefined) continue; + + let cached_val = cache[realindex]; + + if(cached_val != undefined) + + this._dispatch(cached_val, cached_val, index); + + } + + } + + + + get_variable_index(varnum) { + + let index = this.indexes[varnum]; + + if(typeof(index) == "string"){ + + index = page_local_index(index, this.container_id); + + } else { + + if(this.relativeness[varnum]){ + + index += this.offset; + + } + + } + + return index; + + } + + + + overshot(new_val, max) { + + } + + + + undershot(new_val, min) { + + } + + + + clip_min_max(index, new_val) { + + let minmax = this.minmaxes[index]; + + if(minmax !== undefined && typeof new_val == "number") { + + let [min,max] = minmax; + + if(new_val < min){ + + this.undershot(new_val, min); + + return min; + + } + + if(new_val > max){ + + this.overshot(new_val, max); + + return max; + + } + + } + + return new_val; + + } + + + + change_hmi_value(index, opstr) { + + let realindex = this.get_variable_index(index); + + if(realindex == undefined) return undefined; + + let old_val = cache[realindex]; + + let new_val = eval_operation_string(old_val, opstr); + + new_val = this.clip_min_max(index, new_val); + + return apply_hmi_value(realindex, new_val); + + } + + + + _apply_hmi_value(index, new_val) { + + let realindex = this.get_variable_index(index); + + if(realindex == undefined) return undefined; + + new_val = this.clip_min_max(index, new_val); + + return apply_hmi_value(realindex, new_val); + + } + + + + unhinibit(index){ + + this.inhibit[index] = undefined; + + let new_val = this.pending[index]; + + this.pending[index] = undefined; + + return this.apply_hmi_value(index, new_val); + + } + + + + apply_hmi_value(index, new_val) { + + if(this.inhibit[index] == undefined){ + + let now = Date.now(); + + let min_interval = 1000/this.frequency; + + let lastapply = this.lastapply[index]; + + if(lastapply == undefined || now > lastapply + min_interval){ + + this.lastapply[index] = now; + + return this._apply_hmi_value(index, new_val); + + } + + else { + + let elapsed = now - lastapply; + + this.pending[index] = new_val; + + this.inhibit[index] = setTimeout(this.bound_unhinibit, min_interval - elapsed, index); + + } + + } + + else { + + this.pending[index] = new_val; + + return new_val; + + } + + } + + + + new_hmi_value(index, value, oldval) { + + // TODO avoid searching, store index at sub() + + for(let i = 0; i < this.indexes.length; i++) { + + let refindex = this.get_variable_index(i); + + if(refindex == undefined) continue; + + + + if(index == refindex) { + + this._dispatch(value, oldval, i); + + break; + + } + + } + + } + + + + _dispatch(value, oldval, varnum) { + + let dispatch = this.dispatch; + + if(dispatch != undefined){ + + try { + + dispatch.call(this, value, oldval, varnum); + + } catch(err) { + + console.log(err); + + } + + } + + } + + + + _animate(){ + + this.animate(); + + this.pending_animate = false; + + } + + + + request_animate(){ + + if(!this.pending_animate){ + + pending_widget_animates.push(this); + + this.pending_animate = true; + + requestHMIAnimation(); + + } + + + + } + + + + activate_activable(eltsub) { + + eltsub.inactive.style.display = "none"; + + eltsub.active.style.display = ""; + + } + + + + inactivate_activable(eltsub) { + + eltsub.active.style.display = "none"; + + eltsub.inactive.style.display = ""; + + } + + } + + + + + + + + + + + /* + + */ + + + + + + + + + + class + + Widget extends Widget{ + + /* empty class, as + + widget didn't provide any */ + + } + + + + + + + + + + /* + + */ + + + + var hmi_widgets = { + + + } + + + + + + + + + + + + + + + + + + + widget must have a + + element + + + + + + + _elt: id(" + + "), + + + + + _sub: { + + + + + + + + + + widget must have a + + / + + element + + + /* missing + + / + + element */ + + + + " + + ": id(" + + ") + + , + + + + + + + }, + + + + + + + + + + + + + + + + + + + class + AnimateWidget + extends Widget{ + + frequency = 5; + + speed = 0; + + start = false; + + widget_center = undefined; + + + + dispatch(value) { + + this.speed = value / 5; + + + + //reconfigure animation + + this.request_animate(); + + } + + + + animate(){ + + // change animation properties + + for(let child of this.element.children){ + + if(child.nodeName.startsWith("animate")){ + + if(this.speed != 0 && !this.start){ + + this.start = true; + + this.element.beginElement(); + + } + + + + if(this.speed > 0){ + + child.setAttribute("dur", this.speed+"s"); + + } + + else if(this.speed < 0){ + + child.setAttribute("dur", (-1)*this.speed+"s"); + + } + + else{ + + this.start = false; + + this.element.endElement(); + + } + + } + + } + + } + + + + init() { + + let widget_pos = this.element.getBBox(); + + this.widget_center = [(widget_pos.x+widget_pos.width/2), (widget_pos.y+widget_pos.height/2)]; + + } + + } + + + + + + + + AnimateRotation - DEPRECATED, do not use. + + Doesn't follow WYSIWYG principle, and forces user to add animateTransform tag in SVG (using inkscape XML editor for exemple) + + + + AnimateRotation - DEPRECATED + + + speed + + + + class + AnimateRotationWidget + extends Widget{ + + frequency = 5; + + speed = 0; + + widget_center = undefined; + + + + dispatch(value) { + + this.speed = value / 5; + + + + //reconfigure animation + + this.request_animate(); + + } + + + + animate(){ + + // change animation properties + + // TODO : rewrite with proper es6 + + for(let child of this.element.children){ + + if(child.nodeName == "animateTransform"){ + + if(this.speed > 0){ + + child.setAttribute("dur", this.speed+"s"); + + child.setAttribute("from", "0 "+this.widget_center[0]+" "+this.widget_center[1]); + + child.setAttribute("to", "360 "+this.widget_center[0]+" "+this.widget_center[1]); + + } + + else if(this.speed < 0){ + + child.setAttribute("dur", (-1)*this.speed+"s"); + + child.setAttribute("from", "360 "+this.widget_center[0]+" "+this.widget_center[1]); + + child.setAttribute("to", "0 "+this.widget_center[0]+" "+this.widget_center[1]); + + } + + else{ + + child.setAttribute("from", "0 "+this.widget_center[0]+" "+this.widget_center[1]); + + child.setAttribute("to", "0 "+this.widget_center[0]+" "+this.widget_center[1]); + + } + + } + + } + + } + + + + init() { + + let widget_pos = this.element.getBBox(); + + this.widget_center = [(widget_pos.x+widget_pos.width/2), (widget_pos.y+widget_pos.height/2)]; + + } + + } + + + + + + + + Back widget brings focus back to previous page in history when clicked. + + + + Jump to previous page + + + + class + BackWidget + extends Widget{ + + on_click(evt) { + + if(jump_history.length > 1){ + + jump_history.pop(); + + let [page_name, index] = jump_history.pop(); + + switch_page(page_name, index); + + } + + } + + init() { + + this.element.setAttribute("onclick", "hmi_widgets['"+this.element_id+"'].on_click(evt)"); + + } + + } + + + + + + + + Button widget takes one boolean variable path, and reflect current true + + or false value by showing "active" or "inactive" labeled element + + respectively. Pressing and releasing button changes variable to true and + + false respectively. Potential inconsistency caused by quick consecutive + + presses on the button is mitigated by using a state machine that wait for + + previous state change to be reflected on variable before applying next one. + + + + Push button reflecting consistently given boolean variable + + + Boolean variable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + switch (this.state) { + + + } + + + + case " + + ": + + + break; + + + + if(value == + + ) { + + + } + + + + + switch (this.state) { + + + + + } + + + + + case " + + ": + + + break; + + + + + + + this.state = " + + "; + + this. + + _action(); + + + + + + + + + _action(){ + + + } + + + + this.display = " + + "; + + this.request_animate(); + + + + this.apply_hmi_value(0, + + ); + + + + class + ButtonWidget + extends Widget{ + + + frequency = 5; + + display = "inactive"; + + state = "init"; + + dispatch(value) { + + + } + + onmouseup(evt) { + + svg_root.removeEventListener("pointerup", this.bound_onmouseup, true); + + + + + } + + onmousedown(evt) { + + svg_root.addEventListener("pointerup", this.bound_onmouseup, true); + + + + + } + + + animate(){ + + if (this.active_elt && this.inactive_elt) { + + + if(this.display == " + + ") + + this. + + _elt.style.display = ""; + + else + + this. + + _elt.style.display = "none"; + + + } + + } + + init() { + + this.bound_onmouseup = this.onmouseup.bind(this); + + this.element.addEventListener("pointerdown", this.onmousedown.bind(this)); + + } + + } + + + + + + + + active inactive + + + + + + + + + + CircularBar widget changes the end angle of a "path" labeled arc according + + to value of the single accepted variable. + + + + If "min" a "max" labeled texts are provided, then they are used as + + respective minimum and maximum value. Otherwise, value is expected to be + + in between 0 and 100. + + + + If "value" labeled text is found, then its content is replaced by value. + + + + Change end angle of Inkscape's arc + + + Value to display + + + + class + CircularBarWidget + extends Widget{ + + frequency = 10; + + range = undefined; + + + + dispatch(value) { + + this.display_val = value; + + this.request_animate(); + + } + + + + animate(){ + + if(this.value_elt) + + this.value_elt.textContent = String(this.display_val); + + let [min,max,start,end] = this.range; + + let [cx,cy] = this.center; + + let [rx,ry] = this.proportions; + + let tip = start + (end-start)*Number(this.display_val)/(max-min); + + let size = 0; + + + + if (tip-start > Math.PI) + + size = 1; + + else + + size = 0; + + + + this.path_elt.setAttribute('d', "M "+(cx+rx*Math.cos(start))+","+(cy+ry*Math.sin(start))+ + + " A "+rx+","+ry+ + + " 0 "+size+ + + " 1 "+(cx+rx*Math.cos(tip))+","+(cy+ry*Math.sin(tip))); + + } + + + + init() { + + let [start, end, cx, cy, rx, ry] = ["start", "end", "cx", "cy", "rx", "ry"]. + + map(tag=>Number(this.path_elt.getAttribute('sodipodi:'+tag))) + + + + if (ry == 0) + + ry = rx; + + + + if (start > end) + + end = end + 2*Math.PI; + + + + let [min,max] = [[this.min_elt,0],[this.max_elt,100]].map(([elt,def],i)=>elt? + + Number(elt.textContent) : + + this.args.length >= i+1 ? this.args[i] : def); + + + + this.range = [min, max, start, end]; + + this.center = [cx, cy]; + + this.proportions = [rx, ry]; + + } + + } + + + + + + + + path + + + + + + value min max + + + + + + + + + + CircularSlider - DEPRECATED, to be replaced by PathSlider + + This widget moves "handle" labeled group along "range" labeled + + arc, according to value of the single accepted variable. + + + + If "min" a "max" labeled texts are provided, or if first and second + + argument are given, then they are used as respective minimum and maximum + + value. Otherwise, value is expected to be in between 0 and 100. + + + + If "value" labeled text is found, then its content is replaced by value. + + During drag, "setpoint" labeled group is moved to position defined by user + + while "handle" reflects current value from variable. + + + + CircularSlider - DEPRECATED + + + minimum value + + + maximum value + + + Value to display + + + + class + CircularSliderWidget + extends Widget{ + + frequency = 5; + + range = undefined; + + circle = undefined; + + handle_pos = undefined; + + curr_value = 0; + + drag = false; + + enTimer = false; + + last_drag = false; + + + + dispatch(value) { + + let [min,max,start,totallength] = this.range; + + //save current value inside widget + + this.curr_value = value; + + + + //check if in range + + if (this.curr_value > max){ + + this.curr_value = max; + + this.apply_hmi_value(0, this.curr_value); + + } + + else if (this.curr_value < min){ + + this.curr_value = min; + + this.apply_hmi_value(0, this.curr_value); + + } + + + + if(this.value_elt) + + this.value_elt.textContent = String(value); + + + + //don't update if draging and setpoint ghost doesn't exist + + if(!this.drag || (this.setpoint_elt != undefined)){ + + this.update_DOM(value, this.handle_elt); + + } + + } + + + + update_DOM(value, elt){ + + let [min,max,totalDistance] = this.range; + + let length = Math.max(0,Math.min((totalDistance),(Number(value)-min)/(max-min)*(totalDistance))); + + let tip = this.range_elt.getPointAtLength(length); + + elt.setAttribute('transform',"translate("+(tip.x-this.handle_pos.x)+","+(tip.y-this.handle_pos.y)+")"); + + + + // show or hide ghost if exists + + if(this.setpoint_elt != undefined){ + + if(this.last_drag!= this.drag){ + + if(this.drag){ + + this.setpoint_elt.setAttribute("style", this.setpoint_style); + + }else{ + + this.setpoint_elt.setAttribute("style", "display:none"); + + } + + this.last_drag = this.drag; + + } + + } + + } + + + + on_release(evt) { + + //unbind events + + window.removeEventListener("touchmove", this.on_bound_drag, true); + + window.removeEventListener("mousemove", this.on_bound_drag, true); + + + + window.removeEventListener("mouseup", this.bound_on_release, true) + + window.removeEventListener("touchend", this.bound_on_release, true); + + window.removeEventListener("touchcancel", this.bound_on_release, true); + + + + //reset drag flag + + if(this.drag){ + + this.drag = false; + + } + + + + // get final position + + this.update_position(evt); + + } + + + + on_drag(evt){ + + //ignore drag event for X amount of time and if not selected + + if(this.enTimer && this.drag){ + + this.update_position(evt); + + + + //reset timer + + this.enTimer = false; + + setTimeout("{hmi_widgets['"+this.element_id+"'].enTimer = true;}", 100); + + } + + } + + + + update_position(evt){ + + if(this.drag && this.enTimer){ + + var svg_dist = 0; + + + + //calculate center of widget in html + + // --TODO maybe it would be better to bind this part to window change size event ??? + + let [xdest,ydest,svgWidth,svgHeight] = page_desc[current_visible_page].bbox; + + let [cX, cY,fiStart,fiEnd,minMax,x1,y1,width,height] = this.circle; + + let htmlCirc = this.range_elt.getBoundingClientRect(); + + let cxHtml = ((htmlCirc.right-htmlCirc.left)/(width)*(cX-x1))+htmlCirc.left; + + let cyHtml = ((htmlCirc.bottom-htmlCirc.top)/(height)*(cY-y1))+htmlCirc.top; + + + + + + //get mouse coordinates + + let mouseX = undefined; + + let mouseY = undefined; + + if (evt.type.startsWith("touch")){ + + mouseX = Math.ceil(evt.touches[0].clientX); + + mouseY = Math.ceil(evt.touches[0].clientY); + + } + + else{ + + mouseX = evt.pageX; + + mouseY = evt.pageY; + + } + + + + //calculate angle + + let fi = Math.atan2(cyHtml-mouseY, mouseX-cxHtml); + + + + // transform from 0 to 2PI + + if (fi > 0){ + + fi = 2*Math.PI-fi; + + } + + else{ + + fi = -fi; + + } + + + + //offset it to 0 + + fi = fi - fiStart; + + if (fi < 0){ + + fi = fi + 2*Math.PI; + + } + + + + //get handle distance from mouse position + + if(fi<fiEnd){ + + this.curr_value=(fi)/(fiEnd)*(this.range[1]-this.range[0]); + + } + + else if(fiEnd<fi && fi<fiEnd+minMax){ + + this.curr_value = this.range[1]; + + } + + else{ + + this.curr_value = this.range[0]; + + } + + + + //apply value to hmi + + this.apply_hmi_value(0, Math.ceil(this.curr_value)); + + + + //redraw handle + + this.request_animate(); + + + + } + + + + } + + + + animate(){ + + // redraw handle on screen refresh + + // check if setpoint(ghost) handle exsist otherwise update main handle + + if(this.setpoint_elt != undefined){ + + this.update_DOM(this.curr_value, this.setpoint_elt); + + } + + else{ + + this.update_DOM(this.curr_value, this.handle_elt); + + } + + } + + + + on_select(evt){ + + //enable drag flag and timer + + this.drag = true; + + this.enTimer = true; + + + + //bind events + + window.addEventListener("touchmove", this.on_bound_drag, true); + + window.addEventListener("mousemove", this.on_bound_drag, true); + + + + window.addEventListener("mouseup", this.bound_on_release, true); + + window.addEventListener("touchend", this.bound_on_release, true); + + window.addEventListener("touchcancel", this.bound_on_release, true); + + + + //update postion on mouse press + + this.update_position(evt); + + + + //prevent next events + + evt.stopPropagation(); + + } + + + + init() { + + //get min max + + let min = this.min_elt ? + + Number(this.min_elt.textContent) : + + this.args.length >= 1 ? this.args[0] : 0; + + let max = this.max_elt ? + + Number(this.max_elt.textContent) : + + this.args.length >= 2 ? this.args[1] : 100; + + + + //fiStart ==> offset + + let fiStart = Number(this.range_elt.getAttribute('sodipodi:start')); + + let fiEnd = Number(this.range_elt.getAttribute('sodipodi:end')); + + fiEnd = fiEnd - fiStart; + + + + //fiEnd ==> size of angle + + if (fiEnd < 0){ + + fiEnd = 2*Math.PI + fiEnd; + + } + + + + //min max barrier angle + + let minMax = (2*Math.PI - fiEnd)/2; + + + + //get parameters from svg + + let cX = Number(this.range_elt.getAttribute('sodipodi:cx')); + + let cY = Number(this.range_elt.getAttribute('sodipodi:cy')); + + this.range_elt.style.strokeMiterlimit="0"; //eliminates some weird border around html object + + this.range = [min, max,this.range_elt.getTotalLength()]; + + let cPos = this.range_elt.getBBox(); + + this.handle_pos = this.range_elt.getPointAtLength(0); + + this.circle = [cX, cY,fiStart,fiEnd,minMax,cPos.x,cPos.y,cPos.width,cPos.height]; + + + + //bind functions + + this.bound_on_select = this.on_select.bind(this); + + this.bound_on_release = this.on_release.bind(this); + + this.on_bound_drag = this.on_drag.bind(this); + + + + this.handle_elt.addEventListener("mousedown", this.bound_on_select); + + this.element.addEventListener("mousedown", this.bound_on_select); + + this.element.addEventListener("touchstart", this.bound_on_select); + + //touch recognised as page drag without next command + + document.body.addEventListener("touchstart", function(e){}, false); + + + + //save ghost style + + //save ghost style + + if(this.setpoint_elt != undefined){ + + this.setpoint_style = this.setpoint_elt.getAttribute("style"); + + this.setpoint_elt.setAttribute("style", "display:none"); + + } + + + + } + + } + + + + + + + + handle range + + + + + + value min max setpoint + + + + + + + + + + + + CustomHtml widget allows insertion of HTML code in a svg:foreignObject. + + Widget content is replaced by foreignObject. HTML code is obtained from + + "code" labeled text content. HTML insert position and size is given with + + "container" labeled element. + + + + Custom HTML insert + + + + class + CustomHtmlWidget + extends Widget{ + + frequency = 5; + + widget_size = undefined; + + + + dispatch(value) { + + this.request_animate(); + + } + + + + animate(){ + + } + + + + init() { + + this.widget_size = this.container_elt.getBBox(); + + this.element.innerHTML ='<foreignObject x="'+ + + this.widget_size.x+'" y="'+this.widget_size.y+ + + '" width="'+this.widget_size.width+'" height="'+this.widget_size.height+'"> '+ + + this.code_elt.textContent+ + + ' </foreignObject>'; + + } + + } + + + + + + + + container code + + + + + + + + + If Display widget is a svg:text element, then text content is replaced by + + value of given variables, space separated. + + + + Otherwise, if Display widget is a group containing a svg:text element + + labelled "format", then text content is replaced by printf-like formated + + string. In other words, if "format" labeled text is "%d %s %f", then 3 + + variables paths are expected : HMI_IN, HMI_STRING and HMI_REAL. + + + + In case Display widget is a svg::text element, it is also possible to give + + format string as first argument. + + + + Printf-like formated text display + + + printf-like format string when not given as svg:text + + + variables to be displayed + + + + class + DisplayWidget + extends Widget{ + + frequency = 5; + + dispatch(value, oldval, index) { + + this.fields[index] = value; + + this.request_animate(); + + } + + } + + + + + + + + + format + + + + + + + + + Display Widget id=" + + " must be a svg::text element itself or a group containing a svg:text element labelled "format" + + + + + + + "" + + + 0 + + + + , + + + + fields: [ + + ], + + animate: function(){ + + + + if(this.format_elt.getAttribute("lang")) { + + this.format = svg_text_to_multiline(this.format_elt); + + this.format_elt.removeAttribute("lang"); + + } + + let str = vsprintf(this.format,this.fields); + + multiline_to_svg_text(this.format_elt, str); + + + + let str = this.args.length == 1 ? vsprintf(this.args[0],this.fields) : this.fields.join(' '); + + multiline_to_svg_text(this.element, str); + + + + }, + + + + + init: function() { + + this.format = svg_text_to_multiline(this.format_elt); + + }, + + + + + + + + /* + + */ + + + + /* https://github.com/alexei/sprintf.js/blob/master/src/sprintf.js */ + + /* global window, exports, define */ + + + + !function() { + + 'use strict' + + + + var re = { + + not_string: /[^s]/, + + not_bool: /[^t]/, + + not_type: /[^T]/, + + not_primitive: /[^v]/, + + number: /[diefg]/, + + numeric_arg: /[bcdiefguxX]/, + + json: /[j]/, + + not_json: /[^j]/, + + text: /^[^%]+/, + + modulo: /^%{2}/, + + placeholder: /^%(?:([1-9]\d*)\$|\(([^)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-gijostTuvxX])/, + + key: /^([a-z_][a-z_\d]*)/i, + + key_access: /^\.([a-z_][a-z_\d]*)/i, + + index_access: /^\[(\d+)\]/, + + sign: /^[+-]/ + + } + + + + function sprintf(key) { + + // arguments is not an array, but should be fine for this call + + return sprintf_format(sprintf_parse(key), arguments) + + } + + + + function vsprintf(fmt, argv) { + + return sprintf.apply(null, [fmt].concat(argv || [])) + + } + + + + function sprintf_format(parse_tree, argv) { + + var cursor = 1, tree_length = parse_tree.length, arg, output = '', i, k, ph, pad, pad_character, pad_length, is_positive, sign + + for (i = 0; i < tree_length; i++) { + + if (typeof parse_tree[i] === 'string') { + + output += parse_tree[i] + + } + + else if (typeof parse_tree[i] === 'object') { + + ph = parse_tree[i] // convenience purposes only + + if (ph.keys) { // keyword argument + + arg = argv[cursor] + + for (k = 0; k < ph.keys.length; k++) { + + if (arg == undefined) { + + throw new Error(sprintf('[sprintf] Cannot access property "%s" of undefined value "%s"', ph.keys[k], ph.keys[k-1])) + + } + + arg = arg[ph.keys[k]] + + } + + } + + else if (ph.param_no) { // positional argument (explicit) + + arg = argv[ph.param_no] + + } + + else { // positional argument (implicit) + + arg = argv[cursor++] + + } + + + + if (re.not_type.test(ph.type) && re.not_primitive.test(ph.type) && arg instanceof Function) { + + arg = arg() + + } + + + + if (re.numeric_arg.test(ph.type) && (typeof arg !== 'number' && isNaN(arg))) { + + throw new TypeError(sprintf('[sprintf] expecting number but found %T', arg)) + + } + + + + if (re.number.test(ph.type)) { + + is_positive = arg >= 0 + + } + + + + switch (ph.type) { + + case 'b': + + arg = parseInt(arg, 10).toString(2) + + break + + case 'c': + + arg = String.fromCharCode(parseInt(arg, 10)) + + break + + case 'd': + + case 'i': + + arg = parseInt(arg, 10) + + break + + case 'j': + + arg = JSON.stringify(arg, null, ph.width ? parseInt(ph.width) : 0) + + break + + case 'e': + + arg = ph.precision ? parseFloat(arg).toExponential(ph.precision) : parseFloat(arg).toExponential() + + break + + case 'f': + + arg = ph.precision ? parseFloat(arg).toFixed(ph.precision) : parseFloat(arg) + + break + + case 'g': + + arg = ph.precision ? String(Number(arg.toPrecision(ph.precision))) : parseFloat(arg) + + break + + case 'o': + + arg = (parseInt(arg, 10) >>> 0).toString(8) + + break + + case 's': + + arg = String(arg) + + arg = (ph.precision ? arg.substring(0, ph.precision) : arg) + + break + + case 't': + + arg = String(!!arg) + + arg = (ph.precision ? arg.substring(0, ph.precision) : arg) + + break + + case 'T': + + arg = Object.prototype.toString.call(arg).slice(8, -1).toLowerCase() + + arg = (ph.precision ? arg.substring(0, ph.precision) : arg) + + break + + case 'u': + + arg = parseInt(arg, 10) >>> 0 + + break + + case 'v': + + arg = arg.valueOf() + + arg = (ph.precision ? arg.substring(0, ph.precision) : arg) + + break + + case 'x': + + arg = (parseInt(arg, 10) >>> 0).toString(16) + + break + + case 'X': + + arg = (parseInt(arg, 10) >>> 0).toString(16).toUpperCase() + + break + + } + + if (re.json.test(ph.type)) { + + output += arg + + } + + else { + + if (re.number.test(ph.type) && (!is_positive || ph.sign)) { + + sign = is_positive ? '+' : '-' + + arg = arg.toString().replace(re.sign, '') + + } + + else { + + sign = '' + + } + + pad_character = ph.pad_char ? ph.pad_char === '0' ? '0' : ph.pad_char.charAt(1) : ' ' + + pad_length = ph.width - (sign + arg).length + + pad = ph.width ? (pad_length > 0 ? pad_character.repeat(pad_length) : '') : '' + + output += ph.align ? sign + arg + pad : (pad_character === '0' ? sign + pad + arg : pad + sign + arg) + + } + + } + + } + + return output + + } + + + + var sprintf_cache = Object.create(null) + + + + function sprintf_parse(fmt) { + + if (sprintf_cache[fmt]) { + + return sprintf_cache[fmt] + + } + + + + var _fmt = fmt, match, parse_tree = [], arg_names = 0 + + while (_fmt) { + + if ((match = re.text.exec(_fmt)) !== null) { + + parse_tree.push(match[0]) + + } + + else if ((match = re.modulo.exec(_fmt)) !== null) { + + parse_tree.push('%') + + } + + else if ((match = re.placeholder.exec(_fmt)) !== null) { + + if (match[2]) { + + arg_names |= 1 + + var field_list = [], replacement_field = match[2], field_match = [] + + if ((field_match = re.key.exec(replacement_field)) !== null) { + + field_list.push(field_match[1]) + + while ((replacement_field = replacement_field.substring(field_match[0].length)) !== '') { + + if ((field_match = re.key_access.exec(replacement_field)) !== null) { + + field_list.push(field_match[1]) + + } + + else if ((field_match = re.index_access.exec(replacement_field)) !== null) { + + field_list.push(field_match[1]) + + } + + else { + + throw new SyntaxError('[sprintf] failed to parse named argument key') + + } + + } + + } + + else { + + throw new SyntaxError('[sprintf] failed to parse named argument key') + + } + + match[2] = field_list + + } + + else { + + arg_names |= 2 + + } + + if (arg_names === 3) { + + throw new Error('[sprintf] mixing positional and named placeholders is not (yet) supported') + + } + + + + parse_tree.push( + + { + + placeholder: match[0], + + param_no: match[1], + + keys: match[2], + + sign: match[3], + + pad_char: match[4], + + align: match[5], + + width: match[6], + + precision: match[7], + + type: match[8] + + } + + ) + + } + + else { + + throw new SyntaxError('[sprintf] unexpected placeholder') + + } + + _fmt = _fmt.substring(match[0].length) + + } + + return sprintf_cache[fmt] = parse_tree + + } + + + + /** + + * export to either browser or node.js + + */ + + /* eslint-disable quote-props */ + + if (typeof exports !== 'undefined') { + + exports['sprintf'] = sprintf + + exports['vsprintf'] = vsprintf + + } + + if (typeof window !== 'undefined') { + + window['sprintf'] = sprintf + + window['vsprintf'] = vsprintf + + + + if (typeof define === 'function' && define['amd']) { + + define(function() { + + return { + + 'sprintf': sprintf, + + 'vsprintf': vsprintf + + } + + }) + + } + + } + + /* eslint-enable quote-props */ + + }(); // eslint-disable-line + + + + + + + + + + DropDown widget let user select an entry in a list of texts, given as + + arguments. Single variable path is index of selection. + + + + It needs "text" (svg:text), "box" (svg:rect), "button" (svg:*), + + and "highlight" (svg:rect) labeled elements. + + + + When user clicks on "button", "text" is duplicated to display enties in the + + limit of available space in page, and "box" is extended to contain all + + texts. "highlight" is moved over pre-selected entry. + + + + When only one argument is given, and argment contains "#langs" then list of + + texts is automatically set to the list of human-readable languages supported + + by this HMI. + + + + Let user select text entry in a drop-down menu + + + drop-down menu entries + + + selection index + + + + class + DropDownWidget + extends Widget{ + + dispatch(value) { + + if(!this.opened) this.set_selection(value); + + } + + init() { + + this.button_elt.onclick = this.on_button_click.bind(this); + + // Save original size of rectangle + + this.box_bbox = this.box_elt.getBBox() + + this.highlight_bbox = this.highlight_elt.getBBox() + + this.highlight_elt.style.visibility = "hidden"; + + + + // Compute margins + + this.text_bbox = this.text_elt.getBBox(); + + let lmargin = this.text_bbox.x - this.box_bbox.x; + + let tmargin = this.text_bbox.y - this.box_bbox.y; + + this.margins = [lmargin, tmargin].map(x => Math.max(x,0)); + + + + // Index of first visible element in the menu, when opened + + this.menu_offset = 0; + + + + // How mutch to lift the menu vertically so that it does not cross bottom border + + this.lift = 0; + + + + // Event handlers cannot be object method ('this' is unknown) + + // as a workaround, handler given to addEventListener is bound in advance. + + this.bound_close_on_click_elsewhere = this.close_on_click_elsewhere.bind(this); + + this.bound_on_selection_click = this.on_selection_click.bind(this); + + this.bound_on_backward_click = this.on_backward_click.bind(this); + + this.bound_on_forward_click = this.on_forward_click.bind(this); + + this.opened = false; + + this.clickables = []; + + } + + on_button_click() { + + this.open(); + + } + + // Called when a menu entry is clicked + + on_selection_click(selection) { + + this.close(); + + this.apply_hmi_value(0, selection); + + } + + on_backward_click(){ + + this.scroll(false); + + } + + on_forward_click(){ + + this.scroll(true); + + } + + set_selection(value) { + + let display_str; + + if(value >= 0 && value < this.content.length){ + + // if valid selection resolve content + + display_str = this.content[value]; + + this.last_selection = value; + + } else { + + // otherwise show problem + + display_str = "?"+String(value)+"?"; + + } + + // It is assumed that first span always stays, + + // and contains selection when menu is closed + + this.text_elt.firstElementChild.textContent = display_str; + + } + + grow_text(up_to) { + + let count = 1; + + let txt = this.text_elt; + + let first = txt.firstElementChild; + + // Real world (pixels) boundaries of current page + + let bounds = svg_root.getBoundingClientRect(); + + this.lift = 0; + + while(count < up_to) { + + let next = first.cloneNode(); + + // relative line by line text flow instead of absolute y coordinate + + next.removeAttribute("y"); + + next.setAttribute("dy", "1.1em"); + + // default content to allow computing text element bbox + + next.textContent = "..."; + + // append new span to text element + + txt.appendChild(next); + + // now check if text extended by one row fits to page + + // FIXME : exclude margins to be more accurate on box size + + let rect = txt.getBoundingClientRect(); + + if(rect.bottom > bounds.bottom){ + + // in case of overflow at the bottom, lift up one row + + let backup = first.getAttribute("dy"); + + // apply lift as a dy added too first span (y attrib stays) + + first.setAttribute("dy", "-"+String((this.lift+1)*1.1)+"em"); + + rect = txt.getBoundingClientRect(); + + if(rect.top > bounds.top){ + + this.lift += 1; + + } else { + + // if it goes over the top, then backtrack + + // restore dy attribute on first span + + if(backup) + + first.setAttribute("dy", backup); + + else + + first.removeAttribute("dy"); + + // remove unwanted child + + txt.removeChild(next); + + return count; + + } + + } + + count++; + + } + + return count; + + } + + close_on_click_elsewhere(e) { + + // inhibit events not targetting spans (menu items) + + if([this.text_elt, this.element].indexOf(e.target.parentNode) == -1){ + + e.stopPropagation(); + + // close menu in case click is outside box + + if(e.target !== this.box_elt) + + this.close(); + + } + + } + + close(){ + + // Stop hogging all click events + + svg_root.removeEventListener("pointerdown", this.numb_event, true); + + svg_root.removeEventListener("pointerup", this.numb_event, true); + + svg_root.removeEventListener("click", this.bound_close_on_click_elsewhere, true); + + // Restore position and sixe of widget elements + + this.reset_text(); + + this.reset_clickables(); + + this.reset_box(); + + this.reset_highlight(); + + // Put the button back in place + + this.element.appendChild(this.button_elt); + + // Mark as closed (to allow dispatch) + + this.opened = false; + + // Dispatch last cached value + + this.apply_cache(); + + } + + // Make item (text span) clickable by overlaying a rectangle on top of it + + make_clickable(span, func) { + + let txt = this.text_elt; + + let original_text_y = this.text_bbox.y; + + let highlight = this.highlight_elt; + + let original_h_y = this.highlight_bbox.y; + + let clickable = highlight.cloneNode(); + + let yoffset = span.getBBox().y - original_text_y; + + clickable.y.baseVal.value = original_h_y + yoffset; + + clickable.style.pointerEvents = "bounding-box"; + + //clickable.style.visibility = "hidden"; + + //clickable.onclick = () => alert("love JS"); + + clickable.onclick = func; + + this.element.appendChild(clickable); + + this.clickables.push(clickable) + + } + + reset_clickables() { + + while(this.clickables.length){ + + this.element.removeChild(this.clickables.pop()); + + } + + } + + // Set text content when content is smaller than menu (no scrolling) + + set_complete_text(){ + + let spans = this.text_elt.children; + + let c = 0; + + for(let item of this.content){ + + let span=spans[c]; + + span.textContent = item; + + let sel = c; + + this.make_clickable(span, (evt) => this.bound_on_selection_click(sel)); + + c++; + + } + + } + + // Move partial view : + + // false : upward, lower value + + // true : downward, higher value + + scroll(forward){ + + let contentlength = this.content.length; + + let spans = this.text_elt.children; + + let spanslength = spans.length; + + // reduce accounted menu size according to prsence of scroll buttons + + // since we scroll there is necessarly one button + + spanslength--; + + if(forward){ + + // reduce accounted menu size because of back button + + // in current view + + if(this.menu_offset > 0) spanslength--; + + this.menu_offset = Math.min( + + contentlength - spans.length + 1, + + this.menu_offset + spanslength); + + }else{ + + // reduce accounted menu size because of back button + + // in view once scrolled + + if(this.menu_offset - spanslength > 0) spanslength--; + + this.menu_offset = Math.max( + + 0, + + this.menu_offset - spanslength); + + } + + if(this.menu_offset == 1) + + this.menu_offset = 0; + + + + this.reset_highlight(); + + + + this.reset_clickables(); + + this.set_partial_text(); + + + + this.highlight_selection(); + + } + + // Setup partial view text content + + // with jumps at first and last entry when appropriate + + set_partial_text(){ + + let spans = this.text_elt.children; + + let contentlength = this.content.length; + + let spanslength = spans.length; + + let i = this.menu_offset, c = 0; + + let m = this.box_bbox; + + while(c < spanslength){ + + let span=spans[c]; + + let onclickfunc; + + // backward jump only present if not exactly at start + + if(c == 0 && i != 0){ + + span.textContent = "▲"; + + onclickfunc = this.bound_on_backward_click; + + let o = span.getBBox(); + + span.setAttribute("dx", (m.width - o.width)/2); + + // presence of forward jump when not right at the end + + }else if(c == spanslength-1 && i < contentlength - 1){ + + span.textContent = "▼"; + + onclickfunc = this.bound_on_forward_click; + + let o = span.getBBox(); + + span.setAttribute("dx", (m.width - o.width)/2); + + // otherwise normal content + + }else{ + + span.textContent = this.content[i]; + + let sel = i; + + onclickfunc = (evt) => this.bound_on_selection_click(sel); + + span.removeAttribute("dx"); + + i++; + + } + + this.make_clickable(span, onclickfunc); + + c++; + + } + + } + + numb_event(e) { + + e.stopPropagation(); + + } + + open(){ + + let length = this.content.length; + + // systematically reset text, to strip eventual whitespace spans + + this.reset_text(); + + // grow as much as needed or possible + + let slots = this.grow_text(length); + + // Depending on final size + + if(slots == length) { + + // show all at once + + this.set_complete_text(); + + } else { + + // eventualy align menu to current selection, compensating for lift + + let offset = this.last_selection - this.lift; + + if(offset > 0) + + this.menu_offset = Math.min(offset + 1, length - slots + 1); + + else + + this.menu_offset = 0; + + // show surrounding values + + this.set_partial_text(); + + } + + // Now that text size is known, we can set the box around it + + this.adjust_box_to_text(); + + // Take button out until menu closed + + this.element.removeChild(this.button_elt); + + // Rise widget to top by moving it to last position among siblings + + this.element.parentNode.appendChild(this.element.parentNode.removeChild(this.element)); + + // disable interaction with background + + svg_root.addEventListener("pointerdown", this.numb_event, true); + + svg_root.addEventListener("pointerup", this.numb_event, true); + + svg_root.addEventListener("click", this.bound_close_on_click_elsewhere, true); + + this.highlight_selection(); + + + + // mark as open + + this.opened = true; + + } + + // Put text element in normalized state + + reset_text(){ + + let txt = this.text_elt; + + let first = txt.firstElementChild; + + // remove attribute eventually added to first text line while opening + + first.onclick = null; + + first.removeAttribute("dy"); + + first.removeAttribute("dx"); + + // keep only the first line of text + + for(let span of Array.from(txt.children).slice(1)){ + + txt.removeChild(span) + + } + + } + + // Put rectangle element in saved original state + + reset_box(){ + + let m = this.box_bbox; + + let b = this.box_elt; + + b.x.baseVal.value = m.x; + + b.y.baseVal.value = m.y; + + b.width.baseVal.value = m.width; + + b.height.baseVal.value = m.height; + + } + + highlight_selection(){ + + if(this.last_selection == undefined) return; + + let highlighted_row = this.last_selection - this.menu_offset; + + if(highlighted_row < 0) return; + + let spans = this.text_elt.children; + + let spanslength = spans.length; + + let contentlength = this.content.length; + + if(this.menu_offset != 0) { + + spanslength--; + + highlighted_row++; + + } + + if(this.menu_offset + spanslength < contentlength - 1) spanslength--; + + if(highlighted_row > spanslength) return; + + let original_text_y = this.text_bbox.y; + + let highlight = this.highlight_elt; + + let span = spans[highlighted_row]; + + let yoffset = span.getBBox().y - original_text_y; + + highlight.y.baseVal.value = this.highlight_bbox.y + yoffset; + + highlight.style.visibility = "visible"; + + } + + reset_highlight(){ + + let highlight = this.highlight_elt; + + highlight.y.baseVal.value = this.highlight_bbox.y; + + highlight.style.visibility = "hidden"; + + } + + // Use margin and text size to compute box size + + adjust_box_to_text(){ + + let [lmargin, tmargin] = this.margins; + + let m = this.text_elt.getBBox(); + + let b = this.box_elt; + + // b.x.baseVal.value = m.x - lmargin; + + b.y.baseVal.value = m.y - tmargin; + + // b.width.baseVal.value = 2 * lmargin + m.width; + + b.height.baseVal.value = 2 * tmargin + m.height; + + } + + } + + + + + + + + text box button highlight + + + content: + + + langs + + + [ + + + " + + ", + + + ] + + + , + + + + + + + + ForEach widget is used to span a small set of widget over a larger set of + + repeated HMI_NODEs. + + + + Idea is somewhat similar to relative page, but it all happens inside the + + ForEach widget, no page involved. + + + + Together with relative Jump widgets it can be used to build a menu to reach + + relative pages covering many identical HMI_NODES siblings. + + + + ForEach widget takes a HMI_CLASS name as argument and a HMI_NODE path as + + variable. + + + + Direct sub-elements can be either groups of widget to be spanned, labeled + + "ClassName:offset", or buttons to control the spanning, labeled + + "ClassName:+/-number". + + + + span widgets over a set of repeated HMI_NODEs + + + HMI_CLASS name + + + where to find HMI_NODEs whose HMI_CLASS is class_name + + + + + + + ForEach widget + + must have one HMI path given. + + + + + ForEach widget + + must have one argument given : a class name. + + + + + + + + + + index_pool: [ + + + + + + , + + + + + ], + + init: function() { + + + + + + + id(" + + ").setAttribute("onclick", "hmi_widgets[' + + '].on_click(' + + ', evt)"); + + + + + this.items = [ + + + + + + + + + [ /* item=" + + " path=" + + " */ + + + + Missing item labeled + + in ForEach widget + + + + + + + Widget id=" + + " label=" + + " is having wrong path. Accroding to ForEach widget ancestor id=" + + ", path should be descendant of " + + ". + + + hmi_widgets[" + + "] + + , + + + + + ] + + , + + + + + ] + + }, + + item_offset: 0, + + + + class + ForEachWidget + extends Widget{ + + + + unsub_items(){ + + for(let item of this.items){ + + for(let widget of item) { + + widget.unsub(); + + } + + } + + } + + + + unsub(){ + + this.unsub_items(); + + this.offset = 0; + + this.relativeness = undefined; + + } + + + + sub_items(){ + + for(let i = 0; i < this.items.length; i++) { + + let item = this.items[i]; + + let orig_item_index = this.index_pool[i]; + + let item_index = this.index_pool[i+this.item_offset]; + + let item_index_offset = item_index - orig_item_index; + + if(this.relativeness[0]) + + item_index_offset += this.offset; + + for(let widget of item) { + + /* all variables of all widgets in a ForEach are all relative. + + Really. + + + + TODO: allow absolute variables in ForEach widgets + + */ + + widget.sub(item_index_offset, widget.indexes.map(_=>true)); + + } + + } + + } + + + + sub(new_offset=0, relativeness=[]){ + + this.offset = new_offset; + + this.relativeness = relativeness; + + this.sub_items(); + + } + + + + apply_cache() { + + this.items.forEach(item=>item.forEach(widget=>widget.apply_cache())); + + } + + + + on_click(opstr, evt) { + + let new_item_offset = eval(String(this.item_offset)+opstr); + + if(new_item_offset + this.items.length > this.index_pool.length) { + + if(this.item_offset + this.items.length == this.index_pool.length) + + new_item_offset = 0; + + else + + new_item_offset = this.index_pool.length - this.items.length; + + } else if(new_item_offset < 0) { + + if(this.item_offset == 0) + + new_item_offset = this.index_pool.length - this.items.length; + + else + + new_item_offset = 0; + + } + + this.item_offset = new_item_offset; + + this.unsub_items(); + + this.sub_items(); + + update_subscriptions(); + + need_cache_apply.push(this); + + jumps_need_update = true; + + requestHMIAnimation(); + + } + + } + + + + + + + + Input widget takes one variable path, and displays current value in + + optional "value" labeled sub-element. + + + + Click on optional "edit" labeled element opens keypad to edit value. + + + + Operation on current value is performed when click on sub-elements with + + label starting with '=', '+' or '-' sign. Value after sign is used as + + operand. + + + + Input field with predefined operation buttons + + + optional printf-like format + + + single variable to edit + + + + class + InputWidget + extends Widget{ + + on_op_click(opstr) { + + this.change_hmi_value(0, opstr); + + } + + edit_callback(new_val) { + + this.apply_hmi_value(0, new_val); + + } + + + + is_inhibited = false; + + alert(msg){ + + this.is_inhibited = true; + + this.display = msg; + + setTimeout(() => this.stopalert(), 1000); + + this.request_animate(); + + } + + + + stopalert(){ + + this.is_inhibited = false; + + this.display = this.last_value; + + this.request_animate(); + + } + + + + overshot(new_val, max) { + + this.alert("max"); + + } + + + + undershot(new_val, min) { + + this.alert("min"); + + } + + } + + + + + + + + + value + + + + + + + + + + + edit + + + + + + + + frequency: 5, + + + dispatch: function(value) { + + + + + this.last_value = vsprintf(" + + ", [value]); + + + + this.last_value = value; + + + + if(!this.is_inhibited){ + + this.display = this.last_value; + + + this.request_animate(); + + + } + + + }, + + + animate: function(){ + + this.value_elt.textContent = String(this.display); + + }, + + + init: function() { + + + this.edit_elt.onclick = () => edit_value(" + + ", " + + ", this, this.last_value); + + + this.value_elt.style.pointerEvents = "none"; + + + + + id(" + + ").onclick = () => this.on_op_click(" + + "); + + + }, + + + + + + + + Send given variables as POST to http URL argument, spread returned JSON in + + SVG sub-elements of "data" labeled element. + + + + Documentation to be written. see svbghmi exemple. + + + + Http POST variables, spread JSON back + + + + + + single variable to edit + + + + class + JsonTableWidget + extends Widget{ + + // arbitrary defaults to avoid missing entries in query + + cache = [0,0,0]; + + init_common() { + + this.spread_json_data_bound = this.spread_json_data.bind(this); + + this.handle_http_response_bound = this.handle_http_response.bind(this); + + this.fetch_error_bound = this.fetch_error.bind(this); + + this.promised = false; + + } + + + + handle_http_response(response) { + + if (!response.ok) { + + console.log("HTTP error, status = " + response.status); + + } + + return response.json(); + + } + + + + fetch_error(e){ + + console.log("HTTP fetch error, message = " + e.message + "Widget:" + this.element_id); + + } + + + + do_http_request(...opt) { + + this.abort_controller = new AbortController(); + + return Promise.resolve().then(() => { + + + + const query = { + + args: this.args, + + range: this.cache[1], + + position: this.cache[2], + + visible: this.visible, + + extra: this.cache.slice(4), + + options: opt + + }; + + + + const options = { + + method: 'POST', + + body: JSON.stringify(query), + + headers: {'Content-Type': 'application/json'}, + + signal: this.abort_controller.signal + + }; + + + + return fetch(this.args[0], options) + + .then(this.handle_http_response_bound) + + .then(this.spread_json_data_bound) + + .catch(this.fetch_error_bound); + + }); + + } + + + + unsub(){ + + this.abort_controller.abort(); + + super.unsub(); + + } + + + + sub(...args){ + + this.cache[0] = undefined; + + super.sub(...args); + + } + + + + dispatch(value, oldval, index) { + + + + if(this.cache[index] != value) + + this.cache[index] = value; + + else + + return; + + + + if(!this.promised){ + + this.promised = true; + + this.do_http_request().finally(() => { + + this.promised = false; + + }); + + } + + } + + make_on_click(...options){ + + let that = this; + + return function(evt){ + + that.do_http_request(...options); + + } + + } + + // on_click(evt, ...options) { + + // this.do_http_request(...options); + + // } + + } + + + + + JsonTable Widget can't contain element of type + + . + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + JsonTable : missplaced '=' or inconsistent names in Json data expressions. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + jdata + + + + + + + + + + + id(" + + ").setAttribute("xlink:href", + + "#"+hmi_widgets[" + + "].items[ + + ]); + + + + + Clones (svg:use) in JsonTable Widget must point to a valid HMI:List widget or item. Reference " + + " is not valid and will not be updated. + + + + + + + + + + + + + + + Clones (svg:use) in JsonTable Widget pointing to a HMI:TextStyleList widget or item must have a "textContent=.someVal" assignement following value expression in label. + + + { + + let elt = id(" + + "); + + elt.textContent = String( + + ); + + elt.style = hmi_widgets[" + + "].styles[ + + ]; + + } + + + + id(" + + ").textContent = String( + + ); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id(" + + ").onclick = this.make_on_click(' + + ', + + ); + + + + + + + + + + + + obj_ + + _ + + try { + + + let + + + = + + ; + + if( + + + == undefined) { + + throw null; + + } + + + + + + + + + + + + + + id(" + + ").style = " + + "; + + + + + + } catch(err) { + + id(" + + ").style = "display:none"; + + } + + + + + + + + data + + + + visible: + + , + + spread_json_data: function(janswer) { + + let [range,position,jdata] = janswer; + + [[1, range], [2, position], [3, this.visible]].map(([i,v]) => { + + this.apply_hmi_value(i,v); + + this.cache[i] = v; + + }); + + + + + + }, + + init() { + + this.init_common(); + + + id(" + + ").onclick = this.make_on_click(" + + "); + + + } + + + + + + + + Jump widget brings focus to a different page. Mandatory single argument + + gives name of the page. + + + + Optional single path is used as new reference when jumping to a relative + + page, it must point to a HMI_NODE. + + + + "active"+"inactive" labeled elements can be provided and reflect current + + page being shown. + + + + "disabled" labeled element, if provided, is shown instead of "active" or + + "inactive" widget when pointed HMI_NODE is null. + + + + Jump to given page + + + name of page to jump to + + + reference for relative jump + + + + class + JumpWidget + extends Widget{ + + activable = false; + + active = false; + + disabled = false; + + frequency = 2; + + + + update_activity() { + + if(this.active) { + + /* show active */ + + this.active_elt.style.display = ""; + + /* hide inactive */ + + this.inactive_elt.style.display = "none"; + + } else { + + /* show inactive */ + + this.inactive_elt.style.display = ""; + + /* hide active */ + + this.active_elt.style.display = "none"; + + } + + } + + + + update_disability() { + + if(this.disabled) { + + /* show disabled */ + + this.disabled_elt.style.display = ""; + + /* hide inactive */ + + this.inactive_elt.style.display = "none"; + + /* hide active */ + + this.active_elt.style.display = "none"; + + } else { + + /* hide disabled */ + + this.disabled_elt.style.display = "none"; + + this.update_activity(); + + } + + } + + + + make_on_click() { + + let that = this; + + const name = this.args[0]; + + return function(evt){ + + /* TODO: in order to allow jumps to page selected through for exemple a dropdown, + + support path pointing to local variable whom value + + would be an HMI_TREE index and then jump to a relative page not hard-coded in advance */ + + + + if(!that.disabled) { + + const index = that.indexes.length > 0 ? that.indexes[0] + that.offset : undefined; + + switch_page(name, index); + + } + + } + + } + + + + notify_page_change(page_name, index) { + + if(this.activable) { + + const ref_index = this.indexes.length > 0 ? this.indexes[0] + this.offset : undefined; + + const ref_name = this.args[0]; + + this.active = ((ref_name == undefined || ref_name == page_name) && index == ref_index); + + this.update_state(); + + } + + } + + + + dispatch(value) { + + this.disabled = !Number(value); + + this.update_state(); + + } + + } + + + + + + + + + active inactive + + + + + + + + + + + disabled + + + + + + + init: function() { + + this.element.onclick = this.make_on_click(); + + + this.activable = true; + + + + this.unsubscribable = true; + + + this.update_state = + + + this.update_disability + + + this.update_activity + + + null + + + ; + + }, + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jump id=" + + " to page " + + " with incompatible path " + + (must be same class as " + + ") + + + + + + + + + /* + + */ + + + + var jumps_need_update = false; + + var jump_history = [[default_page, undefined]]; + + + + function update_jumps() { + + page_desc[current_visible_page].jumps.map(w=>w.notify_page_change(current_visible_page,current_page_index)); + + jumps_need_update = false; + + }; + + + + + + + + + + + + Keypad - to be written + + + + Keypad + + + keypad can input those types + + + + + + + /* + + */ + + + + + + var keypads = { + + + + + + " + + ":[" + + ", + + , + + ], + + + + } + + + + + + class + KeypadWidget + extends Widget{ + + on_key_click(symbols) { + + var syms = symbols.split(" "); + + this.shift |= this.caps; + + this.editstr += syms[this.shift?syms.length-1:0]; + + this.shift = false; + + this.update(); + + } + + + + on_Esc_click() { + + end_modal.call(this); + + } + + + + on_Enter_click() { + + let coercedval = (typeof this.initial) == "number" ? Number(this.editstr) : this.editstr; + + if(typeof coercedval == 'number' && isNaN(coercedval)){ + + // revert to initial so it explicitely shows input was ignored + + this.editstr = String(this.initial); + + this.update(); + + } else { + + let callback_obj = this.result_callback_obj; + + end_modal.call(this); + + callback_obj.edit_callback(coercedval); + + } + + } + + + + on_BackSpace_click() { + + this.editstr = this.editstr.slice(0,this.editstr.length-1); + + this.update(); + + } + + + + on_Sign_click() { + + if(this.editstr[0] == "-") + + this.editstr = this.editstr.slice(1,this.editstr.length); + + else + + this.editstr = "-" + this.editstr; + + this.update(); + + } + + + + on_NumDot_click() { + + if(this.editstr.indexOf(".") == "-1"){ + + this.editstr += "."; + + this.update(); + + } + + } + + + + on_Space_click() { + + this.editstr += " "; + + this.update(); + + } + + + + caps = false; + + _caps = undefined; + + on_CapsLock_click() { + + this.caps = !this.caps; + + this.update(); + + } + + + + shift = false; + + _shift = undefined; + + on_Shift_click() { + + this.shift = !this.shift; + + this.caps = false; + + this.update(); + + } + + editstr = ""; + + _editstr = undefined; + + result_callback_obj = undefined; + + start_edit(info, valuetype, callback_obj, initial,size) { + + show_modal.call(this,size); + + this.editstr = String(initial); + + this.result_callback_obj = callback_obj; + + this.Info_elt.textContent = info; + + this.shift = false; + + this.caps = false; + + this.initial = initial; + + + + this.update(); + + } + + + + update() { + + if(this.editstr != this._editstr){ + + this._editstr = this.editstr; + + this.Value_elt.textContent = this.editstr; + + } + + if(this.Shift_sub && this.shift != this._shift){ + + this._shift = this.shift; + + (this.shift?this.activate_activable:this.inactivate_activable)(this.Shift_sub); + + } + + if(this.CapsLock_sub && this.caps != this._caps){ + + this._caps = this.caps; + + (this.caps?this.activate_activable:this.inactivate_activable)(this.CapsLock_sub); + + } + + } + + } + + + + + + + + Esc Enter BackSpace Keys Info Value + + + + + + Sign Space NumDot + + + + + + + CapsLock Shift + + + + + init: function() { + + + id(" + + ").setAttribute("onclick", "hmi_widgets[' + + '].on_key_click(' + + ')"); + + + + if(this. + + _elt) + + this. + + _elt.setAttribute("onclick", "hmi_widgets[' + + '].on_ + + _click()"); + + + }, + + + + + coordinates: [ + + , + + ], + + + + + + + + + + items: { + + + + + : " + + ", + + + }, + + + + + + + + styles: { + + + + + + : " + + ", + + + }, + + + + + + + + Meter widget moves the end of "needle" labeled path along "range" labeled + + path, according to value of the single accepted variable. + + + + Needle is reduced to a single segment. If "min" a "max" labeled texts + + are provided, or if first and second argument are given, then they are used + + as respective minimum and maximum value. Otherwise, value is expected to be + + in between 0 and 100. + + + + If "value" labeled text is found, then its content is replaced by value. + + + + Moves "needle" along "range" + + + minimum value + + + maximum value + + + Value to display + + + + class + MeterWidget + extends Widget{ + + frequency = 10; + + origin = undefined; + + range = undefined; + + + + dispatch(value) { + + this.display_val = value; + + this.request_animate(); + + } + + + + animate(){ + + if(this.value_elt) + + this.value_elt.textContent = String(this.display_val); + + let [min,max,totallength] = this.range; + + let length = Math.max(0,Math.min(totallength,(Number(this.display_val)-min)*totallength/(max-min))); + + let tip = this.range_elt.getPointAtLength(length); + + this.needle_elt.setAttribute('d', "M "+this.origin.x+","+this.origin.y+" "+tip.x+","+tip.y); + + } + + + + init() { + + let [min,max] = [[this.min_elt,0],[this.max_elt,100]].map(([elt,def],i)=>elt? + + Number(elt.textContent) : + + this.args.length >= i+1 ? this.args[i] : def); + + + + this.range = [min, max, this.range_elt.getTotalLength()] + + this.origin = this.needle_elt.getPointAtLength(0); + + } + + } + + + + + + + + needle range + + + + + + value min max + + + + + + + + Mutlistateh widget hides all subelements whose label do not match given + + variable value representation. For exemple if given variable type + + is HMI_INT and value is 1, then elements with label '1' will be displayed. + + Label can have comments, so '1#some comment' would also match. If matching + + variable of type HMI_STRING, then double quotes must be used. For exemple, + + '"hello"' or '"hello"#another comment' match HMI_STRING 'hello'. + + + + Click on widget changes variable value to next value in given list, or to + + first one if not initialized to value already part of the list. + + + + Show elements whose label match value. + + + value to compare to labels + + + + class + MultiStateWidget + extends Widget{ + + frequency = 5; + + state = 0; + + dispatch(value) { + + this.state = value; + + for(let choice of this.choices){ + + if(this.state != choice.value){ + + choice.elt.setAttribute("style", "display:none"); + + } else { + + choice.elt.setAttribute("style", choice.style); + + } + + } + + } + + + + on_click(evt) { + + //get current selected value + + let next_ind; + + for(next_ind=0; next_ind<this.choices.length; next_ind++){ + + if(this.state == this.choices[next_ind].value){ + + next_ind = next_ind + 1; + + break; + + } + + } + + + + //get next selected value + + if(this.choices.length > next_ind){ + + this.state = this.choices[next_ind].value; + + } + + else{ + + this.state = this.choices[0].value; + + } + + + + //post value to plc + + this.apply_hmi_value(0, this.state); + + } + + + + init() { + + this.element.setAttribute("onclick", "hmi_widgets['"+this.element_id+"'].on_click(evt)"); + + } + + } + + + + + choices: [ + + + + + { + + elt:id(" + + "), + + style:" + + ", + + value: + + + + } + + , + + + + + ], + + + + + + + + ScrollBar - documentation to be written + + + + ScrollBar + + + value + + + range + + + visible + + + + class + ScrollBarWidget + extends Widget{ + + frequency = 10; + + position = undefined; + + range = undefined; + + size = undefined; + + mincursize = 0.1; + + + + dispatch(value,oldval, index) { + + switch(index) { + + case 0: + + this.range = Math.max(1,value); + + break; + + case 1: + + this.position = value; + + break; + + case 2: + + this.size = value; + + break; + + } + + + + this.request_animate(); + + } + + + + get_ratios() { + + let range = this.range; + + let size = Math.max(this.range * this.mincursize, Math.min(this.size, range)); + + let maxh = this.range_elt.height.baseVal.value; + + let pixels = maxh; + + let units = range; + + return [size, maxh, range, pixels, units]; + + } + + + + animate(){ + + if(this.position == undefined || this.range == undefined || this.size == undefined) + + return; + + let [size, maxh, range, pixels, units] = this.get_ratios(); + + + + let new_y = this.range_elt.y.baseVal.value + Math.round(Math.min(this.position,range-size) * pixels / units); + + let new_height = Math.round(maxh * size/range); + + + + this.cursor_elt.y.baseVal.value = new_y; + + this.cursor_elt.height.baseVal.value = new_height; + + } + + + + init_mandatory() { + + this.cursor_elt.onpointerdown = () => this.on_cursor_down(); + + + + this.bound_drag = this.drag.bind(this); + + this.bound_drop = this.drop.bind(this); + + } + + + + apply_position(position){ + + this.position = Math.round(Math.max(Math.min(position, this.range - this.size), 0)); + + this.apply_hmi_value(1, this.position); + + } + + + + on_page_click(is_up){ + + this.apply_position(is_up ? this.position-this.size + + : this.position+this.size); + + } + + + + on_cursor_down(e){ + + // get scrollbar -> root transform + + let ctm = this.range_elt.getCTM(); + + // relative motion -> discard translation + + ctm.e = 0; + + ctm.f = 0; + + // root -> scrollbar transform + + this.invctm = ctm.inverse(); + + svg_root.addEventListener("pointerup", this.bound_drop, true); + + svg_root.addEventListener("pointermove", this.bound_drag, true); + + this.dragpos = this.position; + + } + + + + drop(e) { + + svg_root.removeEventListener("pointerup", this.bound_drop, true); + + svg_root.removeEventListener("pointermove", this.bound_drag, true); + + } + + + + drag(e) { + + let [size, maxh, range, pixels, units] = this.get_ratios(); + + if(pixels == 0) return; + + let point = new DOMPoint(e.movementX, e.movementY); + + let movement = point.matrixTransform(this.invctm).y; + + this.dragpos += movement * units / pixels; + + this.apply_position(this.dragpos); + + } + + } + + + + + + + + cursor range + + + + + + + pageup pagedown + + + + + + + init: function() { + + this.init_mandatory(); + + + this.pageup_elt.onclick = () => this.on_page_click(true); + + this.pagedown_elt.onclick = () => this.on_page_click(false); + + + }, + + + + + + + + Slider - DEPRECATED - use ScrollBar or PathSlider instead + + + + Slider - DEPRECATED - use ScrollBar instead + + + value + + + range + + + visible + + + + class + SliderWidget + extends Widget{ + + frequency = 5; + + range = undefined; + + handle_orig = undefined; + + scroll_size = undefined; + + scroll_range = 0; + + scroll_visible = 7; + + min_size = 0.07; + + fi = undefined; + + curr_value = 0; + + drag = false; + + enTimer = false; + + handle_click = undefined; + + last_drag = false; + + + + dispatch(value,oldval, index) { + + if (index == 0){ + + let [min,max,start,totallength] = this.range; + + //save current value inside widget + + this.curr_value = value; + + + + //check if in range + + if (this.curr_value > max){ + + this.curr_value = max; + + this.apply_hmi_value(0, this.curr_value); + + } + + else if (this.curr_value < min){ + + this.curr_value = min; + + this.apply_hmi_value(0, this.curr_value); + + } + + + + if(this.value_elt) + + this.value_elt.textContent = String(value); + + } + + else if(index == 1){ + + this.scroll_range = value; + + this.set_scroll(); + + } + + else if(index == 2){ + + this.scroll_visible = value; + + this.set_scroll(); + + } + + + + //don't update if draging and setpoint ghost doesn't exist + + if(!this.drag || (this.setpoint_elt != undefined)){ + + this.update_DOM(this.curr_value, this.handle_elt); + + } + + } + + + + set_scroll(){ + + //check if range is bigger than visible and set scroll size + + if(this.scroll_range > this.scroll_visible){ + + this.scroll_size = this.scroll_range - this.scroll_visible; + + this.range[0] = 0; + + this.range[1] = this.scroll_size; + + } + + else{ + + this.scroll_size = 1; + + this.range[0] = 0; + + this.range[1] = 1; + + } + + } + + + + update_DOM(value, elt){ + + let [min,max,start,totallength] = this.range; + + // check if handle is resizeable + + if (this.scroll_size != undefined){ //size changes + + //get parameters + + let length = Math.max(min,Math.min(max,(Number(value)-min)*max/(max-min))); + + let tip = this.range_elt.getPointAtLength(length); + + let handle_min = totallength*this.min_size; + + + + let step = 1; + + //check if range is bigger than max displayed and recalculate step + + if ((totallength/handle_min) < (max-min+1)){ + + step = (max-min+1)/(totallength/handle_min-1); + + } + + + + let kx,ky,offseY,offseX = undefined; + + //scale on x or y axes + + if (this.fi > 0.75){ + + //get scale factor + + if(step > 1){ + + ky = handle_min/this.handle_orig.height; + + } + + else{ + + ky = (totallength-handle_min*(max-min))/this.handle_orig.height; + + } + + kx = 1; + + //get 0 offset to stay inside range + + offseY = start.y - (this.handle_orig.height + this.handle_orig.y) * ky; + + offseX = 0; + + //get distance from value + + tip.y =this.range_elt.getPointAtLength(0).y - length/step *handle_min; + + } + + else{ + + //get scale factor + + if(step > 1){ + + kx = handle_min/this.handle_orig.width; + + } + + else{ + + kx = (totallength-handle_min*(max-min))/this.handle_orig.width; + + } + + ky = 1; + + //get 0 offset to stay inside range + + offseX = start.x - (this.handle_orig.x * kx); + + offseY = 0; + + //get distance from value + + tip.x =this.range_elt.getPointAtLength(0).x + length/step *handle_min; + + } + + elt.setAttribute('transform',"matrix("+(kx)+" 0 0 "+(ky)+" "+(tip.x-start.x+offseX)+" "+(tip.y-start.y+offseY)+")"); + + } + + else{ //size stays the same + + let length = Math.max(0,Math.min(totallength,(Number(value)-min)*totallength/(max-min))); + + let tip = this.range_elt.getPointAtLength(length); + + elt.setAttribute('transform',"translate("+(tip.x-start.x)+","+(tip.y-start.y)+")"); + + } + + + + // show or hide ghost if exists + + if(this.setpoint_elt != undefined){ + + if(this.last_drag!= this.drag){ + + if(this.drag){ + + this.setpoint_elt.setAttribute("style", this.setpoint_style); + + }else{ + + this.setpoint_elt.setAttribute("style", "display:none"); + + } + + this.last_drag = this.drag; + + } + + } + + } + + + + on_release(evt) { + + //unbind events + + window.removeEventListener("touchmove", this.on_bound_drag, true); + + window.removeEventListener("mousemove", this.on_bound_drag, true); + + + + window.removeEventListener("mouseup", this.bound_on_release, true); + + window.removeEventListener("touchend", this.bound_on_release, true); + + window.removeEventListener("touchcancel", this.bound_on_release, true); + + + + //reset drag flag + + if(this.drag){ + + this.drag = false; + + } + + + + // get final position + + this.update_position(evt); + + + + } + + + + on_drag(evt){ + + //ignore drag event for X amount of time and if not selected + + if(this.enTimer && this.drag){ + + this.update_position(evt); + + + + //reset timer + + this.enTimer = false; + + setTimeout("{hmi_widgets['"+this.element_id+"'].enTimer = true;}", 100); + + } + + } + + + + update_position(evt){ + + var html_dist = 0; + + let [min,max,start,totallength] = this.range; + + + + //calculate size of widget in html + + var range_borders = this.range_elt.getBoundingClientRect(); + + var [minX,minY,maxX,maxY] = [range_borders.left,range_borders.bottom,range_borders.right,range_borders.top]; + + var range_length = Math.sqrt( range_borders.height*range_borders.height + range_borders.width*range_borders.width ); + + + + //get range and mouse coordinates + + var mouseX = undefined; + + var mouseY = undefined; + + if (evt.type.startsWith("touch")){ + + mouseX = Math.ceil(evt.touches[0].clientX); + + mouseY = Math.ceil(evt.touches[0].clientY); + + } + + else{ + + mouseX = evt.pageX; + + mouseY = evt.pageY; + + } + + + + // calculate position + + if (this.handle_click){ //if clicked on handle + + let moveDist = 0, resizeAdd = 0; + + let range_percent = 1; + + + + //set paramters for resizeable handle + + if (this.scroll_size != undefined){ + + // add one more object to stay inside range + + resizeAdd = 1; + + + + //chack if range is bigger than display option and + + // calculate percent of range with out handle + + if(((max/(max*this.min_size)) < (max-min+1))){ + + range_percent = 1-this.min_size; + + } + + else{ + + range_percent = 1-(max-max*this.min_size*(max-min))/max; + + } + + } + + + + //calculate value difference on x or y axis + + if(this.fi > 0.7){ + + moveDist = ((max-min+resizeAdd)/(range_length*range_percent))*((this.handle_click[1]-mouseY)/Math.sin(this.fi)); + + } + + else{ + + moveDist = ((max-min+resizeAdd)/(range_length*range_percent))*((mouseX-this.handle_click[0])/Math.cos(this.fi)); + + } + + + + this.curr_value = Math.ceil(this.handle_click[2] + moveDist); + + } + + else{ //if clicked on widget + + //get handle distance from mouse position + + if (minX > mouseX && minY < mouseY){ + + html_dist = 0; + + } + + else if (maxX < mouseX && maxY > mouseY){ + + html_dist = range_length; + + } + + else{ + + if(this.fi > 0.7){ + + html_dist = (minY - mouseY)/Math.sin(this.fi); + + } + + else{ + + html_dist = (mouseX - minX)/Math.cos(this.fi); + + } + + } + + //calculate distance + + this.curr_value=Math.ceil((html_dist/range_length)*(this.range[1]-this.range[0])+this.range[0]); + + } + + + + //check if in range and apply + + if (this.curr_value > max){ + + this.curr_value = max; + + } + + else if (this.curr_value < min){ + + this.curr_value = min; + + } + + this.apply_hmi_value(0, this.curr_value); + + + + //redraw handle + + this.request_animate(); + + + + } + + + + animate(){ + + // redraw handle on screen refresh + + // check if setpoint(ghost) handle exsist otherwise update main handle + + if(this.setpoint_elt != undefined){ + + this.update_DOM(this.curr_value, this.setpoint_elt); + + } + + else{ + + this.update_DOM(this.curr_value, this.handle_elt); + + } + + } + + + + on_select(evt){ + + //enable drag flag and timer + + this.drag = true; + + this.enTimer = true; + + + + //bind events + + window.addEventListener("touchmove", this.on_bound_drag, true); + + window.addEventListener("mousemove", this.on_bound_drag, true); + + + + window.addEventListener("mouseup", this.bound_on_release, true); + + window.addEventListener("touchend", this.bound_on_release, true); + + window.addEventListener("touchcancel", this.bound_on_release, true); + + + + // check if handle was pressed + + if (evt.currentTarget == this.handle_elt){ + + //get mouse position on the handle + + let mouseX = undefined; + + let mouseY = undefined; + + if (evt.type.startsWith("touch")){ + + mouseX = Math.ceil(evt.touches[0].clientX); + + mouseY = Math.ceil(evt.touches[0].clientY); + + } + + else{ + + mouseX = evt.pageX; + + mouseY = evt.pageY; + + } + + //save coordinates and orig value + + this.handle_click = [mouseX,mouseY,this.curr_value]; + + } + + else{ + + // get new handle position and reset if handle was not pressed + + this.handle_click = undefined; + + this.update_position(evt); + + } + + + + //prevent next events + + evt.stopPropagation(); + + + + } + + + + + + init() { + + //set min max value if not defined + + let min = this.min_elt ? + + Number(this.min_elt.textContent) : + + this.args.length >= 1 ? this.args[0] : 0; + + let max = this.max_elt ? + + Number(this.max_elt.textContent) : + + this.args.length >= 2 ? this.args[1] : 100; + + + + + + // save initial parameters + + this.range_elt.style.strokeMiterlimit="0"; + + this.range = [min, max, this.range_elt.getPointAtLength(0),this.range_elt.getTotalLength()]; + + let start = this.range_elt.getPointAtLength(0); + + let end = this.range_elt.getPointAtLength(this.range_elt.getTotalLength()); + + this.fi = Math.atan2(start.y-end.y, end.x-start.x); + + this.handle_orig = this.handle_elt.getBBox(); + + + + //bind functions + + this.bound_on_select = this.on_select.bind(this); + + this.bound_on_release = this.on_release.bind(this); + + this.on_bound_drag = this.on_drag.bind(this); + + + + this.handle_elt.addEventListener("mousedown", this.bound_on_select); + + this.element.addEventListener("mousedown", this.bound_on_select); + + this.element.addEventListener("touchstart", this.bound_on_select); + + //touch recognised as page drag without next command + + document.body.addEventListener("touchstart", function(e){}, false); + + + + //save ghost style + + if(this.setpoint_elt != undefined){ + + this.setpoint_style = this.setpoint_elt.getAttribute("style"); + + this.setpoint_elt.setAttribute("style", "display:none"); + + } + + + + } + + } + + + + + + + + handle range + + + + + + value min max setpoint + + + + + + + + + + Switch widget hides all subelements whose label do not match given + + variable current value representation. For exemple if given variable type + + is HMI_INT and value is 1, then elements with label '1' will be displayed. + + Label can have comments, so '1#some comment' would also match. If matching + + variable of type HMI_STRING, then double quotes must be used. For exemple, + + '"hello"' or '"hello"#another comment' match HMI_STRING 'hello'. + + + + Show elements whose label match value. + + + value to compare to labels + + + + class + SwitchWidget + extends Widget{ + + frequency = 5; + + dispatch(value) { + + for(let choice of this.choices){ + + if(value != choice.value){ + + choice.elt.setAttribute("style", "display:none"); + + } else { + + choice.elt.setAttribute("style", choice.style); + + } + + } + + } + + } + + + + + choices: [ + + + + + + + + { + + elt:id(" + + "), + + style:" + + ", + + value: + + + + } + + , + + + + + ], + + + + + + + + Button widget takes one boolean variable path, and reflect current true + + or false value by showing "active" or "inactive" labeled element + + respectively. Clicking or touching button toggles variable. + + + + Toggle button reflecting given boolean variable + + + Boolean variable + + + + class + ToggleButtonWidget + extends Widget{ + + frequency = 5; + + state = 0; + + active_style = undefined; + + inactive_style = undefined; + + + + dispatch(value) { + + this.state = value; + + //redraw toggle button + + this.request_animate(); + + } + + + + on_click(evt) { + + //toggle state and apply + + this.state = this.state ? false : true; + + this.apply_hmi_value(0, this.state); + + + + //redraw toggle button + + this.request_animate(); + + } + + + + activate(val) { + + let [active, inactive] = val ? ["none",""] : ["", "none"]; + + if (this.active_elt) + + this.active_elt.style.display = active; + + if (this.inactive_elt) + + this.inactive_elt.style.display = inactive; + + } + + + + animate(){ + + // redraw toggle button on screen refresh + + this.activate(this.state); + + } + + + + init() { + + this.activate(false); + + this.element.onclick = (evt) => this.on_click(evt); + + } + + } + + + + + + + + active inactive + + + + + + + Made with SVGHMI. https://beremiz.org + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/gen_index_xhtml.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/gen_index_xhtml.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,101 @@ +include yslt_noindent.yml2 + +// overrides yslt's output function to set CDATA +decl output(method, cdata-section-elements="xhtml:script"); + +// helper to emit some content to internal namespaces +decl emit(*name) alias - { + *name; + template *name { + // value "ns:ProgressStart(name())"; + | + | /* «local-name()» */ + | + content; + | + // value "ns:ProgressEnd(name())"; + } +}; + +istylesheet + /* From Inkscape */ + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:xhtml="http://www.w3.org/1999/xhtml" + + /* Internal namespaces to allow emit code/content from anywhere */ + xmlns:debug="debug" + xmlns:preamble="preamble" + xmlns:declarations="declarations" + xmlns:definitions="definitions" + xmlns:epilogue="epilogue" + + /* Namespace to invoke python code */ + xmlns:ns="beremiz" + + extension-element-prefixes="ns func exsl regexp str dyn" + exclude-result-prefixes="ns func exsl regexp str dyn debug preamble epilogue declarations definitions" { + + const "svg", "/svg:svg"; + const "hmi_elements", "//svg:*[starts-with(@inkscape:label, 'HMI:')]"; + + + include hmi_tree.ysl2 + + include geometry.ysl2 + + include detachable_pages.ysl2 + + include inline_svg.ysl2 + + include i18n.ysl2 + + include widgets_common.ysl2 + + include widget_*.ysl2 + + + template "/" { + comment > Made with SVGHMI. https://beremiz.org + + // all debug output from included definitions, as comments + // comment apply "document('')/*/debug:*"; + + html xmlns="http://www.w3.org/1999/xhtml" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" { + head { + style type="text/css" media="screen" { + value "ns:GetFonts()"; + } + } + // prevents user selection by mouse click / touch and drag + // prevents pinch zoom and other accidental panning panning with touch devices + body style="margin:0;overflow:hidden;user-select:none;touch-action:none;" { + // Inline SVG + copy "$result_svg"; + script{ + | \n//\n//\n// Early independent declarations \n//\n// + apply "document('')/*/preamble:*"; + + | \n//\n//\n// Declarations depending on preamble \n//\n// + apply "document('')/*/declarations:*"; + + | \n//\n//\n// Order independent declaration and code \n//\n// + apply "document('')/*/definitions:*"; + + | \n//\n//\n// Statements that needs to be at the end \n//\n// + apply "document('')/*/epilogue:*"; + + include text svghmi.js + + } + } + } + } +} diff -r 577118ebd179 -r c89fc366bebd svghmi/geometry.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/geometry.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,147 @@ +// geometry.ysl2 +// +// Geometry (bounding box intersection) definitions + +// This retrieves geometry obtained through "inkscape -S" +// already parsed by python and presented as a list of +// +const "all_geometry", "ns:GetSVGGeometry()"; +const "defs", "//svg:defs/descendant-or-self::svg:*"; +const "geometry", "$all_geometry[not(@Id = $defs/@id)]"; + +// Debug data +emit "debug:geometry" { + | ID, x, y, w, h + foreach "$geometry" + | «@Id» «@x» «@y» «@w» «@h» +} + +// Rates 1D intersection of 2 segments A and B +// described respectively with a0,a1 and b0,b1 +def "func:intersect_1d" { + // it is assumed that a1 > a0 and b1 > b0 + param "a0"; + param "a1"; + param "b0"; + param "b1"; + + const "d0", "$a0 >= $b0"; + const "d1", "$a1 >= $b1"; + choose { + when "not($d0) and $d1" + // b contained in a + // a0 0 ))]"""; +} diff -r 577118ebd179 -r c89fc366bebd svghmi/hmi_tree.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/hmi_tree.py Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,164 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz +# Copyright (C) 2021: Edouard TISSERANT +# +# See COPYING file for copyrights details. + +from __future__ import absolute_import +from itertools import izip, imap +from pprint import pformat +import weakref +import hashlib + +from lxml import etree + +HMI_TYPES_DESC = { + "HMI_NODE":{}, + "HMI_STRING":{}, + "HMI_INT":{}, + "HMI_BOOL":{}, + "HMI_REAL":{} +} + +HMI_TYPES = HMI_TYPES_DESC.keys() + +class HMITreeNode(object): + def __init__(self, path, name, nodetype, iectype = None, vartype = None, cpath = None, hmiclass = None): + self.path = path + self.name = name + self.nodetype = nodetype + self.hmiclass = hmiclass + self.parent = None + + if iectype is not None: + self.iectype = iectype + self.vartype = vartype + self.cpath = cpath + + if nodetype in ["HMI_NODE"]: + self.children = [] + + def pprint(self, indent = 0): + res = ">"*indent + pformat(self.__dict__, indent = indent, depth = 1) + "\n" + if hasattr(self, "children"): + res += "\n".join([child.pprint(indent = indent + 1) + for child in self.children]) + res += "\n" + + return res + + def place_node(self, node): + best_child = None + known_best_match = 0 + potential_siblings = {} + for child in self.children: + if child.path is not None: + in_common = 0 + for child_path_item, node_path_item in izip(child.path, node.path): + if child_path_item == node_path_item: + in_common +=1 + else: + break + # Match can only be HMI_NODE, and the whole path of node + # must match candidate node (except for name part) + # since candidate would become child of that node + if in_common > known_best_match and \ + child.nodetype == "HMI_NODE" and \ + in_common == len(child.path) - 1: + known_best_match = in_common + best_child = child + else: + potential_siblings[child.path[ + -2 if child.nodetype == "HMI_NODE" else -1]] = child + if best_child is not None: + if node.nodetype == "HMI_NODE" and best_child.path[:-1] == node.path[:-1]: + return "Duplicate_HMI_NODE", best_child + return best_child.place_node(node) + else: + candidate_name = node.path[-2 if node.nodetype == "HMI_NODE" else -1] + if candidate_name in potential_siblings: + return "Non_Unique", potential_siblings[candidate_name] + + if node.nodetype == "HMI_NODE" and len(self.children) > 0: + prev = self.children[-1] + if prev.path[:-1] == node.path[:-1]: + return "Late_HMI_NODE",prev + + node.parent = weakref.ref(self) + self.children.append(node) + return None + + def etree(self, add_hash=False): + + attribs = dict(name=self.name) + if self.path is not None: + attribs["path"] = ".".join(self.path) + + if self.hmiclass is not None: + attribs["class"] = self.hmiclass + + if add_hash: + attribs["hash"] = ",".join(map(str,self.hash())) + + res = etree.Element(self.nodetype, **attribs) + + if hasattr(self, "children"): + for child_etree in imap(lambda c:c.etree(), self.children): + res.append(child_etree) + + return res + + @classmethod + def from_etree(cls, enode): + """ + alternative constructor, restoring HMI Tree from XML backup + note: all C-related information is gone, + this restore is only for tree display and widget picking + """ + nodetype = enode.tag + attributes = enode.attrib + name = attributes["name"] + path = attributes["path"].split('.') if "path" in attributes else None + hmiclass = attributes.get("class", None) + # hash is computed on demand + node = cls(path, name, nodetype, hmiclass=hmiclass) + for child in enode.iterchildren(): + newnode = cls.from_etree(child) + newnode.parent = weakref.ref(node) + node.children.append(newnode) + return node + + def traverse(self): + yield self + if hasattr(self, "children"): + for c in self.children: + for yoodl in c.traverse(): + yield yoodl + + def hmi_path(self): + if self.parent is None: + return "/" + p = self.parent() + if p.parent is None: + return "/" + self.name + return p.hmi_path() + "/" + self.name + + def hash(self): + """ Produce a hash, any change in HMI tree structure change that hash """ + s = hashlib.new('md5') + self._hash(s) + # limit size to HMI_HASH_SIZE as in svghmi.c + return map(ord,s.digest())[:8] + + def _hash(self, s): + s.update(str((self.name,self.nodetype))) + if hasattr(self, "children"): + for c in self.children: + c._hash(s) + +SPECIAL_NODES = [("HMI_ROOT", "HMI_NODE"), + ("heartbeat", "HMI_INT")] + # ("current_page", "HMI_STRING")]) + diff -r 577118ebd179 -r c89fc366bebd svghmi/hmi_tree.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/hmi_tree.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,129 @@ +// hmi_tree.ysl2 + + +// HMI Tree computed from VARIABLES.CSV in svghmi.py +const "hmitree", "ns:GetHMITree()"; + +const "_categories" { + noindex > HMI_PLC_STATUS + noindex > HMI_CURRENT_PAGE +} +const "categories", "exsl:node-set($_categories)"; + +// HMI Tree Index +const "_indexed_hmitree" apply "$hmitree", mode="index"; +const "indexed_hmitree", "exsl:node-set($_indexed_hmitree)"; + +emit "preamble:hmi-tree" { + | var hmi_hash = [«$hmitree/@hash»]; + | + | var heartbeat_index = «$indexed_hmitree/*[@hmipath = '/HEARTBEAT']/@index»; + | + | var hmitree_types = [ + + foreach "$indexed_hmitree/*" + | /* «@index» */ "«substring(local-name(), 5)»"`if "position()!=last()" > ,` + + | ]; + | + | var hmitree_paths = [ + + foreach "$indexed_hmitree/*" + | /* «@index» */ "«@hmipath»"`if "position()!=last()" > ,` + + | ]; + | +} + +template "*", mode="index" { + param "index", "0"; + param "parentpath", "''"; + const "content" { + const "path" + choose { + when "count(ancestor::*)=0" > / + when "count(ancestor::*)=1" > /«@name» + otherwise > «$parentpath»/«@name» + } + choose { + when "not(local-name() = $categories/noindex)" { + xsl:copy { + attrib "index" > «$index» + attrib "hmipath" > «$path» + foreach "@*" xsl:copy; + } + apply "*[1]", mode="index"{ + with "index", "$index + 1"; + with "parentpath" > «$path» + } + } + otherwise { + apply "*[1]", mode="index"{ + with "index", "$index"; + with "parentpath" > «$path» + } + } + } + } + + copy "$content"; + apply "following-sibling::*[1]", mode="index" { + with "index", "$index + count(exsl:node-set($content)/*)"; + with "parentpath" > «$parentpath» + } +} + +include parse_labels.ysl2 + +const "_parsed_widgets" { + widget type="VarInitPersistent" { + arg value="0"; + path value="lang"; + } + apply "$hmi_elements", mode="parselabel"; +} + +const "parsed_widgets","exsl:node-set($_parsed_widgets)"; + +def "func:widget" { + param "id"; + result "$parsed_widgets/widget[@id = $id]"; +} + +def "func:is_descendant_path" { + param "descend"; + param "ancest"; + // TODO : use HMI tree to answer more accurately + result "string-length($ancest) > 0 and starts-with($descend,$ancest)"; +} + +def "func:same_class_paths" { + param "a"; + param "b"; + const "class_a", "$indexed_hmitree/*[@hmipath = $a]/@class"; + const "class_b", "$indexed_hmitree/*[@hmipath = $b]/@class"; + result "$class_a and $class_b and $class_a = $class_b"; +} + +// Debug data +template "*", mode="testtree"{ + param "indent", "''"; + > «$indent» «local-name()» + foreach "@*" > «local-name()»="«.»" + > \n + apply "*", mode="testtree" { + with "indent" value "concat($indent,'>')" + }; +} + +emit "debug:hmi-tree" { + | Raw HMI tree + apply "$hmitree", mode="testtree"; + | + | Indexed HMI tree + apply "$indexed_hmitree", mode="testtree"; + | + | Parsed Widgets + copy "_parsed_widgets"; + apply "$parsed_widgets", mode="testtree"; +} diff -r 577118ebd179 -r c89fc366bebd svghmi/i18n.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/i18n.py Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,353 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz +# Copyright (C) 2021: Edouard TISSERANT +# +# See COPYING file for copyrights details. + +from __future__ import absolute_import +from lxml import etree +import os +import sys +import subprocess +import time +import ast +import wx +import re + +# to have it for python 2, had to install +# https://pypi.org/project/pycountry/18.12.8/ +# python2 -m pip install pycountry==18.12.8 --user +import pycountry + +cmd_parser = re.compile(r'(?:"([^"]+)"\s*|([^\s]+)\s*)?') + +def open_pofile(pofile): + """ Opens PO file with POEdit """ + + if sys.platform.startswith('win'): + from six.moves import winreg + poedit_cmd = None + try: + poedit_cmd = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, + 'SOFTWARE\\Classes\\poedit\\shell\\open\\command') + cmd = re.findall(cmd_parser, poedit_cmd) + dblquote_value,smpl_value = cmd[0] + poedit_path = dblquote_value+smpl_value + except OSError: + poedit_path = None + + else: + try: + poedit_path = subprocess.check_output("command -v poedit", shell=True).strip() + except subprocess.CalledProcessError: + poedit_path = None + + if poedit_path is None: + wx.MessageBox("POEdit is not found or installed !") + else: + subprocess.Popen([poedit_path,pofile]) + +def EtreeToMessages(msgs): + """ Converts XML tree from 'extract_i18n' templates into a list of tuples """ + messages = [] + + for msg in msgs: + messages.append(( + "\n".join([line.text for line in msg]), + msg.get("label"), msg.get("id"))) + + return messages + +def SaveCatalog(fname, messages): + """ Save messages given as list of tupple (msg,label,id) in POT file """ + w = POTWriter() + w.ImportMessages(messages) + + with open(fname, 'w') as POT_file: + w.write(POT_file) + +def GetPoFiles(dirpath): + po_files = [fname for fname in os.listdir(dirpath) if fname.endswith(".po")] + po_files.sort() + return [(po_fname[:-3],os.path.join(dirpath, po_fname)) for po_fname in po_files] + +def ReadTranslations(dirpath): + """ Read all PO files from a directory and return a list of (langcode, translation_dict) tuples """ + + translations = [] + for translation_name, po_path in GetPoFiles(dirpath): + r = POReader() + with open(po_path, 'r') as PO_file: + r.read(PO_file) + translations.append((translation_name, r.get_messages())) + return translations + +def MatchTranslations(translations, messages, errcallback): + """ + Matches translations against original message catalog, + warn about inconsistancies, + returns list of langs, and a list of (msgid, [translations]) tuples + """ + translated_messages = [] + broken_lang = set() + for msgid,label,svgid in messages: + translated_message = [] + for langcode,translation in translations: + msg = translation.pop(msgid, None) + if msg is None: + broken_lang.add(langcode) + errcallback(_('{}: Missing translation for "{}" (label:{}, id:{})\n').format(langcode,msgid,label,svgid)) + translated_message.append(msgid) + else: + translated_message.append(msg) + translated_messages.append((msgid,translated_message)) + langs = [] + for langcode,translation in translations: + try: + l,c = langcode.split("_") + language_name = pycountry.languages.get(alpha_2 = l).name + country_name = pycountry.countries.get(alpha_2 = c).name + langname = "{} ({})".format(language_name, country_name) + except: + try: + langname = pycountry.languages.get(alpha_2 = langcode).name + except: + langname = langcode + + langs.append((langname,langcode)) + + broken = False + for msgid, msg in translation.iteritems(): + broken = True + errcallback(_('{}: Unused translation "{}":"{}"\n').format(langcode,msgid,msg)) + if broken or langcode in broken_lang: + errcallback(_('Translation for {} is outdated, please edit {}.po, click "Catalog -> Update from POT File..." and select messages.pot.\n').format(langcode,langcode)) + + + return langs,translated_messages + + +def TranslationToEtree(langs,translated_messages): + + result = etree.Element("translations") + + langsroot = etree.SubElement(result, "langs") + for name, code in langs: + langel = etree.SubElement(langsroot, "lang", {"code":code}) + langel.text = name + + msgsroot = etree.SubElement(result, "messages") + for msgid, msgs in translated_messages: + msgidel = etree.SubElement(msgsroot, "msgid") + for msg in msgs: + msgel = etree.SubElement(msgidel, "msg") + for line in msg.split("\n"): + lineel = etree.SubElement(msgel, "line") + lineel.text = escape(line.encode("utf-8")).decode("utf-8") + + return result + + + +locpfx = '#:svghmi.svg:' + +pot_header = '''\ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\\n" +"POT-Creation-Date: %(time)s\\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n" +"Last-Translator: FULL NAME \\n" +"Language-Team: LANGUAGE \\n" +"MIME-Version: 1.0\\n" +"Content-Type: text/plain; charset=UTF-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Generated-By: SVGHMI 1.0\\n" + +''' +escapes = [] + +def make_escapes(pass_iso8859): + global escapes + escapes = [chr(i) for i in range(256)] + if pass_iso8859: + # Allow iso-8859 characters to pass through so that e.g. 'msgid + # "Höhe"' would result not result in 'msgid "H\366he"'. Otherwise we + # escape any character outside the 32..126 range. + mod = 128 + else: + mod = 256 + for i in range(mod): + if not(32 <= i <= 126): + escapes[i] = "\\%03o" % i + escapes[ord('\\')] = '\\\\' + escapes[ord('\t')] = '\\t' + escapes[ord('\r')] = '\\r' + escapes[ord('\n')] = '\\n' + escapes[ord('\"')] = '\\"' + +make_escapes(pass_iso8859 = True) + +EMPTYSTRING = '' + +def escape(s): + global escapes + s = list(s) + for i in range(len(s)): + s[i] = escapes[ord(s[i])] + return EMPTYSTRING.join(s) + +def normalize(s): + # This converts the various Python string types into a format that is + # appropriate for .po files, namely much closer to C style. + lines = s.split('\n') + if len(lines) == 1: + s = '"' + escape(s) + '"' + else: + if not lines[-1]: + del lines[-1] + lines[-1] = lines[-1] + '\n' + for i in range(len(lines)): + lines[i] = escape(lines[i]) + lineterm = '\\n"\n"' + s = '""\n"' + lineterm.join(lines) + '"' + return s + + +class POTWriter: + def __init__(self): + self.__messages = {} + + def ImportMessages(self, msgs): + for msg, label, svgid in msgs: + self.addentry(msg.encode("utf-8"), label, svgid) + + def addentry(self, msg, label, svgid): + entry = (label, svgid) + self.__messages.setdefault(msg, set()).add(entry) + + def write(self, fp): + timestamp = time.strftime('%Y-%m-%d %H:%M+%Z') + print >> fp, pot_header % {'time': timestamp} + reverse = {} + for k, v in self.__messages.items(): + keys = list(v) + keys.sort() + reverse.setdefault(tuple(keys), []).append((k, v)) + rkeys = reverse.keys() + rkeys.sort() + for rkey in rkeys: + rentries = reverse[rkey] + rentries.sort() + for k, v in rentries: + v = list(v) + v.sort() + locline = locpfx + for label, svgid in v: + d = {'label': label, 'svgid': svgid} + s = _(' %(label)s:%(svgid)s') % d + if len(locline) + len(s) <= 78: + locline = locline + s + else: + print >> fp, locline + locline = locpfx + s + if len(locline) > len(locpfx): + print >> fp, locline + print >> fp, 'msgid', normalize(k) + print >> fp, 'msgstr ""\n' + + +class POReader: + def __init__(self): + self.__messages = {} + + def get_messages(self): + return self.__messages + + def add(self, msgid, msgstr, fuzzy): + "Add a non-fuzzy translation to the dictionary." + if not fuzzy and msgstr and msgid: + self.__messages[msgid.decode('utf-8')] = msgstr.decode('utf-8') + + def read(self, fp): + ID = 1 + STR = 2 + + lines = fp.readlines() + section = None + fuzzy = 0 + + # Parse the catalog + lno = 0 + for l in lines: + lno += 1 + # If we get a comment line after a msgstr, this is a new entry + if l[0] == '#' and section == STR: + self.add(msgid, msgstr, fuzzy) + section = None + fuzzy = 0 + # Record a fuzzy mark + if l[:2] == '#,' and 'fuzzy' in l: + fuzzy = 1 + # Skip comments + if l[0] == '#': + continue + # Now we are in a msgid section, output previous section + if l.startswith('msgid') and not l.startswith('msgid_plural'): + if section == STR: + self.add(msgid, msgstr, fuzzy) + section = ID + l = l[5:] + msgid = msgstr = '' + is_plural = False + # This is a message with plural forms + elif l.startswith('msgid_plural'): + if section != ID: + print >> sys.stderr, 'msgid_plural not preceded by msgid on %s:%d' %\ + (infile, lno) + sys.exit(1) + l = l[12:] + msgid += '\0' # separator of singular and plural + is_plural = True + # Now we are in a msgstr section + elif l.startswith('msgstr'): + section = STR + if l.startswith('msgstr['): + if not is_plural: + print >> sys.stderr, 'plural without msgid_plural on %s:%d' %\ + (infile, lno) + sys.exit(1) + l = l.split(']', 1)[1] + if msgstr: + msgstr += '\0' # Separator of the various plural forms + else: + if is_plural: + print >> sys.stderr, 'indexed msgstr required for plural on %s:%d' %\ + (infile, lno) + sys.exit(1) + l = l[6:] + # Skip empty lines + l = l.strip() + if not l: + continue + l = ast.literal_eval(l) + if section == ID: + msgid += l + elif section == STR: + msgstr += l + else: + print >> sys.stderr, 'Syntax error on %s:%d' % (infile, lno), \ + 'before:' + print >> sys.stderr, l + sys.exit(1) + # Add last entry + if section == STR: + self.add(msgid, msgstr, fuzzy) + + diff -r 577118ebd179 -r c89fc366bebd svghmi/i18n.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/i18n.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,54 @@ +// i18n.ysl2 + + +template "svg:tspan", mode="extract_i18n" { + if "string-length(.) > 0" line { + value "."; + } +} + +template "svg:text", mode="extract_i18n" { + msg { + attrib "id" value "@id"; + attrib "label" value "substring(@inkscape:label,2)"; + apply "svg:*", mode="extract_i18n"; + } +} + +const "translatable_texts", "//svg:text[starts-with(@inkscape:label, '_')]"; +const "translatable_strings" apply "$translatable_texts", mode="extract_i18n"; + +emit "preamble:i18n" { + const "translations", "ns:GetTranslations($translatable_strings)"; + > var langs = [ ["Default", "C"], + foreach "$translations/langs/lang" { + > ["«.»","«@code»"] + if "position()!=last()" > , + } + | ]; + | var translations = [ + foreach "$translatable_texts" { + const "n","position()"; + const "current_id","@id"; + const "text_unlinked_uses","$result_svg_ns//svg:text[@original = $current_id]/@id"; + > [[ + foreach "@id | $text_unlinked_uses" { + > id("«.»") + if "position()!=last()" > , + } + > ],[ + foreach "$translations/messages/msgid[$n]/msg" { + > " + foreach "line" { + value "."; + if "position()!=last()" > \\\\n + } + > " + if "position()!=last()" > , + } + > ]] + if "position()!=last()" > , + > \n + } + | ] +} diff -r 577118ebd179 -r c89fc366bebd svghmi/inline_svg.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/inline_svg.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,209 @@ +// inline_svg.ysl2 +// +// Produce Inline SVG element of resulting XHTML page. + +// Since stylesheet output namespace is xhtml, templates that output svg have to be explicitely declared as such +in xsl decl svgtmpl(match, xmlns="http://www.w3.org/2000/svg") alias template; +in xsl decl svgfunc(name, xmlns="http://www.w3.org/2000/svg") alias template; + + +// Identity template : +// - copy every attributes +// - copy every sub-elements + +svgtmpl "@*", mode="inline_svg" xsl:copy; + +template "node()", mode="inline_svg" { + // use real xsl:copy instead copy-of alias from yslt.yml2 + if "not(@id = $discardable_elements/@id)" + xsl:copy apply "@* | node()", mode="inline_svg"; +} + +// replaces inkscape's height and width hints. forces fit +template "svg:svg/@width", mode="inline_svg"; +template "svg:svg/@height", mode="inline_svg"; +svgtmpl "svg:svg", mode="inline_svg" svg { + attrib "preserveAspectRatio" > none + attrib "height" > 100vh + attrib "width" > 100vw + apply "@* | node()", mode="inline_svg"; +} +// ensure that coordinate in CSV file generated by inkscape are in default reference frame +template "svg:svg[@viewBox!=concat('0 0 ', @width, ' ', @height)]", mode="inline_svg" { + error > ViewBox settings other than X=0, Y=0 and Scale=1 are not supported +} +// ensure that coordinate in CSV file generated by inkscape match svg default unit +template "sodipodi:namedview[@units!='px' or @inkscape:document-units!='px']", mode="inline_svg" { + error > All units must be set to "px" in Inkscape's document properties +} + +// remove i18n markers, so that defs_by_labels can find text elements +svgtmpl "svg:text/@inkscape:label[starts-with(., '_')]", mode="inline_svg" { + attrib "{name()}" > «substring(., 2)» +} + +////// Clone unlinking +// +// svg:use (inkscape's clones) inside a widgets are +// replaced by real elements they refer in order to : +// - allow finding "needle" element in "meter" widget, +// even if "needle" is in a group refered by a svg use. +// - if "needle" is visible through a svg:use for +// each instance of the widget, then needle would show +// the same position in all instances +// +// For now, clone unlinkink applies to descendants of all widget except HMI:Page +// TODO: narrow application of clone unlinking to active elements, +// while keeping static decoration cloned +const "targets_not_to_unlink", "$hmi_lists/descendant-or-self::svg:*"; +const "to_unlink", "$hmi_elements[not(@id = $hmi_pages/@id)]/descendant-or-self::svg:use"; + +def "func:is_unlinkable" { + param "targetid"; + param "eltid"; + result "$eltid = $to_unlink/@id and not($targetid = $targets_not_to_unlink/@id)"; +} + +svgtmpl "svg:use", mode="inline_svg"{ + const "targetid","substring-after(@xlink:href,'#')"; + choose { + when "func:is_unlinkable($targetid, @id)" { + call "unlink_clone" { + with "targetid", "$targetid"; + } + } + otherwise xsl:copy apply "@*", mode="inline_svg"; + } +} + +// to unlink a clone, an group containing a copy of target element is created +// that way, style and transforms can be preserved +const "_excluded_use_attrs" { + name > href + name > width + name > height + name > x + name > y + name > id +} +const "excluded_use_attrs","exsl:node-set($_excluded_use_attrs)"; + +const "_merge_use_attrs" { + name > transform + name > style +} +const "merge_use_attrs","exsl:node-set($_merge_use_attrs)"; + +svgfunc "unlink_clone"{ + param "targetid"; + param "seed","''"; + const "target", "//svg:*[@id = $targetid]"; + const "seeded_id" choose { + when "string-length($seed) > 0" > «$seed»_«@id» + otherwise value "@id"; + } + g{ + attrib "id" value "$seeded_id"; + attrib "original" value "@id"; + + choose { + when "$target[self::svg:g]" { + foreach "@*[not(local-name() = $excluded_use_attrs/name | $merge_use_attrs)]" + attrib "{name()}" > «.» + + if "@style | $target/@style" + attrib "style" { + > «@style» + if "@style and $target/@style" > ; + > «$target/@style» + } + + if "@transform | $target/@transform" + attrib "transform" { + > «@transform» + if "@transform and $target/@transform" > + > «$target/@transform» + } + + apply "$target/*", mode="unlink_clone"{ + with "seed","$seeded_id"; + } + } + otherwise { + // include non excluded attributes + foreach "@*[not(local-name() = $excluded_use_attrs/name)]" + attrib "{name()}" > «.» + + apply "$target", mode="unlink_clone"{ + with "seed","$seeded_id"; + } + } + } + } +} + +// clone unlinking is really similar to deep-copy +// all nodes are sytematically copied +svgtmpl "@id", mode="unlink_clone" { + param "seed"; + attrib "id" > «$seed»_«.» + attrib "original" > «.» +} + +svgtmpl "@*", mode="unlink_clone" xsl:copy; + +svgtmpl "svg:use", mode="unlink_clone" { + param "seed"; + const "targetid","substring-after(@xlink:href,'#')"; + choose { + when "func:is_unlinkable($targetid, @id)" { + call "unlink_clone" { + with "targetid", "$targetid"; + with "seed","$seed"; + } + } + otherwise xsl:copy apply "@*", mode="unlink_clone" { + with "seed","$seed"; + } + } +} + +// copying widgets would have unwanted effect +// instead widget is refered through a svg:use. +svgtmpl "svg:*", mode="unlink_clone" { + param "seed"; + choose { + // node recursive copy ends when finding a widget + when "@id = $hmi_elements/@id" { + // place a clone instead of copying + use{ + attrib "xlink:href" > «concat('#',@id)» + } + } + otherwise { + xsl:copy apply "@* | node()", mode="unlink_clone" { + with "seed","$seed"; + } + } + } +} + +const "result_svg" apply "/", mode="inline_svg"; +const "result_svg_ns", "exsl:node-set($result_svg)"; + +emit "preamble:inline-svg" { + | let id = document.getElementById.bind(document); + | var svg_root = id("«$svg/@id»"); +} + +emit "debug:clone-unlinking" { + | + | Unlinked : + foreach "$to_unlink"{ + | «@id» + } + | Not to unlink : + foreach "$targets_not_to_unlink"{ + | «@id» + } +} diff -r 577118ebd179 -r c89fc366bebd svghmi/parse_labels.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/parse_labels.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,108 @@ +// parse_labels.ysl2 + + +// Parses: +// "HMI:WidgetType:param1:param2@path1,path1min,path1max@path2" +// +// Into: +// widget type="WidgetType" id="blah456" { +// arg value="param1"; +// arg value="param2"; +// path value=".path1" index=".path1" min="path1min" max="path1max" type="PAGE_LOCAL"; +// path value="/path1" index="348" type="HMI_INT"; +// path value="path4" index="path4" type="HMI_LOCAL"; +// } +// +const "pathregex",!"'^([^\[,]+)(\[[^\]]+\])?([\d,]*)$'"!; + +template "*", mode="parselabel" +{ + const "label","@inkscape:label"; + const "id","@id"; + + const "description", "substring-after($label,'HMI:')"; + + const "_args", "substring-before($description,'@')"; + const "args" choose { + when "$_args" value "$_args"; + otherwise value "$description"; + } + + const "_type", "substring-before($args,':')"; + const "type" choose { + when "$_type" value "$_type"; + otherwise value "$args"; + } + + if "$type" widget { + attrib "id" > «$id» + attrib "type" > «$type» + foreach "str:split(substring-after($args, ':'), ':')" { + arg { + attrib "value" > «.» + } + } + const "paths", "substring-after($description,'@')"; + foreach "str:split($paths, '@')" { + if "string-length(.) > 0" path { + // 1 : global match + // 2 : /path + // 3 : [accepts] + // 4 : min,max + const "path_match", "regexp:match(.,$pathregex)"; + const "pathminmax", "str:split($path_match[4],',')"; + const "path", "$path_match[2]"; + const "path_accepts", "$path_match[3]"; + const "pathminmaxcount", "count($pathminmax)"; + attrib "value" > «$path» + if "string-length($path_accepts)" + attrib "accepts" > «$path_accepts» + choose { + when "$pathminmaxcount = 2" { + attrib "min" > «$pathminmax[1]» + attrib "max" > «$pathminmax[2]» + } + when "$pathminmaxcount = 1 or $pathminmaxcount > 2" { + error > Widget id:«$id» label:«$label» has wrong syntax of path section «$pathminmax» + } + } + if "$indexed_hmitree" choose { + when "regexp:test($path,'^\.[a-zA-Z0-9_]+$')" { + attrib "type" > PAGE_LOCAL + } + when "regexp:test($path,'^[a-zA-Z0-9_]+$')" { + attrib "type" > HMI_LOCAL + } + otherwise { + const "item", "$indexed_hmitree/*[@hmipath = $path]"; + const "pathtype", "local-name($item)"; + if "$pathminmaxcount = 3 and not($pathtype = 'HMI_INT' or $pathtype = 'HMI_REAL')" { + error > Widget id:«$id» label:«$label» path section «$pathminmax» use min and max on non mumeric value + } + if "count($item) = 1" { + attrib "index" > «$item/@index» + attrib "type" > «$pathtype» + } + } + } + } + } + if "svg:desc" desc value "svg:desc/text()"; + } +} + + +// Templates to generate label back from parsed tree +template "arg", mode="genlabel" > :«@value» + +template "path", mode="genlabel" { + > @«@value» + if "string-length(@min)>0 or string-length(@max)>0" > ,«@min»,«@max» +} + +template "widget", mode="genlabel" { + > HMI:«@type» + apply "arg", mode="genlabel"; + apply "path", mode="genlabel"; +} + diff -r 577118ebd179 -r c89fc366bebd svghmi/pous.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/pous.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/svghmi.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/svghmi.c Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,409 @@ +#include +#include +#include "iec_types_all.h" +#include "POUS.h" +#include "config.h" +#include "beremiz.h" + +#define DEFAULT_REFRESH_PERIOD_MS 100 +#define HMI_BUFFER_SIZE %(buffer_size)d +#define HMI_ITEM_COUNT %(item_count)d +#define HMI_HASH_SIZE 8 +#define MAX_CONNECTIONS %(max_connections)d + +static uint8_t hmi_hash[HMI_HASH_SIZE] = {%(hmi_hash_ints)s}; + +/* PLC reads from that buffer */ +static char rbuf[HMI_BUFFER_SIZE]; + +/* PLC writes to that buffer */ +static char wbuf[HMI_BUFFER_SIZE]; + +/* TODO change that in case of multiclient... */ +/* worst biggest send buffer. FIXME : use dynamic alloc ? */ +static char sbuf[HMI_HASH_SIZE + HMI_BUFFER_SIZE + (HMI_ITEM_COUNT * sizeof(uint32_t))]; +static unsigned int sbufidx; + +%(extern_variables_declarations)s + +#define ticktime_ns %(PLC_ticktime)d +static uint16_t ticktime_ms = (ticktime_ns>1000000)? + ticktime_ns/1000000: + 1; + +typedef enum { + buf_free = 0, + buf_new, + buf_set, + buf_tosend +} buf_state_t; + +static int global_write_dirty = 0; + +typedef struct { + void *ptr; + __IEC_types_enum type; + uint32_t buf_index; + + /* publish/write/send */ + long wlock; + buf_state_t wstate[MAX_CONNECTIONS]; + + /* zero means not subscribed */ + uint16_t refresh_period_ms[MAX_CONNECTIONS]; + uint16_t age_ms[MAX_CONNECTIONS]; + + /* retrieve/read/recv */ + long rlock; + buf_state_t rstate; + +} hmi_tree_item_t; + +static hmi_tree_item_t hmi_tree_item[] = { +%(variable_decl_array)s +}; + +typedef int(*hmi_tree_iterator)(uint32_t, hmi_tree_item_t*); +static int traverse_hmi_tree(hmi_tree_iterator fp) +{ + unsigned int i; + for(i = 0; i < sizeof(hmi_tree_item)/sizeof(hmi_tree_item_t); i++){ + hmi_tree_item_t *dsc = &hmi_tree_item[i]; + int res = (*fp)(i, dsc); + if(res != 0){ + return res; + } + } + return 0; +} + +#define __Unpack_desc_type hmi_tree_item_t + +%(var_access_code)s + +static int write_iterator(uint32_t index, hmi_tree_item_t *dsc) +{ + uint32_t session_index = 0; + int value_changed = 0; + if(AtomicCompareExchange(&dsc->wlock, 0, 1) == 0) { + void *dest_p = &wbuf[dsc->buf_index]; + void *real_value_p = NULL; + char flags = 0; + void *visible_value_p = UnpackVar(dsc, &real_value_p, &flags); + USINT sz = __get_type_enum_size(dsc->type); + if(__Is_a_string(dsc)){ + sz = ((STRING*)visible_value_p)->len + 1; + } + while(session_index < MAX_CONNECTIONS) { + if(dsc->wstate[session_index] == buf_set){ + /* if being subscribed */ + if(dsc->refresh_period_ms[session_index]){ + if(dsc->age_ms[session_index] + ticktime_ms < dsc->refresh_period_ms[session_index]){ + dsc->age_ms[session_index] += ticktime_ms; + }else{ + dsc->wstate[session_index] = buf_tosend; + global_write_dirty = 1; + } + } + } + + if(dsc->wstate[session_index] == buf_new /* just subscribed + or already subscribed having value change */ + || (dsc->refresh_period_ms[session_index] > 0 + && (value_changed || (value_changed=memcmp(dest_p, visible_value_p, sz))) != 0)){ + /* if not already marked/signaled, do it */ + if(dsc->wstate[session_index] != buf_set && dsc->wstate[session_index] != buf_tosend) { + if(dsc->wstate[session_index] == buf_new || ticktime_ms > dsc->refresh_period_ms[session_index]){ + dsc->wstate[session_index] = buf_tosend; + global_write_dirty = 1; + } else { + dsc->wstate[session_index] = buf_set; + } + dsc->age_ms[session_index] = 0; + } + } + + session_index++; + } + /* copy value if changed (and subscribed) */ + if(value_changed) + memcpy(dest_p, visible_value_p, sz); + AtomicCompareExchange(&dsc->wlock, 1, 0); + } + // else ... : PLC can't wait, variable will be updated next turn + return 0; +} + +static uint32_t send_session_index; +static int send_iterator(uint32_t index, hmi_tree_item_t *dsc) +{ + while(AtomicCompareExchange(&dsc->wlock, 0, 1)) + nRT_reschedule(); + + if(dsc->wstate[send_session_index] == buf_tosend) + { + uint32_t sz = __get_type_enum_size(dsc->type); + if(sbufidx + sizeof(uint32_t) + sz <= sizeof(sbuf)) + { + void *src_p = &wbuf[dsc->buf_index]; + void *dst_p = &sbuf[sbufidx]; + if(__Is_a_string(dsc)){ + sz = ((STRING*)src_p)->len + 1; + } + /* TODO : force into little endian */ + memcpy(dst_p, &index, sizeof(uint32_t)); + memcpy(dst_p + sizeof(uint32_t), src_p, sz); + dsc->wstate[send_session_index] = buf_free; + sbufidx += sizeof(uint32_t) /* index */ + sz; + } + else + { + printf("BUG!!! %%d + %%ld + %%d > %%ld \n", sbufidx, sizeof(uint32_t), sz, sizeof(sbuf)); + AtomicCompareExchange(&dsc->wlock, 1, 0); + return EOVERFLOW; + } + } + + AtomicCompareExchange(&dsc->wlock, 1, 0); + return 0; +} + +static int read_iterator(uint32_t index, hmi_tree_item_t *dsc) +{ + if(AtomicCompareExchange(&dsc->rlock, 0, 1) == 0) + { + if(dsc->rstate == buf_set) + { + void *src_p = &rbuf[dsc->buf_index]; + void *real_value_p = NULL; + char flags = 0; + void *visible_value_p = UnpackVar(dsc, &real_value_p, &flags); + memcpy(real_value_p, src_p, __get_type_enum_size(dsc->type)); + dsc->rstate = buf_free; + } + AtomicCompareExchange(&dsc->rlock, 1, 0); + } + // else ... : PLC can't wait, variable will be updated next turn + return 0; +} + +void update_refresh_period(hmi_tree_item_t *dsc, uint32_t session_index, uint16_t refresh_period_ms) +{ + while(AtomicCompareExchange(&dsc->wlock, 0, 1)) + nRT_reschedule(); + + if(refresh_period_ms) { + if(!dsc->refresh_period_ms[session_index]) + { + dsc->wstate[session_index] = buf_new; + } + } else { + dsc->wstate[session_index] = buf_free; + } + dsc->refresh_period_ms[session_index] = refresh_period_ms; + AtomicCompareExchange(&dsc->wlock, 1, 0); +} + +static uint32_t reset_session_index; +static int reset_iterator(uint32_t index, hmi_tree_item_t *dsc) +{ + update_refresh_period(dsc, reset_session_index, 0); + return 0; +} + +static void *svghmi_handle; + +void SVGHMI_SuspendFromPythonThread(void) +{ + wait_RT_to_nRT_signal(svghmi_handle); +} + +void SVGHMI_WakeupFromRTThread(void) +{ + unblock_RT_to_nRT_signal(svghmi_handle); +} + +int svghmi_continue_collect; + +int __init_svghmi() +{ + memset(rbuf,0,sizeof(rbuf)); + memset(wbuf,0,sizeof(wbuf)); + + svghmi_continue_collect = 1; + + /* create svghmi_pipe */ + svghmi_handle = create_RT_to_nRT_signal("SVGHMI_pipe"); + + if(!svghmi_handle) + return 1; + + return 0; +} + +void __cleanup_svghmi() +{ + svghmi_continue_collect = 0; + SVGHMI_WakeupFromRTThread(); + delete_RT_to_nRT_signal(svghmi_handle); +} + +void __retrieve_svghmi() +{ + traverse_hmi_tree(read_iterator); +} + +void __publish_svghmi() +{ + global_write_dirty = 0; + traverse_hmi_tree(write_iterator); + if(global_write_dirty) { + SVGHMI_WakeupFromRTThread(); + } +} + +/* PYTHON CALLS */ +int svghmi_wait(void){ + + SVGHMI_SuspendFromPythonThread(); +} + +int svghmi_send_collect(uint32_t session_index, uint32_t *size, char **ptr){ + + if(svghmi_continue_collect) { + int res; + sbufidx = HMI_HASH_SIZE; + send_session_index = session_index; + if((res = traverse_hmi_tree(send_iterator)) == 0) + { + if(sbufidx > HMI_HASH_SIZE){ + memcpy(&sbuf[0], &hmi_hash[0], HMI_HASH_SIZE); + *ptr = &sbuf[0]; + *size = sbufidx; + return 0; + } + return ENODATA; + } + // printf("collected BAD result %%d\n", res); + return res; + } + else + { + return EINTR; + } +} + +typedef enum { + setval = 0, + reset = 1, + subscribe = 2 +} cmd_from_JS; + +int svghmi_reset(uint32_t session_index){ + reset_session_index = session_index; + traverse_hmi_tree(reset_iterator); + return 1; +} + +// Returns : +// 0 is OK, <0 is error, 1 is heartbeat +int svghmi_recv_dispatch(uint32_t session_index, uint32_t size, const uint8_t *ptr){ + const uint8_t* cursor = ptr + HMI_HASH_SIZE; + const uint8_t* end = ptr + size; + + int was_hearbeat = 0; + + /* match hmitree fingerprint */ + if(size <= HMI_HASH_SIZE || memcmp(ptr, hmi_hash, HMI_HASH_SIZE) != 0) + { + printf("svghmi_recv_dispatch MISMATCH !!\n"); + return -EINVAL; + } + + while(cursor < end) + { + uint32_t progress; + cmd_from_JS cmd = *(cursor++); + switch(cmd) + { + case setval: + { + uint32_t index = *(uint32_t*)(cursor); + uint8_t const *valptr = cursor + sizeof(uint32_t); + + if(index == heartbeat_index) + was_hearbeat = 1; + + if(index < HMI_ITEM_COUNT) + { + hmi_tree_item_t *dsc = &hmi_tree_item[index]; + void *real_value_p = NULL; + char flags = 0; + void *visible_value_p = UnpackVar(dsc, &real_value_p, &flags); + void *dst_p = &rbuf[dsc->buf_index]; + uint32_t sz = __get_type_enum_size(dsc->type); + + if(__Is_a_string(dsc)){ + sz = ((STRING*)valptr)->len + 1; + } + + if((valptr + sz) <= end) + { + // rescheduling spinlock until free + while(AtomicCompareExchange(&dsc->rlock, 0, 1)) + nRT_reschedule(); + + memcpy(dst_p, valptr, sz); + dsc->rstate = buf_set; + + AtomicCompareExchange(&dsc->rlock, 1, 0); + progress = sz + sizeof(uint32_t) /* index */; + } + else + { + return -EINVAL; + } + } + else + { + return -EINVAL; + } + } + break; + + case reset: + { + progress = 0; + reset_session_index = session_index; + traverse_hmi_tree(reset_iterator); + } + break; + + case subscribe: + { + uint32_t index = *(uint32_t*)(cursor); + uint16_t refresh_period_ms = *(uint32_t*)(cursor + sizeof(uint32_t)); + + if(index < HMI_ITEM_COUNT) + { + hmi_tree_item_t *dsc = &hmi_tree_item[index]; + update_refresh_period(dsc, session_index, refresh_period_ms); + } + else + { + return -EINVAL; + } + + progress = sizeof(uint32_t) /* index */ + + sizeof(uint16_t) /* refresh period */; + } + break; + default: + printf("svghmi_recv_dispatch unknown %%d\n",cmd); + + } + cursor += progress; + } + return was_hearbeat; +} + diff -r 577118ebd179 -r c89fc366bebd svghmi/svghmi.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/svghmi.js Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,536 @@ +// svghmi.js + +var need_cache_apply = []; + +function dispatch_value(index, value) { + let widgets = subscribers(index); + + let oldval = cache[index]; + cache[index] = value; + + if(widgets.size > 0) { + for(let widget of widgets){ + widget.new_hmi_value(index, value, oldval); + } + } +}; + +function init_widgets() { + Object.keys(hmi_widgets).forEach(function(id) { + let widget = hmi_widgets[id]; + let init = widget.init; + if(typeof(init) == "function"){ + try { + init.call(widget); + } catch(err) { + console.log(err); + } + } + }); +}; + +// Open WebSocket to relative "/ws" address + +var ws_url = + window.location.href.replace(/^http(s?:\/\/[^\/]*)\/.*$/, 'ws$1/ws') + + '?mode=' + (window.location.hash == "#watchdog" + ? "watchdog" + : "multiclient"); +var ws = new WebSocket(ws_url); +ws.binaryType = 'arraybuffer'; + +const dvgetters = { + INT: (dv,offset) => [dv.getInt16(offset, true), 2], + BOOL: (dv,offset) => [dv.getInt8(offset, true), 1], + NODE: (dv,offset) => [dv.getInt8(offset, true), 1], + REAL: (dv,offset) => [dv.getFloat32(offset, true), 4], + STRING: (dv, offset) => { + const size = dv.getInt8(offset); + return [ + String.fromCharCode.apply(null, new Uint8Array( + dv.buffer, /* original buffer */ + offset + 1, /* string starts after size*/ + size /* size of string */ + )), size + 1]; /* total increment */ + } +}; + +// Apply updates recieved through ws.onmessage to subscribed widgets +function apply_updates() { + updates.forEach((value, index) => { + dispatch_value(index, value); + }); + updates.clear(); +} + +// Called on requestAnimationFrame, modifies DOM +var requestAnimationFrameID = null; +function animate() { + // Do the page swith if any one pending + if(current_subscribed_page != current_visible_page){ + switch_visible_page(current_subscribed_page); + } + + while(widget = need_cache_apply.pop()){ + widget.apply_cache(); + } + + if(jumps_need_update) update_jumps(); + + apply_updates(); + + pending_widget_animates.forEach(widget => widget._animate()); + pending_widget_animates = []; + + requestAnimationFrameID = null; +} + +function requestHMIAnimation() { + if(requestAnimationFrameID == null){ + requestAnimationFrameID = window.requestAnimationFrame(animate); + } +} + +// Message reception handler +// Hash is verified and HMI values updates resulting from binary parsing +// are stored until browser can compute next frame, DOM is left untouched +ws.onmessage = function (evt) { + + let data = evt.data; + let dv = new DataView(data); + let i = 0; + try { + for(let hash_int of hmi_hash) { + if(hash_int != dv.getUint8(i)){ + throw new Error("Hash doesn't match"); + }; + i++; + }; + + while(i < data.byteLength){ + let index = dv.getUint32(i, true); + i += 4; + let iectype = hmitree_types[index]; + if(iectype != undefined){ + let dvgetter = dvgetters[iectype]; + let [value, bytesize] = dvgetter(dv,i); + updates.set(index, value); + i += bytesize; + } else { + throw new Error("Unknown index "+index); + } + }; + // register for rendering on next frame, since there are updates + requestHMIAnimation(); + } catch(err) { + // 1003 is for "Unsupported Data" + // ws.close(1003, err.message); + + // TODO : remove debug alert ? + alert("Error : "+err.message+"\\\\nHMI will be reloaded."); + + // force reload ignoring cache + location.reload(true); + } +}; + +hmi_hash_u8 = new Uint8Array(hmi_hash); + +function send_blob(data) { + if(data.length > 0) { + ws.send(new Blob([hmi_hash_u8].concat(data))); + }; +}; + +const typedarray_types = { + INT: (number) => new Int16Array([number]), + BOOL: (truth) => new Int16Array([truth]), + NODE: (truth) => new Int16Array([truth]), + REAL: (number) => new Float32Array([number]), + STRING: (str) => { + // beremiz default string max size is 128 + str = str.slice(0,128); + binary = new Uint8Array(str.length + 1); + binary[0] = str.length; + for(let i = 0; i < str.length; i++){ + binary[i+1] = str.charCodeAt(i); + } + return binary; + } + /* TODO */ +}; + +function send_reset() { + send_blob(new Uint8Array([1])); /* reset = 1 */ +}; + +var subscriptions = []; + +function subscribers(index) { + let entry = subscriptions[index]; + let res; + if(entry == undefined){ + res = new Set(); + subscriptions[index] = [res,0]; + }else{ + [res, _ign] = entry; + } + return res +} + +function get_subscription_period(index) { + let entry = subscriptions[index]; + if(entry == undefined) + return 0; + let [_ign, period] = entry; + return period; +} + +function set_subscription_period(index, period) { + let entry = subscriptions[index]; + if(entry == undefined){ + subscriptions[index] = [new Set(), period]; + } else { + entry[1] = period; + } +} + +// artificially subscribe the watchdog widget to "/heartbeat" hmi variable +// Since dispatch directly calls change_hmi_value, +// PLC will periodically send variable at given frequency +subscribers(heartbeat_index).add({ + /* type: "Watchdog", */ + frequency: 1, + indexes: [heartbeat_index], + new_hmi_value: function(index, value, oldval) { + apply_hmi_value(heartbeat_index, value+1); + } +}); + +function svg_text_to_multiline(elt) { + return(Array.prototype.map.call(elt.children, x=>x.textContent).join("\\\\n")); +} + +function multiline_to_svg_text(elt, str) { + str.split('\\\\n').map((line,i) => {elt.children[i].textContent = line;}); +} + +function switch_langnum(langnum) { + langnum = Math.max(0, Math.min(langs.length - 1, langnum)); + + for (let translation of translations) { + let [objs, msgs] = translation; + let msg = msgs[langnum]; + for (let obj of objs) { + multiline_to_svg_text(obj, msg); + obj.setAttribute("lang",langnum); + } + } + return langnum; +} + +// backup original texts +for (let translation of translations) { + let [objs, msgs] = translation; + msgs.unshift(svg_text_to_multiline(objs[0])); +} + +var lang_local_index = hmi_local_index("lang"); +var langcode_local_index = hmi_local_index("lang_code"); +var langname_local_index = hmi_local_index("lang_name"); +subscribers(lang_local_index).add({ + indexes: [lang_local_index], + new_hmi_value: function(index, value, oldval) { + let current_lang = switch_langnum(value); + let [langname,langcode] = langs[current_lang]; + apply_hmi_value(langcode_local_index, langcode); + apply_hmi_value(langname_local_index, langname); + switch_page(); + } +}); + +function setup_lang(){ + let current_lang = cache[lang_local_index]; + let new_lang = switch_langnum(current_lang); + if(current_lang != new_lang){ + apply_hmi_value(lang_local_index, new_lang); + } +} + +setup_lang(); + +function update_subscriptions() { + let delta = []; + for(let index in subscriptions){ + let widgets = subscribers(index); + + // periods are in ms + let previous_period = get_subscription_period(index); + + // subscribing with a zero period is unsubscribing + let new_period = 0; + if(widgets.size > 0) { + let maxfreq = 0; + for(let widget of widgets){ + let wf = widget.frequency; + if(wf != undefined && maxfreq < wf) + maxfreq = wf; + } + + if(maxfreq != 0) + new_period = 1000/maxfreq; + } + + if(previous_period != new_period) { + set_subscription_period(index, new_period); + if(index <= last_remote_index){ + delta.push( + new Uint8Array([2]), /* subscribe = 2 */ + new Uint32Array([index]), + new Uint16Array([new_period])); + } + } + } + send_blob(delta); +}; + +function send_hmi_value(index, value) { + if(index > last_remote_index){ + updates.set(index, value); + + if(persistent_indexes.has(index)){ + let varname = persistent_indexes.get(index); + document.cookie = varname+"="+value+"; max-age=3153600000"; + } + + requestHMIAnimation(); + return; + } + + let iectype = hmitree_types[index]; + let tobinary = typedarray_types[iectype]; + send_blob([ + new Uint8Array([0]), /* setval = 0 */ + new Uint32Array([index]), + tobinary(value)]); + + // DON'T DO THAT unless read_iterator in svghmi.c modifies wbuf as well, not only rbuf + // cache[index] = value; +}; + +function apply_hmi_value(index, new_val) { + let old_val = cache[index]; + if(new_val != undefined && old_val != new_val) + send_hmi_value(index, new_val); + return new_val; +} + +const quotes = {"'":null, '"':null}; + +function eval_operation_string(old_val, opstr) { + let op = opstr[0]; + let given_val; + if(opstr.length < 2) + return undefined; + if(opstr[1] in quotes){ + if(opstr.length < 3) + return undefined; + if(opstr[opstr.length-1] == opstr[1]){ + given_val = opstr.slice(2,opstr.length-1); + } + } else { + given_val = Number(opstr.slice(1)); + } + let new_val; + switch(op){ + case "=": + new_val = given_val; + break; + case "+": + new_val = old_val + given_val; + break; + case "-": + new_val = old_val - given_val; + break; + case "*": + new_val = old_val * given_val; + break; + case "/": + new_val = old_val / given_val; + break; + } + return new_val; +} + +var current_visible_page; +var current_subscribed_page; +var current_page_index; +var page_node_local_index = hmi_local_index("page_node"); + +function toggleFullscreen() { + let elem = document.documentElement; + + if (!document.fullscreenElement) { + elem.requestFullscreen().catch(err => { + console.log("Error attempting to enable full-screen mode: "+err.message+" ("+err.name+")"); + }); + } else { + document.exitFullscreen(); + } +} + +function prepare_svg() { + // prevents context menu from appearing on right click and long touch + document.body.addEventListener('contextmenu', e => { + toggleFullscreen(); + e.preventDefault(); + }); + + for(let eltid in detachable_elements){ + let [element,parent] = detachable_elements[eltid]; + parent.removeChild(element); + } +}; + +function switch_page(page_name, page_index) { + if(current_subscribed_page != current_visible_page){ + /* page switch already going */ + /* TODO LOG ERROR */ + return false; + } + + if(page_name == undefined) + page_name = current_subscribed_page; + + + let old_desc = page_desc[current_subscribed_page]; + let new_desc = page_desc[page_name]; + + if(new_desc == undefined){ + /* TODO LOG ERROR */ + return false; + } + + if(page_index == undefined){ + page_index = new_desc.page_index; + } + + if(old_desc){ + old_desc.widgets.map(([widget,relativeness])=>widget.unsub()); + } + const new_offset = page_index == undefined ? 0 : page_index - new_desc.page_index; + + const container_id = page_name + (page_index != undefined ? page_index : ""); + + new_desc.widgets.map(([widget,relativeness])=>widget.sub(new_offset,relativeness,container_id)); + + update_subscriptions(); + + current_subscribed_page = page_name; + current_page_index = page_index; + let page_node; + if(page_index != undefined){ + page_node = hmitree_paths[page_index]; + }else{ + page_node = ""; + } + apply_hmi_value(page_node_local_index, page_node); + + jumps_need_update = true; + + requestHMIAnimation(); + jump_history.push([page_name, page_index]); + if(jump_history.length > 42) + jump_history.shift(); + + return true; +}; + +function switch_visible_page(page_name) { + + let old_desc = page_desc[current_visible_page]; + let new_desc = page_desc[page_name]; + + if(old_desc){ + for(let eltid in old_desc.required_detachables){ + if(!(eltid in new_desc.required_detachables)){ + let [element, parent] = old_desc.required_detachables[eltid]; + parent.removeChild(element); + } + } + for(let eltid in new_desc.required_detachables){ + if(!(eltid in old_desc.required_detachables)){ + let [element, parent] = new_desc.required_detachables[eltid]; + parent.appendChild(element); + } + } + }else{ + for(let eltid in new_desc.required_detachables){ + let [element, parent] = new_desc.required_detachables[eltid]; + parent.appendChild(element); + } + } + + svg_root.setAttribute('viewBox',new_desc.bbox.join(" ")); + current_visible_page = page_name; +}; + +// Once connection established +ws.onopen = function (evt) { + init_widgets(); + send_reset(); + // show main page + prepare_svg(); + switch_page(default_page); +}; + +ws.onclose = function (evt) { + // TODO : add visible notification while waiting for reload + console.log("Connection closed. code:"+evt.code+" reason:"+evt.reason+" wasClean:"+evt.wasClean+" Reload in 10s."); + // TODO : re-enable auto reload when not in debug + //window.setTimeout(() => location.reload(true), 10000); + alert("Connection closed. code:"+evt.code+" reason:"+evt.reason+" wasClean:"+evt.wasClean+"."); + +}; + +const xmlns = "http://www.w3.org/2000/svg"; +var edit_callback; +const localtypes = {"PAGE_LOCAL":null, "HMI_LOCAL":null} +function edit_value(path, valuetype, callback, initial) { + if(valuetype in localtypes){ + valuetype = (typeof initial) == "number" ? "HMI_REAL" : "HMI_STRING"; + } + let [keypadid, xcoord, ycoord] = keypads[valuetype]; + edit_callback = callback; + let widget = hmi_widgets[keypadid]; + widget.start_edit(path, valuetype, callback, initial); +}; + +var current_modal; /* TODO stack ?*/ + +function show_modal() { + let [element, parent] = detachable_elements[this.element.id]; + + tmpgrp = document.createElementNS(xmlns,"g"); + tmpgrpattr = document.createAttribute("transform"); + let [xcoord,ycoord] = this.coordinates; + let [xdest,ydest] = page_desc[current_visible_page].bbox; + tmpgrpattr.value = "translate("+String(xdest-xcoord)+","+String(ydest-ycoord)+")"; + + tmpgrp.setAttributeNode(tmpgrpattr); + + tmpgrp.appendChild(element); + parent.appendChild(tmpgrp); + + current_modal = [this.element.id, tmpgrp]; +}; + +function end_modal() { + let [eltid, tmpgrp] = current_modal; + let [element, parent] = detachable_elements[this.element.id]; + + parent.removeChild(tmpgrp); + + current_modal = undefined; +}; + diff -r 577118ebd179 -r c89fc366bebd svghmi/svghmi.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/svghmi.py Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,825 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz +# Copyright (C) 2021: Edouard TISSERANT +# +# See COPYING file for copyrights details. + +from __future__ import absolute_import +import os +import shutil +import hashlib +import shlex +import time + +import wx + +from lxml import etree +from lxml.etree import XSLTApplyError + +import util.paths as paths +from POULibrary import POULibrary +from docutil import open_svg, get_inkscape_path + +from util.ProcessLogger import ProcessLogger +from runtime.typemapping import DebugTypesSize +import targets +from editors.ConfTreeNodeEditor import ConfTreeNodeEditor +from XSLTransform import XSLTransform +from svghmi.i18n import EtreeToMessages, SaveCatalog, ReadTranslations,\ + MatchTranslations, TranslationToEtree, open_pofile,\ + GetPoFiles +from svghmi.hmi_tree import HMI_TYPES, HMITreeNode, SPECIAL_NODES +from svghmi.ui import SVGHMI_UI +from svghmi.fonts import GetFontTypeAndFamilyName, GetCSSFontFaceFromFontFile + + +ScriptDirectory = paths.AbsDir(__file__) + + +# module scope for HMITree root +# so that CTN can use HMITree deduced in Library +# note: this only works because library's Generate_C is +# systematicaly invoked before CTN's CTNGenerate_C + +hmi_tree_root = None + +on_hmitree_update = None + +maxConnectionsTotal = 0 + +class SVGHMILibrary(POULibrary): + def GetLibraryPath(self): + return paths.AbsNeighbourFile(__file__, "pous.xml") + + def Generate_C(self, buildpath, varlist, IECCFLAGS): + global hmi_tree_root, on_hmitree_update, maxConnectionsTotal + + already_found_watchdog = False + found_SVGHMI_instance = False + for CTNChild in self.GetCTR().IterChildren(): + if isinstance(CTNChild, SVGHMI): + found_SVGHMI_instance = True + # collect maximum connection total for all svghmi nodes + maxConnectionsTotal += CTNChild.GetParamsAttributes("SVGHMI.MaxConnections")["value"] + + # spot watchdog abuse + if CTNChild.GetParamsAttributes("SVGHMI.EnableWatchdog")["value"]: + if already_found_watchdog: + self.FatalError("SVGHMI: Only one watchdog enabled HMI allowed") + already_found_watchdog = True + + if not found_SVGHMI_instance: + self.FatalError("SVGHMI : Library is selected but not used. Please either deselect it in project config or add a SVGHMI node to project.") + + + """ + PLC Instance Tree: + prog0 + +->v1 HMI_INT + +->v2 HMI_INT + +->fb0 (type mhoo) + | +->va HMI_NODE + | +->v3 HMI_INT + | +->v4 HMI_INT + | + +->fb1 (type mhoo) + | +->va HMI_NODE + | +->v3 HMI_INT + | +->v4 HMI_INT + | + +->fb2 + +->v5 HMI_IN + + HMI tree: + hmi0 + +->v1 + +->v2 + +->fb0 class:va + | +-> v3 + | +-> v4 + | + +->fb1 class:va + | +-> v3 + | +-> v4 + | + +->v5 + + """ + + # Filter known HMI types + hmi_types_instances = [v for v in varlist if v["derived"] in HMI_TYPES] + + hmi_tree_root = None + + # take first HMI_NODE (placed as special node), make it root + for i,v in enumerate(hmi_types_instances): + path = v["IEC_path"].split(".") + derived = v["derived"] + if derived == "HMI_NODE": + hmi_tree_root = HMITreeNode(path, "", derived, v["type"], v["vartype"], v["C_path"]) + hmi_types_instances.pop(i) + break + + # deduce HMI tree from PLC HMI_* instances + for v in hmi_types_instances: + path = v["IEC_path"].split(".") + # ignores variables starting with _TMP_ + if path[-1].startswith("_TMP_"): + continue + derived = v["derived"] + kwargs={} + if derived == "HMI_NODE": + # TODO : make problem if HMI_NODE used in CONFIG or RESOURCE + name = path[-2] + kwargs['hmiclass'] = path[-1] + else: + name = path[-1] + new_node = HMITreeNode(path, name, derived, v["type"], v["vartype"], v["C_path"], **kwargs) + placement_result = hmi_tree_root.place_node(new_node) + if placement_result is not None: + cause, problematic_node = placement_result + if cause == "Non_Unique": + message = _("HMI tree nodes paths are not unique.\nConflicting variable: {} {}").format( + ".".join(problematic_node.path), + ".".join(new_node.path)) + + last_FB = None + for v in varlist: + if v["vartype"] == "FB": + last_FB = v + if v["C_path"] == problematic_node: + break + if last_FB is not None: + failing_parent = last_FB["type"] + message += "\n" + message += _("Solution: Add HMI_NODE at beginning of {}").format(failing_parent) + + elif cause in ["Late_HMI_NODE", "Duplicate_HMI_NODE"]: + cause, problematic_node = placement_result + message = _("There must be only one occurrence of HMI_NODE before any HMI_* variable in POU.\nConflicting variable: {} {}").format( + ".".join(problematic_node.path), + ".".join(new_node.path)) + + self.FatalError("SVGHMI : " + message) + + if on_hmitree_update is not None: + on_hmitree_update(hmi_tree_root) + + variable_decl_array = [] + extern_variables_declarations = [] + buf_index = 0 + item_count = 0 + found_heartbeat = False + + hearbeat_IEC_path = ['CONFIG', 'HEARTBEAT'] + + for node in hmi_tree_root.traverse(): + if not found_heartbeat and node.path == hearbeat_IEC_path: + hmi_tree_hearbeat_index = item_count + found_heartbeat = True + extern_variables_declarations += [ + "#define heartbeat_index "+str(hmi_tree_hearbeat_index) + ] + if hasattr(node, "iectype"): + sz = DebugTypesSize.get(node.iectype, 0) + variable_decl_array += [ + "{&(" + node.cpath + "), " + node.iectype + { + "EXT": "_P_ENUM", + "IN": "_P_ENUM", + "MEM": "_O_ENUM", + "OUT": "_O_ENUM", + "VAR": "_ENUM" + }[node.vartype] + ", " + + str(buf_index) + ", 0, }"] + buf_index += sz + item_count += 1 + if len(node.path) == 1: + extern_variables_declarations += [ + "extern __IEC_" + node.iectype + "_" + + "t" if node.vartype is "VAR" else "p" + + node.cpath + ";"] + + assert(found_heartbeat) + + # TODO : filter only requiered external declarations + for v in varlist: + if v["C_path"].find('.') < 0: + extern_variables_declarations += [ + "extern %(type)s %(C_path)s;" % v] + + # TODO check if programs need to be declared separately + # "programs_declarations": "\n".join(["extern %(type)s %(C_path)s;" % + # p for p in self._ProgramList]), + + # C code to observe/access HMI tree variables + svghmi_c_filepath = paths.AbsNeighbourFile(__file__, "svghmi.c") + svghmi_c_file = open(svghmi_c_filepath, 'r') + svghmi_c_code = svghmi_c_file.read() + svghmi_c_file.close() + svghmi_c_code = svghmi_c_code % { + "variable_decl_array": ",\n".join(variable_decl_array), + "extern_variables_declarations": "\n".join(extern_variables_declarations), + "buffer_size": buf_index, + "item_count": item_count, + "var_access_code": targets.GetCode("var_access.c"), + "PLC_ticktime": self.GetCTR().GetTicktime(), + "hmi_hash_ints": ",".join(map(str,hmi_tree_root.hash())), + "max_connections": maxConnectionsTotal + } + + gen_svghmi_c_path = os.path.join(buildpath, "svghmi.c") + gen_svghmi_c = open(gen_svghmi_c_path, 'w') + gen_svghmi_c.write(svghmi_c_code) + gen_svghmi_c.close() + + # Python based WebSocket HMITree Server + svghmiserverfile = open(paths.AbsNeighbourFile(__file__, "svghmi_server.py"), 'r') + svghmiservercode = svghmiserverfile.read() + svghmiserverfile.close() + + runtimefile_path = os.path.join(buildpath, "runtime_00_svghmi.py") + runtimefile = open(runtimefile_path, 'w') + runtimefile.write(svghmiservercode) + runtimefile.close() + + # Backup HMI Tree in XML form so that it can be loaded without building + hmitree_backup_path = os.path.join(buildpath, "hmitree.xml") + hmitree_backup_file = open(hmitree_backup_path, 'wb') + hmitree_backup_file.write(etree.tostring(hmi_tree_root.etree())) + hmitree_backup_file.close() + + return ((["svghmi"], [(gen_svghmi_c_path, IECCFLAGS)], True), "", + ("runtime_00_svghmi.py", open(runtimefile_path, "rb"))) + # ^ + # note the double zero after "runtime_", + # to ensure placement before other CTN generated code in execution order + + def GlobalInstances(self): + """ Adds HMI tree root and hearbeat to PLC Configuration's globals """ + return [(name, iec_type, "") for name, iec_type in SPECIAL_NODES] + + + +def Register_SVGHMI_UI_for_HMI_tree_updates(ref): + global on_hmitree_update + def HMITreeUpdate(_hmi_tree_root): + obj = ref() + if obj is not None: + obj.HMITreeUpdate(_hmi_tree_root) + + on_hmitree_update = HMITreeUpdate + + +class SVGHMIEditor(ConfTreeNodeEditor): + CONFNODEEDITOR_TABS = [ + (_("HMI Tree"), "CreateSVGHMI_UI")] + + def CreateSVGHMI_UI(self, parent): + global hmi_tree_root + + if hmi_tree_root is None: + buildpath = self.Controler.GetCTRoot()._getBuildPath() + hmitree_backup_path = os.path.join(buildpath, "hmitree.xml") + if os.path.exists(hmitree_backup_path): + hmitree_backup_file = open(hmitree_backup_path, 'rb') + hmi_tree_root = HMITreeNode.from_etree(etree.parse(hmitree_backup_file).getroot()) + + ret = SVGHMI_UI(parent, Register_SVGHMI_UI_for_HMI_tree_updates) + + on_hmitree_update(hmi_tree_root) + + return ret + +class SVGHMI(object): + XSD = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + EditorType = SVGHMIEditor + + ConfNodeMethods = [ + { + "bitmap": "ImportSVG", + "name": _("Import SVG"), + "tooltip": _("Import SVG"), + "method": "_ImportSVG" + }, + { + "bitmap": "EditSVG", + "name": _("Inkscape"), + "tooltip": _("Edit HMI"), + "method": "_StartInkscape" + }, + { + "bitmap": "OpenPOT", + "name": _("New lang"), + "tooltip": _("Open non translated message catalog (POT) to start new language"), + "method": "_OpenPOT" + }, + { + "bitmap": "EditPO", + "name": _("Edit lang"), + "tooltip": _("Edit existing message catalog (PO) for specific language"), + "method": "_EditPO" + }, + { + "bitmap": "AddFont", + "name": _("Add Font"), + "tooltip": _("Add TTF, OTF or WOFF font to be embedded in HMI"), + "method": "_AddFont" + }, + { + "bitmap": "DelFont", + "name": _("Delete Font"), + "tooltip": _("Remove font previously added to HMI"), + "method": "_DelFont" + }, + ] + + def _getSVGpath(self, project_path=None): + if project_path is None: + project_path = self.CTNPath() + return os.path.join(project_path, "svghmi.svg") + + def _getPOTpath(self, project_path=None): + if project_path is None: + project_path = self.CTNPath() + return os.path.join(project_path, "messages.pot") + + def OnCTNSave(self, from_project_path=None): + if from_project_path is not None: + shutil.copyfile(self._getSVGpath(from_project_path), + self._getSVGpath()) + shutil.copyfile(self._getPOTpath(from_project_path), + self._getPOTpath()) + # XXX TODO copy .PO files + return True + + def GetSVGGeometry(self): + self.ProgressStart("inkscape", "collecting SVG geometry (Inkscape)") + # invoke inskscape -S, csv-parse output, produce elements + InkscapeGeomColumns = ["Id", "x", "y", "w", "h"] + + inkpath = get_inkscape_path() + + if inkpath is None: + self.FatalError("SVGHMI: inkscape is not installed.") + + svgpath = self._getSVGpath() + status, result, _err_result = ProcessLogger(self.GetCTRoot().logger, + '"' + inkpath + '" -S "' + svgpath + '"', + no_stdout=True, + no_stderr=True).spin() + if status != 0: + self.FatalError("SVGHMI: inkscape couldn't extract geometry from given SVG.") + + res = [] + for line in result.split(): + strippedline = line.strip() + attrs = dict( + zip(InkscapeGeomColumns, line.strip().split(','))) + + res.append(etree.Element("bbox", **attrs)) + + self.ProgressEnd("inkscape") + return res + + def GetHMITree(self): + global hmi_tree_root + self.ProgressStart("hmitree", "getting HMI tree") + res = [hmi_tree_root.etree(add_hash=True)] + self.ProgressEnd("hmitree") + return res + + def GetTranslations(self, _context, msgs): + self.ProgressStart("i18n", "getting Translations") + messages = EtreeToMessages(msgs) + + if len(messages) == 0: + self.ProgressEnd("i18n") + return + + SaveCatalog(self._getPOTpath(), messages) + + translations = ReadTranslations(self.CTNPath()) + + langs,translated_messages = MatchTranslations(translations, messages, + errcallback=self.GetCTRoot().logger.write_warning) + + ret = TranslationToEtree(langs,translated_messages) + + self.ProgressEnd("i18n") + + return ret + + def GetFontsFiles(self): + project_path = self.CTNPath() + fontdir = os.path.join(project_path, "fonts") + if os.path.isdir(fontdir): + return [os.path.join(fontdir,f) for f in sorted(os.listdir(fontdir))] + return [] + + def GetFonts(self, _context): + css_parts = [] + + for fontfile in self.GetFontsFiles(): + if os.path.isfile(fontfile): + css_parts.append(GetCSSFontFaceFromFontFile(fontfile)) + + return "".join(css_parts) + + times_msgs = {} + indent = 1 + def ProgressStart(self, k, m): + self.times_msgs[k] = (time.time(), m) + self.GetCTRoot().logger.write(" "*self.indent + "Start %s...\n"%m) + self.indent = self.indent + 1 + + def ProgressEnd(self, k): + t = time.time() + oldt, m = self.times_msgs[k] + self.indent = self.indent - 1 + self.GetCTRoot().logger.write(" "*self.indent + "... finished in %.3fs\n"%(t - oldt)) + + def get_SVGHMI_options(self): + name = self.BaseParams.getName() + port = self.GetParamsAttributes("SVGHMI.Port")["value"] + interface = self.GetParamsAttributes("SVGHMI.Interface")["value"] + path = self.GetParamsAttributes("SVGHMI.Path")["value"].format(name=name) + if path and path[0]=='/': + path = path[1:] + enable_watchdog = self.GetParamsAttributes("SVGHMI.EnableWatchdog")["value"] + url="http://"+interface+("" if port==80 else (":"+str(port)) + ) + (("/"+path) if path else "" + ) + ("#watchdog" if enable_watchdog else "") + + return dict( + name=name, + port=port, + interface=interface, + path=path, + enable_watchdog=enable_watchdog, + url=url) + + def CTNGenerate_C(self, buildpath, locations): + global hmi_tree_root + + if hmi_tree_root is None: + self.FatalError("SVGHMI : Library is not selected. Please select it in project config.") + + location_str = "_".join(map(str, self.GetCurrentLocation())) + svghmi_options = self.get_SVGHMI_options() + + svgfile = self._getSVGpath() + + res = ([], "", False) + + target_fname = "svghmi_"+location_str+".xhtml" + + build_path = self._getBuildPath() + target_path = os.path.join(build_path, target_fname) + hash_path = os.path.join(build_path, "svghmi.md5") + + self.GetCTRoot().logger.write("SVGHMI:\n") + + if os.path.exists(svgfile): + + hasher = hashlib.md5() + hmi_tree_root._hash(hasher) + pofiles = GetPoFiles(self.CTNPath()) + filestocheck = [svgfile] + \ + (list(zip(*pofiles)[1]) if pofiles else []) + \ + self.GetFontsFiles() + + for filetocheck in filestocheck: + with open(filetocheck, 'rb') as afile: + while True: + buf = afile.read(65536) + if len(buf) > 0: + hasher.update(buf) + else: + break + digest = hasher.hexdigest() + + if os.path.exists(hash_path): + with open(hash_path, 'rb') as digest_file: + last_digest = digest_file.read() + else: + last_digest = None + + if digest != last_digest: + + transform = XSLTransform(os.path.join(ScriptDirectory, "gen_index_xhtml.xslt"), + [("GetSVGGeometry", lambda *_ignored:self.GetSVGGeometry()), + ("GetHMITree", lambda *_ignored:self.GetHMITree()), + ("GetTranslations", self.GetTranslations), + ("GetFonts", self.GetFonts), + ("ProgressStart", lambda _ign,k,m:self.ProgressStart(str(k),str(m))), + ("ProgressEnd", lambda _ign,k:self.ProgressEnd(str(k)))]) + + self.ProgressStart("svg", "source SVG parsing") + + # load svg as a DOM with Etree + svgdom = etree.parse(svgfile) + + self.ProgressEnd("svg") + + # call xslt transform on Inkscape's SVG to generate XHTML + try: + self.ProgressStart("xslt", "XSLT transform") + result = transform.transform(svgdom) # , profile_run=True) + self.ProgressEnd("xslt") + except XSLTApplyError as e: + self.FatalError("SVGHMI " + svghmi_options["name"] + ": " + e.message) + finally: + for entry in transform.get_error_log(): + message = "SVGHMI: "+ entry.message + "\n" + self.GetCTRoot().logger.write_warning(message) + + target_file = open(target_path, 'wb') + result.write(target_file, encoding="utf-8") + target_file.close() + + # print(str(result)) + # print(transform.xslt.error_log) + # print(etree.tostring(result.xslt_profile,pretty_print=True)) + + with open(hash_path, 'wb') as digest_file: + digest_file.write(digest) + else: + self.GetCTRoot().logger.write(" No changes - XSLT transformation skipped\n") + + else: + target_file = open(target_path, 'wb') + target_file.write(""" + + +SVGHMI + + +

No SVG file provided

+ + +""") + target_file.close() + + res += ((target_fname, open(target_path, "rb")),) + + svghmi_cmds = {} + for thing in ["Start", "Stop", "Watchdog"]: + given_command = self.GetParamsAttributes("SVGHMI.On"+thing)["value"] + svghmi_cmds[thing] = ( + "Popen(" + + repr(shlex.split(given_command.format(**svghmi_options))) + + ")") if given_command else "pass # no command given" + + runtimefile_path = os.path.join(buildpath, "runtime_%s_svghmi_.py" % location_str) + runtimefile = open(runtimefile_path, 'w') + runtimefile.write(""" +# TODO : multiple watchdog (one for each svghmi instance) +def svghmi_{location}_watchdog_trigger(): + {svghmi_cmds[Watchdog]} + +max_svghmi_sessions = {maxConnections_total} + +def _runtime_{location}_svghmi_start(): + global svghmi_watchdog, svghmi_servers + + srv = svghmi_servers.get("{interface}:{port}", None) + if srv is not None: + svghmi_root, svghmi_listener, path_list = srv + if '{path}' in path_list: + raise Exception("SVGHMI {name}: path {path} already used on {interface}:{port}") + else: + svghmi_root = Resource() + factory = HMIWebSocketServerFactory() + factory.setProtocolOptions(maxConnections={maxConnections}) + + svghmi_root.putChild("ws", WebSocketResource(factory)) + + svghmi_listener = reactor.listenTCP({port}, Site(svghmi_root), interface='{interface}') + path_list = [] + svghmi_servers["{interface}:{port}"] = (svghmi_root, svghmi_listener, path_list) + + svghmi_root.putChild( + '{path}', + NoCacheFile('{xhtml}', + defaultType='application/xhtml+xml')) + + path_list.append("{path}") + + {svghmi_cmds[Start]} + + if {enable_watchdog}: + if svghmi_watchdog is None: + svghmi_watchdog = Watchdog( + {watchdog_initial}, + {watchdog_interval}, + svghmi_{location}_watchdog_trigger) + else: + raise Exception("SVGHMI {name}: only one watchdog allowed") + + +def _runtime_{location}_svghmi_stop(): + global svghmi_watchdog, svghmi_servers + + if svghmi_watchdog is not None: + svghmi_watchdog.cancel() + svghmi_watchdog = None + + svghmi_root, svghmi_listener, path_list = svghmi_servers["{interface}:{port}"] + svghmi_root.delEntity('{path}') + + path_list.remove('{path}') + + if len(path_list)==0: + svghmi_root.delEntity("ws") + svghmi_listener.stopListening() + svghmi_servers.pop("{interface}:{port}") + + {svghmi_cmds[Stop]} + + """.format(location=location_str, + xhtml=target_fname, + svghmi_cmds=svghmi_cmds, + watchdog_initial = self.GetParamsAttributes("SVGHMI.WatchdogInitial")["value"], + watchdog_interval = self.GetParamsAttributes("SVGHMI.WatchdogInterval")["value"], + maxConnections = self.GetParamsAttributes("SVGHMI.MaxConnections")["value"], + maxConnections_total = maxConnectionsTotal, + **svghmi_options + )) + + runtimefile.close() + + res += (("runtime_%s_svghmi.py" % location_str, open(runtimefile_path, "rb")),) + + return res + + def _ImportSVG(self): + dialog = wx.FileDialog(self.GetCTRoot().AppFrame, _("Choose a SVG file"), os.getcwd(), "", _("SVG files (*.svg)|*.svg|All files|*.*"), wx.OPEN) + if dialog.ShowModal() == wx.ID_OK: + svgpath = dialog.GetPath() + if os.path.isfile(svgpath): + shutil.copy(svgpath, self._getSVGpath()) + else: + self.GetCTRoot().logger.write_error(_("No such SVG file: %s\n") % svgpath) + dialog.Destroy() + + def getDefaultSVG(self): + return os.path.join(ScriptDirectory, "default.svg") + + def _StartInkscape(self): + svgfile = self._getSVGpath() + open_inkscape = True + if not self.GetCTRoot().CheckProjectPathPerm(): + dialog = wx.MessageDialog(self.GetCTRoot().AppFrame, + _("You don't have write permissions.\nOpen Inkscape anyway ?"), + _("Open Inkscape"), + wx.YES_NO | wx.ICON_QUESTION) + open_inkscape = dialog.ShowModal() == wx.ID_YES + dialog.Destroy() + if open_inkscape: + if not os.path.isfile(svgfile): + # make a copy of default svg from source + default = self.getDefaultSVG() + shutil.copyfile(default, svgfile) + open_svg(svgfile) + + def _StartPOEdit(self, POFile): + open_poedit = True + if not self.GetCTRoot().CheckProjectPathPerm(): + dialog = wx.MessageDialog(self.GetCTRoot().AppFrame, + _("You don't have write permissions.\nOpen POEdit anyway ?"), + _("Open POEdit"), + wx.YES_NO | wx.ICON_QUESTION) + open_poedit = dialog.ShowModal() == wx.ID_YES + dialog.Destroy() + if open_poedit: + open_pofile(POFile) + + def _EditPO(self): + """ Select a specific translation and edit it with POEdit """ + project_path = self.CTNPath() + dialog = wx.FileDialog(self.GetCTRoot().AppFrame, _("Choose a PO file"), project_path, "", _("PO files (*.po)|*.po"), wx.OPEN) + if dialog.ShowModal() == wx.ID_OK: + POFile = dialog.GetPath() + if os.path.isfile(POFile): + if os.path.relpath(POFile, project_path) == os.path.basename(POFile): + self._StartPOEdit(POFile) + else: + self.GetCTRoot().logger.write_error(_("PO file misplaced: %s is not in %s\n") % (POFile,project_path)) + else: + self.GetCTRoot().logger.write_error(_("PO file does not exist: %s\n") % POFile) + dialog.Destroy() + + def _OpenPOT(self): + """ Start POEdit with untouched empty catalog """ + POFile = self._getPOTpath() + if os.path.isfile(POFile): + self._StartPOEdit(POFile) + else: + self.GetCTRoot().logger.write_error(_("POT file does not exist, add translatable text (label starting with '_') in Inkscape first\n")) + + def _AddFont(self): + dialog = wx.FileDialog( + self.GetCTRoot().AppFrame, + _("Choose a font"), + os.path.expanduser("~"), + "", + _("Font files (*.ttf;*.otf;*.woff;*.woff2)|*.ttf;*.otf;*.woff;*.woff2"), wx.OPEN) + + if dialog.ShowModal() == wx.ID_OK: + fontfile = dialog.GetPath() + if os.path.isfile(fontfile): + familyname, uniquename, formatname, mimetype = GetFontTypeAndFamilyName(fontfile) + else: + self.GetCTRoot().logger.write_error( + _('Selected font %s is not a readable file\n')%fontfile) + return + if familyname is None or uniquename is None or formatname is None or mimetype is None: + self.GetCTRoot().logger.write_error( + _('Selected font file %s is invalid or incompatible\n')%fontfile) + return + + project_path = self.CTNPath() + + fontfname = uniquename + "." + mimetype.split('/')[1] + fontdir = os.path.join(project_path, "fonts") + newfontfile = os.path.join(fontdir, fontfname) + + if not os.path.exists(fontdir): + os.mkdir(fontdir) + + shutil.copyfile(fontfile, newfontfile) + + self.GetCTRoot().logger.write( + _('Added font %s as %s\n')%(fontfile,newfontfile)) + + def _DelFont(self): + project_path = self.CTNPath() + fontdir = os.path.join(project_path, "fonts") + dialog = wx.FileDialog( + self.GetCTRoot().AppFrame, + _("Choose a font to remove"), + fontdir, + "", + _("Font files (*.ttf;*.otf;*.woff;*.woff2)|*.ttf;*.otf;*.woff;*.woff2"), wx.OPEN) + if dialog.ShowModal() == wx.ID_OK: + fontfile = dialog.GetPath() + if os.path.isfile(fontfile): + if os.path.relpath(fontfile, fontdir) == os.path.basename(fontfile): + os.remove(fontfile) + self.GetCTRoot().logger.write( + _('Removed font %s\n')%fontfile) + else: + self.GetCTRoot().logger.write_error( + _("Font to remove %s is not in %s\n") % (fontfile,fontdir)) + else: + self.GetCTRoot().logger.write_error( + _("Font file does not exist: %s\n") % fontfile) + + ## In case one day we support more than one heartbeat + # def CTNGlobalInstances(self): + # view_name = self.BaseParams.getName() + # return [(view_name + "_HEARTBEAT", "HMI_INT", "")] + + def GetIconName(self): + return "SVGHMI" diff -r 577118ebd179 -r c89fc366bebd svghmi/svghmi_server.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/svghmi_server.py Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,298 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz +# Copyright (C) 2019: Edouard TISSERANT +# See COPYING file for copyrights details. + +from __future__ import absolute_import +import errno +from threading import RLock, Timer + +try: + from runtime.spawn_subprocess import Popen +except ImportError: + from subprocess import Popen + +from twisted.web.server import Site +from twisted.web.resource import Resource +from twisted.internet import reactor +from twisted.web.static import File + +from autobahn.twisted.websocket import WebSocketServerFactory, WebSocketServerProtocol +from autobahn.websocket.protocol import WebSocketProtocol +from autobahn.twisted.resource import WebSocketResource + +max_svghmi_sessions = None +svghmi_watchdog = None + + +svghmi_wait = PLCBinary.svghmi_wait +svghmi_wait.restype = ctypes.c_int # error or 0 +svghmi_wait.argtypes = [] + +svghmi_continue_collect = ctypes.c_int.in_dll(PLCBinary, "svghmi_continue_collect") + +svghmi_send_collect = PLCBinary.svghmi_send_collect +svghmi_send_collect.restype = ctypes.c_int # error or 0 +svghmi_send_collect.argtypes = [ + ctypes.c_uint32, # index + ctypes.POINTER(ctypes.c_uint32), # size + ctypes.POINTER(ctypes.c_void_p)] # data ptr + +svghmi_reset = PLCBinary.svghmi_reset +svghmi_reset.restype = ctypes.c_int # error or 0 +svghmi_reset.argtypes = [ + ctypes.c_uint32] # index + +svghmi_recv_dispatch = PLCBinary.svghmi_recv_dispatch +svghmi_recv_dispatch.restype = ctypes.c_int # error or 0 +svghmi_recv_dispatch.argtypes = [ + ctypes.c_uint32, # index + ctypes.c_uint32, # size + ctypes.c_char_p] # data ptr + +class HMISessionMgr(object): + def __init__(self): + self.multiclient_sessions = set() + self.watchdog_session = None + self.session_count = 0 + self.lock = RLock() + self.indexes = set() + + def next_index(self): + if self.indexes: + greatest = max(self.indexes) + holes = set(range(greatest)) - self.indexes + index = min(holes) if holes else greatest+1 + else: + index = 0 + self.indexes.add(index) + return index + + def free_index(self, index): + self.indexes.remove(index) + + def register(self, session): + global max_svghmi_sessions + with self.lock: + if session.is_watchdog_session: + # Creating a new watchdog session closes pre-existing one + if self.watchdog_session is not None: + self.unregister(self.watchdog_session) + else: + assert(self.session_count < max_svghmi_sessions) + self.session_count += 1 + + self.watchdog_session = session + else: + assert(self.session_count < max_svghmi_sessions) + self.multiclient_sessions.add(session) + self.session_count += 1 + session.session_index = self.next_index() + + def unregister(self, session): + with self.lock: + if session.is_watchdog_session : + if self.watchdog_session != session: + return + self.watchdog_session = None + else: + try: + self.multiclient_sessions.remove(session) + except KeyError: + return + self.free_index(session.session_index) + self.session_count -= 1 + session.kill() + + def close_all(self): + for session in self.iter_sessions(): + self.unregister(session) + + def iter_sessions(self): + with self.lock: + lst = list(self.multiclient_sessions) + if self.watchdog_session is not None: + lst = [self.watchdog_session]+lst + for nxt_session in lst: + yield nxt_session + + +svghmi_session_manager = HMISessionMgr() + + +class HMISession(object): + def __init__(self, protocol_instance): + self.protocol_instance = protocol_instance + self._session_index = None + self.closed = False + + @property + def is_watchdog_session(self): + return self.protocol_instance.has_watchdog + + @property + def session_index(self): + return self._session_index + + @session_index.setter + def session_index(self, value): + self._session_index = value + + def reset(self): + return svghmi_reset(self.session_index) + + def close(self): + if self.closed: return + self.protocol_instance.sendClose(WebSocketProtocol.CLOSE_STATUS_CODE_NORMAL) + + def notify_closed(self): + self.closed = True + + def kill(self): + self.close() + self.reset() + + def onMessage(self, msg): + # pass message to the C side recieve_message() + if self.closed: return + return svghmi_recv_dispatch(self.session_index, len(msg), msg) + + def sendMessage(self, msg): + if self.closed: return + self.protocol_instance.sendMessage(msg, True) + return 0 + +class Watchdog(object): + def __init__(self, initial_timeout, interval, callback): + self._callback = callback + self.lock = RLock() + self.initial_timeout = initial_timeout + self.interval = interval + self.callback = callback + with self.lock: + self._start() + + def _start(self, rearm=False): + duration = self.interval if rearm else self.initial_timeout + if duration: + self.timer = Timer(duration, self.trigger) + self.timer.start() + else: + self.timer = None + + def _stop(self): + if self.timer is not None: + self.timer.cancel() + self.timer = None + + def cancel(self): + with self.lock: + self._stop() + + def feed(self, rearm=True): + with self.lock: + self._stop() + self._start(rearm) + + def trigger(self): + self._callback() + # Don't repeat trigger periodically + # # wait for initial timeout on re-start + # self.feed(rearm=False) + +class HMIProtocol(WebSocketServerProtocol): + + def __init__(self, *args, **kwargs): + self._hmi_session = None + self.has_watchdog = False + WebSocketServerProtocol.__init__(self, *args, **kwargs) + + def onConnect(self, request): + self.has_watchdog = request.params.get("mode", [None])[0] == "watchdog" + return WebSocketServerProtocol.onConnect(self, request) + + def onOpen(self): + global svghmi_session_manager + assert(self._hmi_session is None) + _hmi_session = HMISession(self) + registered = svghmi_session_manager.register(_hmi_session) + self._hmi_session = _hmi_session + + def onClose(self, wasClean, code, reason): + global svghmi_session_manager + if self._hmi_session is None : return + self._hmi_session.notify_closed() + svghmi_session_manager.unregister(self._hmi_session) + self._hmi_session = None + + def onMessage(self, msg, isBinary): + global svghmi_watchdog + if self._hmi_session is None : return + result = self._hmi_session.onMessage(msg) + if result == 1 and self.has_watchdog: # was heartbeat + if svghmi_watchdog is not None: + svghmi_watchdog.feed() + +class HMIWebSocketServerFactory(WebSocketServerFactory): + protocol = HMIProtocol + +svghmi_servers = {} +svghmi_send_thread = None + +def SendThreadProc(): + global svghmi_session_manager + size = ctypes.c_uint32() + ptr = ctypes.c_void_p() + res = 0 + while svghmi_continue_collect: + svghmi_wait() + for svghmi_session in svghmi_session_manager.iter_sessions(): + res = svghmi_send_collect( + svghmi_session.session_index, + ctypes.byref(size), ctypes.byref(ptr)) + if res == 0: + svghmi_session.sendMessage( + ctypes.string_at(ptr.value,size.value)) + elif res == errno.ENODATA: + # this happens when there is no data after wakeup + # because of hmi data refresh period longer than + # PLC common ticktime + pass + else: + # this happens when finishing + break + +def AddPathToSVGHMIServers(path, factory, *args, **kwargs): + for k,v in svghmi_servers.iteritems(): + svghmi_root, svghmi_listener, path_list = v + svghmi_root.putChild(path, factory(*args, **kwargs)) + +# Called by PLCObject at start +def _runtime_00_svghmi_start(): + global svghmi_send_thread + + # start a thread that call the C part of SVGHMI + svghmi_send_thread = Thread(target=SendThreadProc, name="SVGHMI Send") + svghmi_send_thread.start() + + +# Called by PLCObject at stop +def _runtime_00_svghmi_stop(): + global svghmi_send_thread, svghmi_session + + svghmi_session_manager.close_all() + + # plc cleanup calls svghmi_(locstring)_cleanup and unlocks send thread + svghmi_send_thread.join() + svghmi_send_thread = None + + +class NoCacheFile(File): + def render_GET(self, request): + request.setHeader(b"Cache-Control", b"no-cache, no-store") + return File.render_GET(self, request) + render_HEAD = render_GET + + diff -r 577118ebd179 -r c89fc366bebd svghmi/ui.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/ui.py Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,700 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz +# Copyright (C) 2021: Edouard TISSERANT +# +# See COPYING file for copyrights details. + +from __future__ import absolute_import +import os +import hashlib +import weakref +import re +from threading import Thread, Lock +from functools import reduce +from itertools import izip +from operator import or_ +from tempfile import NamedTemporaryFile + +import wx +from wx.lib.scrolledpanel import ScrolledPanel + +from lxml import etree +from lxml.etree import XSLTApplyError +from XSLTransform import XSLTransform + +import util.paths as paths +from IDEFrame import EncodeFileSystemPath, DecodeFileSystemPath +from docutil import get_inkscape_path + +from util.ProcessLogger import ProcessLogger + +ScriptDirectory = paths.AbsDir(__file__) + +HMITreeDndMagicWord = "text/beremiz-hmitree" + +class HMITreeSelector(wx.TreeCtrl): + def __init__(self, parent): + + wx.TreeCtrl.__init__(self, parent, style=( + wx.TR_MULTIPLE | + wx.TR_HAS_BUTTONS | + wx.SUNKEN_BORDER | + wx.TR_LINES_AT_ROOT)) + + self.ordered_items = [] + self.parent = parent + + self.MakeTree() + + self.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnTreeNodeSelection) + self.Bind(wx.EVT_TREE_BEGIN_DRAG, self.OnTreeBeginDrag) + + def _recurseTree(self, current_hmitree_root, current_tc_root): + for c in current_hmitree_root.children: + if hasattr(c, "children"): + display_name = ('{} (class={})'.format(c.name, c.hmiclass)) \ + if c.hmiclass is not None else c.name + tc_child = self.AppendItem(current_tc_root, display_name) + self.SetPyData(tc_child, c) + + self._recurseTree(c,tc_child) + else: + display_name = '{} {}'.format(c.nodetype[4:], c.name) + tc_child = self.AppendItem(current_tc_root, display_name) + self.SetPyData(tc_child, c) + + def OnTreeNodeSelection(self, event): + items = self.GetSelections() + items_pydata = [self.GetPyData(item) for item in items] + + # append new items to ordered item list + for item_pydata in items_pydata: + if item_pydata not in self.ordered_items: + self.ordered_items.append(item_pydata) + + # filter out vanished items + self.ordered_items = [ + item_pydata + for item_pydata in self.ordered_items + if item_pydata in items_pydata] + + self.parent.OnHMITreeNodeSelection(self.ordered_items) + + def OnTreeBeginDrag(self, event): + """ + Called when a drag is started in tree + @param event: wx.TreeEvent + """ + if self.ordered_items: + # Just send a recognizable mime-type, drop destination + # will get python data from parent + data = wx.CustomDataObject(HMITreeDndMagicWord) + dragSource = wx.DropSource(self) + dragSource.SetData(data) + dragSource.DoDragDrop() + + def MakeTree(self, hmi_tree_root=None): + + self.Freeze() + + self.root = None + self.DeleteAllItems() + + root_display_name = _("Please build to see HMI Tree") \ + if hmi_tree_root is None else "HMI" + self.root = self.AddRoot(root_display_name) + self.SetPyData(self.root, hmi_tree_root) + + if hmi_tree_root is not None: + self._recurseTree(hmi_tree_root, self.root) + self.Expand(self.root) + + self.Thaw() + +class WidgetPicker(wx.TreeCtrl): + def __init__(self, parent, initialdir=None): + wx.TreeCtrl.__init__(self, parent, style=( + wx.TR_MULTIPLE | + wx.TR_HAS_BUTTONS | + wx.SUNKEN_BORDER | + wx.TR_LINES_AT_ROOT)) + + self.MakeTree(initialdir) + + def _recurseTree(self, current_dir, current_tc_root, dirlist): + """ + recurse through subdirectories, but creates tree nodes + only when (sub)directory conbtains .svg file + """ + res = [] + for f in sorted(os.listdir(current_dir)): + p = os.path.join(current_dir,f) + if os.path.isdir(p): + + r = self._recurseTree(p, current_tc_root, dirlist + [f]) + if len(r) > 0 : + res = r + dirlist = [] + current_tc_root = res.pop() + + elif os.path.splitext(f)[1].upper() == ".SVG": + if len(dirlist) > 0 : + res = [] + for d in dirlist: + current_tc_root = self.AppendItem(current_tc_root, d) + res.append(current_tc_root) + self.SetPyData(current_tc_root, None) + dirlist = [] + res.pop() + tc_child = self.AppendItem(current_tc_root, f) + self.SetPyData(tc_child, p) + return res + + def MakeTree(self, lib_dir = None): + + self.Freeze() + + self.root = None + self.DeleteAllItems() + + root_display_name = _("Please select widget library directory") \ + if lib_dir is None else os.path.basename(lib_dir) + self.root = self.AddRoot(root_display_name) + self.SetPyData(self.root, None) + + if lib_dir is not None and os.path.exists(lib_dir): + self._recurseTree(lib_dir, self.root, []) + self.Expand(self.root) + + self.Thaw() + +class PathDropTarget(wx.DropTarget): + + def __init__(self, parent): + data = wx.CustomDataObject(HMITreeDndMagicWord) + wx.DropTarget.__init__(self, data) + self.ParentWindow = parent + + def OnDrop(self, x, y): + self.ParentWindow.OnHMITreeDnD() + return True + +class ParamEditor(wx.Panel): + def __init__(self, parent, paramdesc): + wx.Panel.__init__(self, parent.main_panel) + label = paramdesc.get("name")+ ": " + paramdesc.get("accepts") + if paramdesc.text: + label += "\n\"" + paramdesc.text + "\"" + self.desc = wx.StaticText(self, label=label) + self.valid_bmp = wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK, wx.ART_TOOLBAR, (16,16)) + self.invalid_bmp = wx.ArtProvider.GetBitmap(wx.ART_CROSS_MARK, wx.ART_TOOLBAR, (16,16)) + self.validity_sbmp = wx.StaticBitmap(self, -1, self.invalid_bmp) + self.edit = wx.TextCtrl(self) + self.edit_sizer = wx.FlexGridSizer(cols=2, hgap=0, rows=1, vgap=0) + self.edit_sizer.AddGrowableCol(0) + self.edit_sizer.AddGrowableRow(0) + self.edit_sizer.Add(self.edit, flag=wx.GROW) + self.edit_sizer.Add(self.validity_sbmp, flag=wx.GROW) + self.main_sizer = wx.BoxSizer(wx.VERTICAL) + self.main_sizer.Add(self.desc, flag=wx.GROW) + self.main_sizer.Add(self.edit_sizer, flag=wx.GROW) + self.SetSizer(self.main_sizer) + self.main_sizer.Fit(self) + + def GetValue(self): + return self.edit.GetValue() + + def setValidity(self, validity): + if validity is not None: + bmp = self.valid_bmp if validity else self.invalid_bmp + self.validity_sbmp.SetBitmap(bmp) + self.validity_sbmp.Show(True) + else : + self.validity_sbmp.Show(False) + +models = { typename: re.compile(regex) for typename, regex in [ + ("string", r".*"), + ("int", r"^-?([1-9][0-9]*|0)$"), + ("real", r"^-?([1-9][0-9]*|0)(\.[0-9]+)?$")]} + +class ArgEditor(ParamEditor): + def __init__(self, parent, argdesc, prefillargdesc): + ParamEditor.__init__(self, parent, argdesc) + self.ParentObj = parent + self.argdesc = argdesc + self.Bind(wx.EVT_TEXT, self.OnArgChanged, self.edit) + prefill = "" if prefillargdesc is None else prefillargdesc.get("value") + self.edit.SetValue(prefill) + # TODO add a button to add more ArgEditror instance + # when ordinality is multiple + + def OnArgChanged(self, event): + txt = self.edit.GetValue() + accepts = self.argdesc.get("accepts").split(',') + self.setValidity( + reduce(or_, + map(lambda typename: + models[typename].match(txt) is not None, + accepts), + False) + if accepts and txt else None) + self.ParentObj.RegenSVGLater() + event.Skip() + +class PathEditor(ParamEditor): + def __init__(self, parent, pathdesc): + ParamEditor.__init__(self, parent, pathdesc) + self.ParentObj = parent + self.pathdesc = pathdesc + DropTarget = PathDropTarget(self) + self.edit.SetDropTarget(DropTarget) + self.edit.SetHint(_("Drag'n'drop HMI variable here")) + self.Bind(wx.EVT_TEXT, self.OnPathChanged, self.edit) + + def OnHMITreeDnD(self): + self.ParentObj.GotPathDnDOn(self) + + def SetPath(self, hmitree_node): + self.edit.ChangeValue(hmitree_node.hmi_path()) + self.setValidity( + hmitree_node.nodetype in self.pathdesc.get("accepts").split(",")) + + def OnPathChanged(self, event): + # TODO : find corresponding hmitre node and type to update validity + # Lazy way : hide validity + self.setValidity(None) + self.ParentObj.RegenSVGLater() + event.Skip() + +def KeepDoubleNewLines(txt): + return "\n\n".join(map( + lambda s:re.sub(r'\s+',' ',s), + txt.split("\n\n"))) + +_conf_key = "SVGHMIWidgetLib" +_preview_height = 200 +_preview_margin = 5 +class WidgetLibBrowser(wx.SplitterWindow): + def __init__(self, parent, id=wx.ID_ANY, pos=wx.DefaultPosition, + size=wx.DefaultSize): + + wx.SplitterWindow.__init__(self, parent, + style=wx.SUNKEN_BORDER | wx.SP_3D) + + self.bmp = None + self.msg = None + self.hmitree_nodes = [] + self.selected_SVG = None + + self.Config = wx.ConfigBase.Get() + self.libdir = self.RecallLibDir() + if self.libdir is None: + self.libdir = os.path.join(ScriptDirectory, "widgetlib") + + self.picker_desc_splitter = wx.SplitterWindow(self, style=wx.SUNKEN_BORDER | wx.SP_3D) + + self.picker_panel = wx.Panel(self.picker_desc_splitter) + self.picker_sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=2, vgap=0) + self.picker_sizer.AddGrowableCol(0) + self.picker_sizer.AddGrowableRow(1) + + self.widgetpicker = WidgetPicker(self.picker_panel, self.libdir) + self.libbutton = wx.Button(self.picker_panel, -1, _("Select SVG widget library")) + + self.picker_sizer.Add(self.libbutton, flag=wx.GROW) + self.picker_sizer.Add(self.widgetpicker, flag=wx.GROW) + self.picker_sizer.Layout() + self.picker_panel.SetAutoLayout(True) + self.picker_panel.SetSizer(self.picker_sizer) + + self.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnWidgetSelection, self.widgetpicker) + self.Bind(wx.EVT_BUTTON, self.OnSelectLibDir, self.libbutton) + + + + self.main_panel = ScrolledPanel(parent=self, + name='MiscellaneousPanel', + style=wx.TAB_TRAVERSAL) + + self.main_sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=3, vgap=0) + self.main_sizer.AddGrowableCol(0) + self.main_sizer.AddGrowableRow(2) + + self.staticmsg = wx.StaticText(self, label = _("Drag selected Widget from here to Inkscape")) + self.preview = wx.Panel(self.main_panel, size=(-1, _preview_height + _preview_margin*2)) + self.signature_sizer = wx.BoxSizer(wx.VERTICAL) + self.args_box = wx.StaticBox(self.main_panel, -1, + _("Widget's arguments"), + style = wx.ALIGN_CENTRE_HORIZONTAL) + self.args_sizer = wx.StaticBoxSizer(self.args_box, wx.VERTICAL) + self.paths_box = wx.StaticBox(self.main_panel, -1, + _("Widget's variables"), + style = wx.ALIGN_CENTRE_HORIZONTAL) + self.paths_sizer = wx.StaticBoxSizer(self.paths_box, wx.VERTICAL) + self.signature_sizer.Add(self.args_sizer, flag=wx.GROW) + self.signature_sizer.AddSpacer(5) + self.signature_sizer.Add(self.paths_sizer, flag=wx.GROW) + self.main_sizer.Add(self.staticmsg, flag=wx.GROW) + self.main_sizer.Add(self.preview, flag=wx.GROW) + self.main_sizer.Add(self.signature_sizer, flag=wx.GROW) + self.main_sizer.Layout() + self.main_panel.SetAutoLayout(True) + self.main_panel.SetSizer(self.main_sizer) + self.main_sizer.Fit(self.main_panel) + self.preview.Bind(wx.EVT_PAINT, self.OnPaint) + self.preview.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown) + + self.desc = wx.TextCtrl(self.picker_desc_splitter, size=wx.Size(-1, 160), + style=wx.TE_READONLY | wx.TE_MULTILINE) + + self.picker_desc_splitter.SplitHorizontally(self.picker_panel, self.desc, 400) + self.SplitVertically(self.main_panel, self.picker_desc_splitter, 300) + + self.tempf = None + + self.RegenSVGThread = None + self.RegenSVGLock = Lock() + self.RegenSVGTimer = wx.Timer(self, -1) + self.RegenSVGParams = None + self.Bind(wx.EVT_TIMER, + self.RegenSVG, + self.RegenSVGTimer) + + self.args_editors = [] + self.paths_editors = [] + + def SetMessage(self, msg): + self.staticmsg.SetLabel(msg) + self.main_sizer.Layout() + + def ResetSignature(self): + self.args_sizer.Clear() + for editor in self.args_editors: + editor.Destroy() + self.args_editors = [] + + self.paths_sizer.Clear() + for editor in self.paths_editors: + editor.Destroy() + self.paths_editors = [] + + def AddArgToSignature(self, arg, prefillarg): + new_editor = ArgEditor(self, arg, prefillarg) + self.args_editors.append(new_editor) + self.args_sizer.Add(new_editor, flag=wx.GROW) + + def AddPathToSignature(self, path): + new_editor = PathEditor(self, path) + self.paths_editors.append(new_editor) + self.paths_sizer.Add(new_editor, flag=wx.GROW) + + def GotPathDnDOn(self, target_editor): + dndindex = self.paths_editors.index(target_editor) + + for hmitree_node,editor in zip(self.hmitree_nodes, + self.paths_editors[dndindex:]): + editor.SetPath(hmitree_node) + + self.RegenSVGNow() + + def RecallLibDir(self): + conf = self.Config.Read(_conf_key) + if len(conf) == 0: + return None + else: + return DecodeFileSystemPath(conf) + + def RememberLibDir(self, path): + self.Config.Write(_conf_key, + EncodeFileSystemPath(path)) + self.Config.Flush() + + def DrawPreview(self): + """ + Refresh preview panel + """ + # Init preview panel paint device context + dc = wx.PaintDC(self.preview) + dc.Clear() + + if self.bmp: + # Get Preview panel size + sz = self.preview.GetClientSize() + w = self.bmp.GetWidth() + dc.DrawBitmap(self.bmp, (sz.width - w)/2, _preview_margin) + + + + def OnSelectLibDir(self, event): + defaultpath = self.RecallLibDir() + if defaultpath == None: + defaultpath = os.path.expanduser("~") + + dialog = wx.DirDialog(self, _("Choose a widget library"), defaultpath, + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + + if dialog.ShowModal() == wx.ID_OK: + self.libdir = dialog.GetPath() + self.RememberLibDir(self.libdir) + self.widgetpicker.MakeTree(self.libdir) + + dialog.Destroy() + + def OnPaint(self, event): + """ + Called when Preview panel needs to be redrawn + @param event: wx.PaintEvent + """ + self.DrawPreview() + event.Skip() + + def GenThumbnail(self, svgpath, thumbpath): + inkpath = get_inkscape_path() + if inkpath is None: + self.msg = _("Inkscape is not installed.") + return False + # TODO: spawn a thread, to decouple thumbnail gen + status, result, _err_result = ProcessLogger( + None, + '"' + inkpath + '" "' + svgpath + '" -e "' + thumbpath + + '" -D -h ' + str(_preview_height)).spin() + if status != 0: + self.msg = _("Inkscape couldn't generate thumbnail.") + return False + return True + + def OnWidgetSelection(self, event): + """ + Called when tree item is selected + @param event: wx.TreeEvent + """ + item_pydata = self.widgetpicker.GetPyData(event.GetItem()) + if item_pydata is not None: + svgpath = item_pydata + dname = os.path.dirname(svgpath) + fname = os.path.basename(svgpath) + hasher = hashlib.new('md5') + with open(svgpath, 'rb') as afile: + while True: + buf = afile.read(65536) + if len(buf) > 0: + hasher.update(buf) + else: + break + digest = hasher.hexdigest() + thumbfname = os.path.splitext(fname)[0]+"_"+digest+".png" + thumbdir = os.path.join(dname, ".svghmithumbs") + thumbpath = os.path.join(thumbdir, thumbfname) + + have_thumb = os.path.exists(thumbpath) + + try: + if not have_thumb: + if not os.path.exists(thumbdir): + os.mkdir(thumbdir) + have_thumb = self.GenThumbnail(svgpath, thumbpath) + + self.bmp = wx.Bitmap(thumbpath) if have_thumb else None + + self.selected_SVG = svgpath if have_thumb else None + + self.AnalyseWidgetAndUpdateUI(fname) + + self.SetMessage(self.msg) + + except IOError: + self.msg = _("Widget library must be writable") + + self.Refresh() + event.Skip() + + def OnHMITreeNodeSelection(self, hmitree_nodes): + self.hmitree_nodes = hmitree_nodes + + def OnLeftDown(self, evt): + if self.tempf is not None: + filename = self.tempf.name + data = wx.FileDataObject() + data.AddFile(filename) + dropSource = wx.DropSource(self) + dropSource.SetData(data) + dropSource.DoDragDrop(wx.Drag_AllowMove) + + def RegenSVGLater(self, when=1): + self.SetMessage(_("SVG generation pending")) + self.RegenSVGTimer.Start(milliseconds=when*1000, oneShot=True) + + def RegenSVGNow(self): + self.RegenSVGLater(when=0) + + def RegenSVG(self, event): + self.SetMessage(_("Generating SVG...")) + args = [arged.GetValue() for arged in self.args_editors] + while args and not args[-1]: args.pop(-1) + paths = [pathed.GetValue() for pathed in self.paths_editors] + while paths and not paths[-1]: paths.pop(-1) + if self.RegenSVGLock.acquire(True): + self.RegenSVGParams = (args, paths) + if self.RegenSVGThread is None: + self.RegenSVGThread = \ + Thread(target=self.RegenSVGProc, + name="RegenSVGThread").start() + self.RegenSVGLock.release() + event.Skip() + + def RegenSVGProc(self): + self.RegenSVGLock.acquire(True) + + newparams = self.RegenSVGParams + self.RegenSVGParams = None + + while newparams is not None: + self.RegenSVGLock.release() + + res = self.GenDnDSVG(newparams) + + self.RegenSVGLock.acquire(True) + + newparams = self.RegenSVGParams + self.RegenSVGParams = None + + self.RegenSVGThread = None + + self.RegenSVGLock.release() + + wx.CallAfter(self.DoneRegenSVG) + + def DoneRegenSVG(self): + self.SetMessage(self.msg if self.msg else _("SVG ready for drag'n'drop")) + + def AnalyseWidgetAndUpdateUI(self, fname): + self.msg = "" + self.ResetSignature() + + try: + if self.selected_SVG is None: + raise Exception(_("No widget selected")) + + transform = XSLTransform( + os.path.join(ScriptDirectory, "analyse_widget.xslt"),[]) + + svgdom = etree.parse(self.selected_SVG) + + signature = transform.transform(svgdom) + + for entry in transform.get_error_log(): + self.msg += "XSLT: " + entry.message + "\n" + + except Exception as e: + self.msg += str(e) + return + except XSLTApplyError as e: + self.msg += "Widget " + fname + " analysis error: " + e.message + return + + self.msg += "Widget " + fname + ": OK" + + widgets = signature.getroot() + widget = widgets.find("widget") + defs = widget.find("defs") + # Keep double newlines (to mark paragraphs) + widget_desc = widget.find("desc") + self.desc.SetValue( + fname + ":\n\n" + ( + _("No description given") if widget_desc is None else + KeepDoubleNewLines(widget_desc.text) + ) + "\n\n" + + defs.find("type").text + " Widget: "+defs.find("shortdesc").text+"\n\n" + + KeepDoubleNewLines(defs.find("longdesc").text)) + prefillargs = widget.findall("arg") + args = defs.findall("arg") + # extend args description in prefilled args in longer + # (case of variable list of args) + if len(prefillargs) < len(args): + prefillargs += [None]*(len(args)-len(prefillargs)) + if args and len(prefillargs) > len(args): + # TODO: check ordinality of last arg + # TODO: check that only last arg has multiple ordinality + args += [args[-1]]*(len(prefillargs)-len(args)) + self.args_box.Show(len(args)!=0) + for arg, prefillarg in izip(args,prefillargs): + self.AddArgToSignature(arg, prefillarg) + paths = defs.findall("path") + self.paths_box.Show(len(paths)!=0) + for path in paths: + self.AddPathToSignature(path) + + for widget in widgets: + widget_type = widget.get("type") + for path in widget.iterchildren("path"): + path_value = path.get("value") + path_accepts = map( + str.strip, path.get("accepts", '')[1:-1].split(',')) + + self.main_panel.SetupScrolling(scroll_x=False) + + def GetWidgetParams(self, _context): + args,paths = self.GenDnDSVGParams + root = etree.Element("params") + for arg in args: + etree.SubElement(root, "arg", value=arg) + for path in paths: + etree.SubElement(root, "path", value=path) + return root + + + def GenDnDSVG(self, newparams): + self.msg = "" + + self.GenDnDSVGParams = newparams + + if self.tempf is not None: + os.unlink(self.tempf.name) + self.tempf = None + + try: + if self.selected_SVG is None: + raise Exception(_("No widget selected")) + + transform = XSLTransform( + os.path.join(ScriptDirectory, "gen_dnd_widget_svg.xslt"), + [("GetWidgetParams", self.GetWidgetParams)]) + + svgdom = etree.parse(self.selected_SVG) + + result = transform.transform(svgdom) + + for entry in transform.get_error_log(): + self.msg += "XSLT: " + entry.message + "\n" + + self.tempf = NamedTemporaryFile(suffix='.svg', delete=False) + result.write(self.tempf, encoding="utf-8") + self.tempf.close() + + except Exception as e: + self.msg += str(e) + except XSLTApplyError as e: + self.msg += "Widget transform error: " + e.message + + def __del__(self): + if self.tempf is not None: + os.unlink(self.tempf.name) + +class SVGHMI_UI(wx.SplitterWindow): + + def __init__(self, parent, register_for_HMI_tree_updates): + wx.SplitterWindow.__init__(self, parent, + style=wx.SUNKEN_BORDER | wx.SP_3D) + + self.SelectionTree = HMITreeSelector(self) + self.Staging = WidgetLibBrowser(self) + self.SplitVertically(self.SelectionTree, self.Staging, 300) + register_for_HMI_tree_updates(weakref.ref(self)) + + def HMITreeUpdate(self, hmi_tree_root): + self.SelectionTree.MakeTree(hmi_tree_root) + + def OnHMITreeNodeSelection(self, hmitree_nodes): + self.Staging.OnHMITreeNodeSelection(hmitree_nodes) diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_animate.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_animate.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,46 @@ +// widget_animate.ysl2 + +widget_class("Animate") { + || + frequency = 5; + speed = 0; + start = false; + widget_center = undefined; + + dispatch(value) { + this.speed = value / 5; + + //reconfigure animation + this.request_animate(); + } + + animate(){ + // change animation properties + for(let child of this.element.children){ + if(child.nodeName.startsWith("animate")){ + if(this.speed != 0 && !this.start){ + this.start = true; + this.element.beginElement(); + } + + if(this.speed > 0){ + child.setAttribute("dur", this.speed+"s"); + } + else if(this.speed < 0){ + child.setAttribute("dur", (-1)*this.speed+"s"); + } + else{ + this.start = false; + this.element.endElement(); + } + } + } + } + + init() { + let widget_pos = this.element.getBBox(); + this.widget_center = [(widget_pos.x+widget_pos.width/2), (widget_pos.y+widget_pos.height/2)]; + } + || +} + diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_animaterotation.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_animaterotation.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,58 @@ +// widget_animaterotation.ysl2 + +widget_desc("AnimateRotation") { + longdesc + || + AnimateRotation - DEPRECATED, do not use. + Doesn't follow WYSIWYG principle, and forces user to add animateTransform tag in SVG (using inkscape XML editor for exemple) + || + + shortdesc > AnimateRotation - DEPRECATED + + path name="speed" accepts="HMI_INT,HMI_REAL" > speed + +} + +widget_class("AnimateRotation") { + || + frequency = 5; + speed = 0; + widget_center = undefined; + + dispatch(value) { + this.speed = value / 5; + + //reconfigure animation + this.request_animate(); + } + + animate(){ + // change animation properties + // TODO : rewrite with proper es6 + for(let child of this.element.children){ + if(child.nodeName == "animateTransform"){ + if(this.speed > 0){ + child.setAttribute("dur", this.speed+"s"); + child.setAttribute("from", "0 "+this.widget_center[0]+" "+this.widget_center[1]); + child.setAttribute("to", "360 "+this.widget_center[0]+" "+this.widget_center[1]); + } + else if(this.speed < 0){ + child.setAttribute("dur", (-1)*this.speed+"s"); + child.setAttribute("from", "360 "+this.widget_center[0]+" "+this.widget_center[1]); + child.setAttribute("to", "0 "+this.widget_center[0]+" "+this.widget_center[1]); + } + else{ + child.setAttribute("from", "0 "+this.widget_center[0]+" "+this.widget_center[1]); + child.setAttribute("to", "0 "+this.widget_center[0]+" "+this.widget_center[1]); + } + } + } + } + + init() { + let widget_pos = this.element.getBBox(); + this.widget_center = [(widget_pos.x+widget_pos.width/2), (widget_pos.y+widget_pos.height/2)]; + } + || +} + diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_back.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_back.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,25 @@ +// widget_back.ysl2 + +widget_desc("Back") { + longdesc + || + Back widget brings focus back to previous page in history when clicked. + || + + shortdesc > Jump to previous page +} + +// TODO: use es6 +widget_class("Back") + || + on_click(evt) { + if(jump_history.length > 1){ + jump_history.pop(); + let [page_name, index] = jump_history.pop(); + switch_page(page_name, index); + } + } + init() { + this.element.setAttribute("onclick", "hmi_widgets['"+this.element_id+"'].on_click(evt)"); + } + || diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_button.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_button.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,172 @@ +// widget_button.ysl2 + +widget_desc("Button") { + longdesc + || + Button widget takes one boolean variable path, and reflect current true + or false value by showing "active" or "inactive" labeled element + respectively. Pressing and releasing button changes variable to true and + false respectively. Potential inconsistency caused by quick consecutive + presses on the button is mitigated by using a state machine that wait for + previous state change to be reflected on variable before applying next one. + || + + shortdesc > Push button reflecting consistently given boolean variable + + path name="value" accepts="HMI_BOOL" > Boolean variable + +} + +// Finite state machine +decl fsm(name); +decl state(name); +decl on_mouse(position); +decl on_dispatch(value); +decl jump(state); +decl show(eltname); +decl hmi_value(value); + +gen_index_xhtml { + +// State machine to drive HMI_BOOL on a potentially laggy connection +const "_button_fsm" fsm { + state "init" { + on_dispatch "false" jump "released"; + on_dispatch "true" jump "pressed"; + } + + state "pressing" { + // show "waitactive"; + hmi_value "true"; + on_dispatch "true" jump "pressed"; + on_mouse "up" jump "shortpress"; + } + state "pressed" { + show "active"; + on_mouse "up" jump "releasing"; + on_dispatch "false" jump "released"; + } + state "shortpress" { + on_dispatch "true" jump "releasing"; + on_mouse "down" jump "pressing"; + } + + state "releasing" { + // show "waitinactive"; + hmi_value "false"; + on_dispatch "false" jump "released"; + on_mouse "down" jump "shortrelease"; + } + state "released" { + show "inactive"; + on_mouse "down" jump "pressing"; + on_dispatch "true" jump "pressed"; + } + state "shortrelease" { + on_dispatch "false" jump "pressing"; + on_mouse "up" jump "releasing"; + } +} + +template "fsm", mode="dispatch_transition" { + | switch (this.state) { + apply "state", mode="dispatch_transition"; + | } +} +template "state", mode="dispatch_transition" { + | case "«@name»": + apply "on-dispatch"; + | break; +} +template "on-dispatch" { + | if(value == «@value») { + apply "jump", mode="transition"; + | } +} + +template "fsm", mode="mouse_transition" { + param "position"; + | switch (this.state) { + apply "state", mode="mouse_transition" with "position", "$position"; + | } +} +template "state", mode="mouse_transition" { + param "position"; + | case "«@name»": + apply "on-mouse[@position = $position]"; + | break; +} +template "on-mouse" { + // up or down state is already assumed because apply statement filters it + apply "jump", mode="transition"; +} + +template "jump", mode="transition" { + | this.state = "«@state»"; + | this.«@state»_action(); +} + +template "fsm", mode="actions" { + apply "state", mode="actions"; +} +template "state", mode="actions" { + | «@name»_action(){ + //| console.log("Entering state «@name»"); + apply "*", mode="actions"; + | } +} +template "show", mode="actions" { + | this.display = "«@eltname»"; + | this.request_animate(); +} +template "hmi-value", mode="actions" { + | this.apply_hmi_value(0, «@value»); +} + +} + +widget_class("Button"){ + const "fsm","exsl:node-set($_button_fsm)"; + | frequency = 5; + + | display = "inactive"; + | state = "init"; + + | dispatch(value) { + // | console.log("dispatch"+value); + apply "$fsm", mode="dispatch_transition"; + | } + + | onmouseup(evt) { + | svg_root.removeEventListener("pointerup", this.bound_onmouseup, true); + // | console.log("onmouseup"); + apply "$fsm", mode="mouse_transition" with "position", "'up'"; + | } + | onmousedown(evt) { + | svg_root.addEventListener("pointerup", this.bound_onmouseup, true); + // | console.log("onmousedown"); + apply "$fsm", mode="mouse_transition" with "position", "'down'"; + | } + + apply "$fsm", mode="actions"; + + | animate(){ + | if (this.active_elt && this.inactive_elt) { + foreach "str:split('active inactive')" { + | if(this.display == "«.»") + | this.«.»_elt.style.display = ""; + | else + | this.«.»_elt.style.display = "none"; + } + | } + | } + + | init() { + | this.bound_onmouseup = this.onmouseup.bind(this); + | this.element.addEventListener("pointerdown", this.onmousedown.bind(this)); + | } +} + +widget_defs("Button") { + optional_labels("active inactive"); +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_circularbar.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_circularbar.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,78 @@ +// widget_circularbar.ysl2 + +widget_desc("CircularBar") { + longdesc + || + CircularBar widget changes the end angle of a "path" labeled arc according + to value of the single accepted variable. + + If "min" a "max" labeled texts are provided, then they are used as + respective minimum and maximum value. Otherwise, value is expected to be + in between 0 and 100. + + If "value" labeled text is found, then its content is replaced by value. + || + + shortdesc > Change end angle of Inkscape's arc + + // TODO: add min/max arguments + // TODO: add printf-like format + + path name="value" accepts="HMI_INT,HMI_REAL" > Value to display + +} +widget_class("CircularBar") { + || + frequency = 10; + range = undefined; + + dispatch(value) { + this.display_val = value; + this.request_animate(); + } + + animate(){ + if(this.value_elt) + this.value_elt.textContent = String(this.display_val); + let [min,max,start,end] = this.range; + let [cx,cy] = this.center; + let [rx,ry] = this.proportions; + let tip = start + (end-start)*Number(this.display_val)/(max-min); + let size = 0; + + if (tip-start > Math.PI) + size = 1; + else + size = 0; + + this.path_elt.setAttribute('d', "M "+(cx+rx*Math.cos(start))+","+(cy+ry*Math.sin(start))+ + " A "+rx+","+ry+ + " 0 "+size+ + " 1 "+(cx+rx*Math.cos(tip))+","+(cy+ry*Math.sin(tip))); + } + + init() { + let [start, end, cx, cy, rx, ry] = ["start", "end", "cx", "cy", "rx", "ry"]. + map(tag=>Number(this.path_elt.getAttribute('sodipodi:'+tag))) + + if (ry == 0) + ry = rx; + + if (start > end) + end = end + 2*Math.PI; + + let [min,max] = [[this.min_elt,0],[this.max_elt,100]].map(([elt,def],i)=>elt? + Number(elt.textContent) : + this.args.length >= i+1 ? this.args[i] : def); + + this.range = [min, max, start, end]; + this.center = [cx, cy]; + this.proportions = [rx, ry]; + } + || +} + +widget_defs("CircularBar") { + labels("path"); + optional_labels("value min max"); +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_circularslider.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_circularslider.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,264 @@ +// widget_circuralslider.ysl2 + +widget_desc("CircularSlider") { + longdesc + || + CircularSlider - DEPRECATED, to be replaced by PathSlider + This widget moves "handle" labeled group along "range" labeled + arc, according to value of the single accepted variable. + + If "min" a "max" labeled texts are provided, or if first and second + argument are given, then they are used as respective minimum and maximum + value. Otherwise, value is expected to be in between 0 and 100. + + If "value" labeled text is found, then its content is replaced by value. + During drag, "setpoint" labeled group is moved to position defined by user + while "handle" reflects current value from variable. + || + + shortdesc > CircularSlider - DEPRECATED + + arg name="min" count="optional" accepts="int,real" > minimum value + + arg name="min" count="optional" accepts="int,real" > maximum value + + // TODO: add printf-like format + + path name="value" accepts="HMI_INT,HMI_REAL" > Value to display + +} + +widget_class("CircularSlider") + || + frequency = 5; + range = undefined; + circle = undefined; + handle_pos = undefined; + curr_value = 0; + drag = false; + enTimer = false; + last_drag = false; + + dispatch(value) { + let [min,max,start,totallength] = this.range; + //save current value inside widget + this.curr_value = value; + + //check if in range + if (this.curr_value > max){ + this.curr_value = max; + this.apply_hmi_value(0, this.curr_value); + } + else if (this.curr_value < min){ + this.curr_value = min; + this.apply_hmi_value(0, this.curr_value); + } + + if(this.value_elt) + this.value_elt.textContent = String(value); + + //don't update if draging and setpoint ghost doesn't exist + if(!this.drag || (this.setpoint_elt != undefined)){ + this.update_DOM(value, this.handle_elt); + } + } + + update_DOM(value, elt){ + let [min,max,totalDistance] = this.range; + let length = Math.max(0,Math.min((totalDistance),(Number(value)-min)/(max-min)*(totalDistance))); + let tip = this.range_elt.getPointAtLength(length); + elt.setAttribute('transform',"translate("+(tip.x-this.handle_pos.x)+","+(tip.y-this.handle_pos.y)+")"); + + // show or hide ghost if exists + if(this.setpoint_elt != undefined){ + if(this.last_drag!= this.drag){ + if(this.drag){ + this.setpoint_elt.setAttribute("style", this.setpoint_style); + }else{ + this.setpoint_elt.setAttribute("style", "display:none"); + } + this.last_drag = this.drag; + } + } + } + + on_release(evt) { + //unbind events + window.removeEventListener("touchmove", this.on_bound_drag, true); + window.removeEventListener("mousemove", this.on_bound_drag, true); + + window.removeEventListener("mouseup", this.bound_on_release, true) + window.removeEventListener("touchend", this.bound_on_release, true); + window.removeEventListener("touchcancel", this.bound_on_release, true); + + //reset drag flag + if(this.drag){ + this.drag = false; + } + + // get final position + this.update_position(evt); + } + + on_drag(evt){ + //ignore drag event for X amount of time and if not selected + if(this.enTimer && this.drag){ + this.update_position(evt); + + //reset timer + this.enTimer = false; + setTimeout("{hmi_widgets['"+this.element_id+"'].enTimer = true;}", 100); + } + } + + update_position(evt){ + if(this.drag && this.enTimer){ + var svg_dist = 0; + + //calculate center of widget in html + // --TODO maybe it would be better to bind this part to window change size event ??? + let [xdest,ydest,svgWidth,svgHeight] = page_desc[current_visible_page].bbox; + let [cX, cY,fiStart,fiEnd,minMax,x1,y1,width,height] = this.circle; + let htmlCirc = this.range_elt.getBoundingClientRect(); + let cxHtml = ((htmlCirc.right-htmlCirc.left)/(width)*(cX-x1))+htmlCirc.left; + let cyHtml = ((htmlCirc.bottom-htmlCirc.top)/(height)*(cY-y1))+htmlCirc.top; + + + //get mouse coordinates + let mouseX = undefined; + let mouseY = undefined; + if (evt.type.startsWith("touch")){ + mouseX = Math.ceil(evt.touches[0].clientX); + mouseY = Math.ceil(evt.touches[0].clientY); + } + else{ + mouseX = evt.pageX; + mouseY = evt.pageY; + } + + //calculate angle + let fi = Math.atan2(cyHtml-mouseY, mouseX-cxHtml); + + // transform from 0 to 2PI + if (fi > 0){ + fi = 2*Math.PI-fi; + } + else{ + fi = -fi; + } + + //offset it to 0 + fi = fi - fiStart; + if (fi < 0){ + fi = fi + 2*Math.PI; + } + + //get handle distance from mouse position + if(fi= 1 ? this.args[0] : 0; + let max = this.max_elt ? + Number(this.max_elt.textContent) : + this.args.length >= 2 ? this.args[1] : 100; + + //fiStart ==> offset + let fiStart = Number(this.range_elt.getAttribute('sodipodi:start')); + let fiEnd = Number(this.range_elt.getAttribute('sodipodi:end')); + fiEnd = fiEnd - fiStart; + + //fiEnd ==> size of angle + if (fiEnd < 0){ + fiEnd = 2*Math.PI + fiEnd; + } + + //min max barrier angle + let minMax = (2*Math.PI - fiEnd)/2; + + //get parameters from svg + let cX = Number(this.range_elt.getAttribute('sodipodi:cx')); + let cY = Number(this.range_elt.getAttribute('sodipodi:cy')); + this.range_elt.style.strokeMiterlimit="0"; //eliminates some weird border around html object + this.range = [min, max,this.range_elt.getTotalLength()]; + let cPos = this.range_elt.getBBox(); + this.handle_pos = this.range_elt.getPointAtLength(0); + this.circle = [cX, cY,fiStart,fiEnd,minMax,cPos.x,cPos.y,cPos.width,cPos.height]; + + //bind functions + this.bound_on_select = this.on_select.bind(this); + this.bound_on_release = this.on_release.bind(this); + this.on_bound_drag = this.on_drag.bind(this); + + this.handle_elt.addEventListener("mousedown", this.bound_on_select); + this.element.addEventListener("mousedown", this.bound_on_select); + this.element.addEventListener("touchstart", this.bound_on_select); + //touch recognised as page drag without next command + document.body.addEventListener("touchstart", function(e){}, false); + + //save ghost style + //save ghost style + if(this.setpoint_elt != undefined){ + this.setpoint_style = this.setpoint_elt.getAttribute("style"); + this.setpoint_elt.setAttribute("style", "display:none"); + } + + } + || + +widget_defs("CircularSlider") { + labels("handle range"); + optional_labels("value min max setpoint"); + |, +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_custom.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_custom.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,62 @@ +// widget_custom.ysl2 +// +// widget entierely defined from JS code in Inkscape description field + +// TODO + +// a preliminary implementation was initially attempted but disabled +// code collected around before code refactoring + + +/*const "mark" > =HMI=\n*/ + + + /* TODO re-enable + || + function evaluate_js_from_descriptions() { + var Page; + var Input; + var Display; + var res = []; + || + const "midmark" > \n«$mark» + apply """//*[contains(child::svg:desc, $midmark) or \ + starts-with(child::svg:desc, $mark)]""",2 + mode="code_from_descs"; + || + return res; + } + || + */ + + // template "*", mode="code_from_descs" { + // || + // { + // var path, role, name, priv; + // var id = "«@id»"; + // || + + // /* if label is used, use it as default name */ + // if "@inkscape:label" + // |> name = "«@inkscape:label»"; + + // | /* -------------- */ + + // // this breaks indent, but fixing indent could break string literals + // value "substring-after(svg:desc, $mark)"; + // // nobody reads generated code anyhow... + + // || + + // /* -------------- */ + // res.push({ + // path:path, + // role:role, + // name:name, + // priv:priv + // }) + // } + // || + // } + + diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_customhtml.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_customhtml.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,43 @@ +// widget_customhtml.ysl2 + +widget_desc("CustomHtml") { + longdesc + || + CustomHtml widget allows insertion of HTML code in a svg:foreignObject. + Widget content is replaced by foreignObject. HTML code is obtained from + "code" labeled text content. HTML insert position and size is given with + "container" labeled element. + || + + shortdesc > Custom HTML insert + + // TODO: support reload and POST based on variable content +} + +widget_class("CustomHtml"){ + || + frequency = 5; + widget_size = undefined; + + dispatch(value) { + this.request_animate(); + } + + animate(){ + } + + init() { + this.widget_size = this.container_elt.getBBox(); + this.element.innerHTML =' '+ + this.code_elt.textContent+ + ' '; + } + || +} + + +widget_defs("CustomHtml") { + labels("container code"); +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_display.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_display.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,310 @@ +// widget_display.ysl2 + +widget_desc("Display") { + longdesc + || + If Display widget is a svg:text element, then text content is replaced by + value of given variables, space separated. + + Otherwise, if Display widget is a group containing a svg:text element + labelled "format", then text content is replaced by printf-like formated + string. In other words, if "format" labeled text is "%d %s %f", then 3 + variables paths are expected : HMI_IN, HMI_STRING and HMI_REAL. + + In case Display widget is a svg::text element, it is also possible to give + format string as first argument. + || + + shortdesc > Printf-like formated text display + + arg name="format" count="optional" accepts="string" > printf-like format string when not given as svg:text + + path name="fields" count="many" accepts="HMI_INT,HMI_REAL,HMI_STRING,HMI_BOOL" > variables to be displayed + +} + + +widget_class("Display") + || + frequency = 5; + dispatch(value, oldval, index) { + this.fields[index] = value; + this.request_animate(); + } + || + +widget_defs("Display") { + const "format" optional_labels("format"); + const "has_format","string-length($format)>0"; + value "$format"; + + if "$hmi_element[not(self::svg:text)] and not($has_format)" + error > Display Widget id="«$hmi_element/@id»" must be a svg::text element itself or a group containing a svg:text element labelled "format" + + const "field_initializer" foreach "path" { + choose{ + when "@type='HMI_STRING'" > "" + otherwise > 0 + } + if "position()!=last()" > , + } + | fields: [«$field_initializer»], + | animate: function(){ + choose { + when "$has_format" { + | if(this.format_elt.getAttribute("lang")) { + | this.format = svg_text_to_multiline(this.format_elt); + | this.format_elt.removeAttribute("lang"); + | } + | let str = vsprintf(this.format,this.fields); + | multiline_to_svg_text(this.format_elt, str); + } + otherwise { + | let str = this.args.length == 1 ? vsprintf(this.args[0],this.fields) : this.fields.join(' '); + | multiline_to_svg_text(this.element, str); + } + } + | }, + | + if "$has_format" { + | init: function() { + | this.format = svg_text_to_multiline(this.format_elt); + | }, + } +} + +emit "preamble:display" +|| +/* https://github.com/alexei/sprintf.js/blob/master/src/sprintf.js */ +/* global window, exports, define */ + +!function() { + 'use strict' + + var re = { + not_string: /[^s]/, + not_bool: /[^t]/, + not_type: /[^T]/, + not_primitive: /[^v]/, + number: /[diefg]/, + numeric_arg: /[bcdiefguxX]/, + json: /[j]/, + not_json: /[^j]/, + text: /^[^\x25]+/, + modulo: /^\x25{2}/, + placeholder: /^\x25(?:([1-9]\d*)\$|\(([^)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-gijostTuvxX])/, + key: /^([a-z_][a-z_\d]*)/i, + key_access: /^\.([a-z_][a-z_\d]*)/i, + index_access: /^\[(\d+)\]/, + sign: /^[+-]/ + } + + function sprintf(key) { + // arguments is not an array, but should be fine for this call + return sprintf_format(sprintf_parse(key), arguments) + } + + function vsprintf(fmt, argv) { + return sprintf.apply(null, [fmt].concat(argv || [])) + } + + function sprintf_format(parse_tree, argv) { + var cursor = 1, tree_length = parse_tree.length, arg, output = '', i, k, ph, pad, pad_character, pad_length, is_positive, sign + for (i = 0; i < tree_length; i++) { + if (typeof parse_tree[i] === 'string') { + output += parse_tree[i] + } + else if (typeof parse_tree[i] === 'object') { + ph = parse_tree[i] // convenience purposes only + if (ph.keys) { // keyword argument + arg = argv[cursor] + for (k = 0; k < ph.keys.length; k++) { + if (arg == undefined) { + throw new Error(sprintf('[sprintf] Cannot access property "%s" of undefined value "%s"', ph.keys[k], ph.keys[k-1])) + } + arg = arg[ph.keys[k]] + } + } + else if (ph.param_no) { // positional argument (explicit) + arg = argv[ph.param_no] + } + else { // positional argument (implicit) + arg = argv[cursor++] + } + + if (re.not_type.test(ph.type) && re.not_primitive.test(ph.type) && arg instanceof Function) { + arg = arg() + } + + if (re.numeric_arg.test(ph.type) && (typeof arg !== 'number' && isNaN(arg))) { + throw new TypeError(sprintf('[sprintf] expecting number but found %T', arg)) + } + + if (re.number.test(ph.type)) { + is_positive = arg >= 0 + } + + switch (ph.type) { + case 'b': + arg = parseInt(arg, 10).toString(2) + break + case 'c': + arg = String.fromCharCode(parseInt(arg, 10)) + break + case 'd': + case 'i': + arg = parseInt(arg, 10) + break + case 'j': + arg = JSON.stringify(arg, null, ph.width ? parseInt(ph.width) : 0) + break + case 'e': + arg = ph.precision ? parseFloat(arg).toExponential(ph.precision) : parseFloat(arg).toExponential() + break + case 'f': + arg = ph.precision ? parseFloat(arg).toFixed(ph.precision) : parseFloat(arg) + break + case 'g': + arg = ph.precision ? String(Number(arg.toPrecision(ph.precision))) : parseFloat(arg) + break + case 'o': + arg = (parseInt(arg, 10) >>> 0).toString(8) + break + case 's': + arg = String(arg) + arg = (ph.precision ? arg.substring(0, ph.precision) : arg) + break + case 't': + arg = String(!!arg) + arg = (ph.precision ? arg.substring(0, ph.precision) : arg) + break + case 'T': + arg = Object.prototype.toString.call(arg).slice(8, -1).toLowerCase() + arg = (ph.precision ? arg.substring(0, ph.precision) : arg) + break + case 'u': + arg = parseInt(arg, 10) >>> 0 + break + case 'v': + arg = arg.valueOf() + arg = (ph.precision ? arg.substring(0, ph.precision) : arg) + break + case 'x': + arg = (parseInt(arg, 10) >>> 0).toString(16) + break + case 'X': + arg = (parseInt(arg, 10) >>> 0).toString(16).toUpperCase() + break + } + if (re.json.test(ph.type)) { + output += arg + } + else { + if (re.number.test(ph.type) && (!is_positive || ph.sign)) { + sign = is_positive ? '+' : '-' + arg = arg.toString().replace(re.sign, '') + } + else { + sign = '' + } + pad_character = ph.pad_char ? ph.pad_char === '0' ? '0' : ph.pad_char.charAt(1) : ' ' + pad_length = ph.width - (sign + arg).length + pad = ph.width ? (pad_length > 0 ? pad_character.repeat(pad_length) : '') : '' + output += ph.align ? sign + arg + pad : (pad_character === '0' ? sign + pad + arg : pad + sign + arg) + } + } + } + return output + } + + var sprintf_cache = Object.create(null) + + function sprintf_parse(fmt) { + if (sprintf_cache[fmt]) { + return sprintf_cache[fmt] + } + + var _fmt = fmt, match, parse_tree = [], arg_names = 0 + while (_fmt) { + if ((match = re.text.exec(_fmt)) !== null) { + parse_tree.push(match[0]) + } + else if ((match = re.modulo.exec(_fmt)) !== null) { + parse_tree.push('%') + } + else if ((match = re.placeholder.exec(_fmt)) !== null) { + if (match[2]) { + arg_names |= 1 + var field_list = [], replacement_field = match[2], field_match = [] + if ((field_match = re.key.exec(replacement_field)) !== null) { + field_list.push(field_match[1]) + while ((replacement_field = replacement_field.substring(field_match[0].length)) !== '') { + if ((field_match = re.key_access.exec(replacement_field)) !== null) { + field_list.push(field_match[1]) + } + else if ((field_match = re.index_access.exec(replacement_field)) !== null) { + field_list.push(field_match[1]) + } + else { + throw new SyntaxError('[sprintf] failed to parse named argument key') + } + } + } + else { + throw new SyntaxError('[sprintf] failed to parse named argument key') + } + match[2] = field_list + } + else { + arg_names |= 2 + } + if (arg_names === 3) { + throw new Error('[sprintf] mixing positional and named placeholders is not (yet) supported') + } + + parse_tree.push( + { + placeholder: match[0], + param_no: match[1], + keys: match[2], + sign: match[3], + pad_char: match[4], + align: match[5], + width: match[6], + precision: match[7], + type: match[8] + } + ) + } + else { + throw new SyntaxError('[sprintf] unexpected placeholder') + } + _fmt = _fmt.substring(match[0].length) + } + return sprintf_cache[fmt] = parse_tree + } + + /** + * export to either browser or node.js + */ + /* eslint-disable quote-props */ + if (typeof exports !== 'undefined') { + exports['sprintf'] = sprintf + exports['vsprintf'] = vsprintf + } + if (typeof window !== 'undefined') { + window['sprintf'] = sprintf + window['vsprintf'] = vsprintf + + if (typeof define === 'function' && define['amd']) { + define(function() { + return { + 'sprintf': sprintf, + 'vsprintf': vsprintf + } + }) + } + } + /* eslint-enable quote-props */ +}(); // eslint-disable-line +|| diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_dropdown.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_dropdown.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,378 @@ +// widget_dropdown.ysl2 + +widget_desc("DropDown") { + + longdesc + || + DropDown widget let user select an entry in a list of texts, given as + arguments. Single variable path is index of selection. + + It needs "text" (svg:text), "box" (svg:rect), "button" (svg:*), + and "highlight" (svg:rect) labeled elements. + + When user clicks on "button", "text" is duplicated to display enties in the + limit of available space in page, and "box" is extended to contain all + texts. "highlight" is moved over pre-selected entry. + + When only one argument is given, and argment contains "#langs" then list of + texts is automatically set to the list of human-readable languages supported + by this HMI. + || + + shortdesc > Let user select text entry in a drop-down menu + + arg name="entries" count="many" accepts="string" > drop-down menu entries + + path name="selection" accepts="HMI_INT" > selection index +} + +// TODO: support i18n of menu entries using svg:text elements with labels starting with "_" + +widget_class("DropDown") { +|| + dispatch(value) { + if(!this.opened) this.set_selection(value); + } + init() { + this.button_elt.onclick = this.on_button_click.bind(this); + // Save original size of rectangle + this.box_bbox = this.box_elt.getBBox() + this.highlight_bbox = this.highlight_elt.getBBox() + this.highlight_elt.style.visibility = "hidden"; + + // Compute margins + this.text_bbox = this.text_elt.getBBox(); + let lmargin = this.text_bbox.x - this.box_bbox.x; + let tmargin = this.text_bbox.y - this.box_bbox.y; + this.margins = [lmargin, tmargin].map(x => Math.max(x,0)); + + // Index of first visible element in the menu, when opened + this.menu_offset = 0; + + // How mutch to lift the menu vertically so that it does not cross bottom border + this.lift = 0; + + // Event handlers cannot be object method ('this' is unknown) + // as a workaround, handler given to addEventListener is bound in advance. + this.bound_close_on_click_elsewhere = this.close_on_click_elsewhere.bind(this); + this.bound_on_selection_click = this.on_selection_click.bind(this); + this.bound_on_backward_click = this.on_backward_click.bind(this); + this.bound_on_forward_click = this.on_forward_click.bind(this); + this.opened = false; + this.clickables = []; + } + on_button_click() { + this.open(); + } + // Called when a menu entry is clicked + on_selection_click(selection) { + this.close(); + this.apply_hmi_value(0, selection); + } + on_backward_click(){ + this.scroll(false); + } + on_forward_click(){ + this.scroll(true); + } + set_selection(value) { + let display_str; + if(value >= 0 && value < this.content.length){ + // if valid selection resolve content + display_str = this.content[value]; + this.last_selection = value; + } else { + // otherwise show problem + display_str = "?"+String(value)+"?"; + } + // It is assumed that first span always stays, + // and contains selection when menu is closed + this.text_elt.firstElementChild.textContent = display_str; + } + grow_text(up_to) { + let count = 1; + let txt = this.text_elt; + let first = txt.firstElementChild; + // Real world (pixels) boundaries of current page + let bounds = svg_root.getBoundingClientRect(); + this.lift = 0; + while(count < up_to) { + let next = first.cloneNode(); + // relative line by line text flow instead of absolute y coordinate + next.removeAttribute("y"); + next.setAttribute("dy", "1.1em"); + // default content to allow computing text element bbox + next.textContent = "..."; + // append new span to text element + txt.appendChild(next); + // now check if text extended by one row fits to page + // FIXME : exclude margins to be more accurate on box size + let rect = txt.getBoundingClientRect(); + if(rect.bottom > bounds.bottom){ + // in case of overflow at the bottom, lift up one row + let backup = first.getAttribute("dy"); + // apply lift as a dy added too first span (y attrib stays) + first.setAttribute("dy", "-"+String((this.lift+1)*1.1)+"em"); + rect = txt.getBoundingClientRect(); + if(rect.top > bounds.top){ + this.lift += 1; + } else { + // if it goes over the top, then backtrack + // restore dy attribute on first span + if(backup) + first.setAttribute("dy", backup); + else + first.removeAttribute("dy"); + // remove unwanted child + txt.removeChild(next); + return count; + } + } + count++; + } + return count; + } + close_on_click_elsewhere(e) { + // inhibit events not targetting spans (menu items) + if([this.text_elt, this.element].indexOf(e.target.parentNode) == -1){ + e.stopPropagation(); + // close menu in case click is outside box + if(e.target !== this.box_elt) + this.close(); + } + } + close(){ + // Stop hogging all click events + svg_root.removeEventListener("pointerdown", this.numb_event, true); + svg_root.removeEventListener("pointerup", this.numb_event, true); + svg_root.removeEventListener("click", this.bound_close_on_click_elsewhere, true); + // Restore position and sixe of widget elements + this.reset_text(); + this.reset_clickables(); + this.reset_box(); + this.reset_highlight(); + // Put the button back in place + this.element.appendChild(this.button_elt); + // Mark as closed (to allow dispatch) + this.opened = false; + // Dispatch last cached value + this.apply_cache(); + } + // Make item (text span) clickable by overlaying a rectangle on top of it + make_clickable(span, func) { + let txt = this.text_elt; + let original_text_y = this.text_bbox.y; + let highlight = this.highlight_elt; + let original_h_y = this.highlight_bbox.y; + let clickable = highlight.cloneNode(); + let yoffset = span.getBBox().y - original_text_y; + clickable.y.baseVal.value = original_h_y + yoffset; + clickable.style.pointerEvents = "bounding-box"; + //clickable.style.visibility = "hidden"; + //clickable.onclick = () => alert("love JS"); + clickable.onclick = func; + this.element.appendChild(clickable); + this.clickables.push(clickable) + } + reset_clickables() { + while(this.clickables.length){ + this.element.removeChild(this.clickables.pop()); + } + } + // Set text content when content is smaller than menu (no scrolling) + set_complete_text(){ + let spans = this.text_elt.children; + let c = 0; + for(let item of this.content){ + let span=spans[c]; + span.textContent = item; + let sel = c; + this.make_clickable(span, (evt) => this.bound_on_selection_click(sel)); + c++; + } + } + // Move partial view : + // false : upward, lower value + // true : downward, higher value + scroll(forward){ + let contentlength = this.content.length; + let spans = this.text_elt.children; + let spanslength = spans.length; + // reduce accounted menu size according to prsence of scroll buttons + // since we scroll there is necessarly one button + spanslength--; + if(forward){ + // reduce accounted menu size because of back button + // in current view + if(this.menu_offset > 0) spanslength--; + this.menu_offset = Math.min( + contentlength - spans.length + 1, + this.menu_offset + spanslength); + }else{ + // reduce accounted menu size because of back button + // in view once scrolled + if(this.menu_offset - spanslength > 0) spanslength--; + this.menu_offset = Math.max( + 0, + this.menu_offset - spanslength); + } + if(this.menu_offset == 1) + this.menu_offset = 0; + + this.reset_highlight(); + + this.reset_clickables(); + this.set_partial_text(); + + this.highlight_selection(); + } + // Setup partial view text content + // with jumps at first and last entry when appropriate + set_partial_text(){ + let spans = this.text_elt.children; + let contentlength = this.content.length; + let spanslength = spans.length; + let i = this.menu_offset, c = 0; + let m = this.box_bbox; + while(c < spanslength){ + let span=spans[c]; + let onclickfunc; + // backward jump only present if not exactly at start + if(c == 0 && i != 0){ + span.textContent = "▲"; + onclickfunc = this.bound_on_backward_click; + let o = span.getBBox(); + span.setAttribute("dx", (m.width - o.width)/2); + // presence of forward jump when not right at the end + }else if(c == spanslength-1 && i < contentlength - 1){ + span.textContent = "▼"; + onclickfunc = this.bound_on_forward_click; + let o = span.getBBox(); + span.setAttribute("dx", (m.width - o.width)/2); + // otherwise normal content + }else{ + span.textContent = this.content[i]; + let sel = i; + onclickfunc = (evt) => this.bound_on_selection_click(sel); + span.removeAttribute("dx"); + i++; + } + this.make_clickable(span, onclickfunc); + c++; + } + } + numb_event(e) { + e.stopPropagation(); + } + open(){ + let length = this.content.length; + // systematically reset text, to strip eventual whitespace spans + this.reset_text(); + // grow as much as needed or possible + let slots = this.grow_text(length); + // Depending on final size + if(slots == length) { + // show all at once + this.set_complete_text(); + } else { + // eventualy align menu to current selection, compensating for lift + let offset = this.last_selection - this.lift; + if(offset > 0) + this.menu_offset = Math.min(offset + 1, length - slots + 1); + else + this.menu_offset = 0; + // show surrounding values + this.set_partial_text(); + } + // Now that text size is known, we can set the box around it + this.adjust_box_to_text(); + // Take button out until menu closed + this.element.removeChild(this.button_elt); + // Rise widget to top by moving it to last position among siblings + this.element.parentNode.appendChild(this.element.parentNode.removeChild(this.element)); + // disable interaction with background + svg_root.addEventListener("pointerdown", this.numb_event, true); + svg_root.addEventListener("pointerup", this.numb_event, true); + svg_root.addEventListener("click", this.bound_close_on_click_elsewhere, true); + this.highlight_selection(); + + // mark as open + this.opened = true; + } + // Put text element in normalized state + reset_text(){ + let txt = this.text_elt; + let first = txt.firstElementChild; + // remove attribute eventually added to first text line while opening + first.onclick = null; + first.removeAttribute("dy"); + first.removeAttribute("dx"); + // keep only the first line of text + for(let span of Array.from(txt.children).slice(1)){ + txt.removeChild(span) + } + } + // Put rectangle element in saved original state + reset_box(){ + let m = this.box_bbox; + let b = this.box_elt; + b.x.baseVal.value = m.x; + b.y.baseVal.value = m.y; + b.width.baseVal.value = m.width; + b.height.baseVal.value = m.height; + } + highlight_selection(){ + if(this.last_selection == undefined) return; + let highlighted_row = this.last_selection - this.menu_offset; + if(highlighted_row < 0) return; + let spans = this.text_elt.children; + let spanslength = spans.length; + let contentlength = this.content.length; + if(this.menu_offset != 0) { + spanslength--; + highlighted_row++; + } + if(this.menu_offset + spanslength < contentlength - 1) spanslength--; + if(highlighted_row > spanslength) return; + let original_text_y = this.text_bbox.y; + let highlight = this.highlight_elt; + let span = spans[highlighted_row]; + let yoffset = span.getBBox().y - original_text_y; + highlight.y.baseVal.value = this.highlight_bbox.y + yoffset; + highlight.style.visibility = "visible"; + } + reset_highlight(){ + let highlight = this.highlight_elt; + highlight.y.baseVal.value = this.highlight_bbox.y; + highlight.style.visibility = "hidden"; + } + // Use margin and text size to compute box size + adjust_box_to_text(){ + let [lmargin, tmargin] = this.margins; + let m = this.text_elt.getBBox(); + let b = this.box_elt; + // b.x.baseVal.value = m.x - lmargin; + b.y.baseVal.value = m.y - tmargin; + // b.width.baseVal.value = 2 * lmargin + m.width; + b.height.baseVal.value = 2 * tmargin + m.height; + } +|| +} + +widget_defs("DropDown") { + labels("text box button highlight"); + // It is assumed that list content conforms to Array interface. + > content: + choose{ + // special case when used for language selection + when "count(arg) = 1 and arg[1]/@value = '#langs'" { + > langs + } + otherwise { + > [\n + foreach "arg" | "«@value»", + > ] + } + } + > ,\n +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_foreach.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_foreach.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,148 @@ +// widget_foreach.ysl2 + +widget_desc("ForEach") { + + longdesc + || + ForEach widget is used to span a small set of widget over a larger set of + repeated HMI_NODEs. + + Idea is somewhat similar to relative page, but it all happens inside the + ForEach widget, no page involved. + + Together with relative Jump widgets it can be used to build a menu to reach + relative pages covering many identical HMI_NODES siblings. + + ForEach widget takes a HMI_CLASS name as argument and a HMI_NODE path as + variable. + + Direct sub-elements can be either groups of widget to be spanned, labeled + "ClassName:offset", or buttons to control the spanning, labeled + "ClassName:+/-number". + || + + shortdesc > span widgets over a set of repeated HMI_NODEs + + arg name="class_name" accepts="string" > HMI_CLASS name + + path name="root" accepts="HMI_NODE" > where to find HMI_NODEs whose HMI_CLASS is class_name +} + +widget_defs("ForEach") { + + if "count(path) != 1" error > ForEach widget «$hmi_element/@id» must have one HMI path given. + if "count(arg) != 1" error > ForEach widget «$hmi_element/@id» must have one argument given : a class name. + + const "class","arg[1]/@value"; + + const "base_path","path/@value"; + const "hmi_index_base", "$indexed_hmitree/*[@hmipath = $base_path]"; + const "hmi_tree_base", "$hmitree/descendant-or-self::*[@path = $hmi_index_base/@path]"; + const "hmi_tree_items", "$hmi_tree_base/*[@class = $class]"; + const "hmi_index_items", "$indexed_hmitree/*[@path = $hmi_tree_items/@path]"; + const "items_paths", "$hmi_index_items/@hmipath"; + | index_pool: [ + foreach "$hmi_index_items" { + | «@index»`if "position()!=last()" > ,` + } + | ], + | init: function() { + const "prefix","concat($class,':')"; + const "buttons_regex","concat('^',$prefix,'[+\-][0-9]+')"; + const "buttons", "$hmi_element/*[regexp:test(@inkscape:label, $buttons_regex)]"; + foreach "$buttons" { + const "op","substring-after(@inkscape:label, $prefix)"; + | id("«@id»").setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_click('«$op»', evt)"); + } + | + | this.items = [ + const "items_regex","concat('^',$prefix,'[0-9]+')"; + const "unordered_items","$hmi_element//*[regexp:test(@inkscape:label, $items_regex)]"; + foreach "$unordered_items" { + const "elt_label","concat($prefix, string(position()))"; + const "elt","$unordered_items[@inkscape:label = $elt_label]"; + const "pos","position()"; + const "item_path", "$items_paths[$pos]"; + | [ /* item="«$elt_label»" path="«$item_path»" */ + if "count($elt)=0" error > Missing item labeled «$elt_label» in ForEach widget «$hmi_element/@id» + foreach "func:refered_elements($elt)[@id = $hmi_elements/@id][not(@id = $elt/@id)]" { + if "not(func:is_descendant_path(func:widget(@id)/path/@value, $item_path))" + error > Widget id="«@id»" label="«@inkscape:label»" is having wrong path. Accroding to ForEach widget ancestor id="«$hmi_element/@id»", path should be descendant of "«$item_path»". + | hmi_widgets["«@id»"]`if "position()!=last()" > ,` + } + | ]`if "position()!=last()" > ,` + } + | ] + | }, + | item_offset: 0, +} + +widget_class("ForEach") +|| + + unsub_items(){ + for(let item of this.items){ + for(let widget of item) { + widget.unsub(); + } + } + } + + unsub(){ + this.unsub_items(); + this.offset = 0; + this.relativeness = undefined; + } + + sub_items(){ + for(let i = 0; i < this.items.length; i++) { + let item = this.items[i]; + let orig_item_index = this.index_pool[i]; + let item_index = this.index_pool[i+this.item_offset]; + let item_index_offset = item_index - orig_item_index; + if(this.relativeness[0]) + item_index_offset += this.offset; + for(let widget of item) { + /* all variables of all widgets in a ForEach are all relative. + Really. + + TODO: allow absolute variables in ForEach widgets + */ + widget.sub(item_index_offset, widget.indexes.map(_=>true)); + } + } + } + + sub(new_offset=0, relativeness=[]){ + this.offset = new_offset; + this.relativeness = relativeness; + this.sub_items(); + } + + apply_cache() { + this.items.forEach(item=>item.forEach(widget=>widget.apply_cache())); + } + + on_click(opstr, evt) { + let new_item_offset = eval(String(this.item_offset)+opstr); + if(new_item_offset + this.items.length > this.index_pool.length) { + if(this.item_offset + this.items.length == this.index_pool.length) + new_item_offset = 0; + else + new_item_offset = this.index_pool.length - this.items.length; + } else if(new_item_offset < 0) { + if(this.item_offset == 0) + new_item_offset = this.index_pool.length - this.items.length; + else + new_item_offset = 0; + } + this.item_offset = new_item_offset; + this.unsub_items(); + this.sub_items(); + update_subscriptions(); + need_cache_apply.push(this); + jumps_need_update = true; + requestHMIAnimation(); + } +|| + diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_input.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_input.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,110 @@ +// widget_input.ysl2 + +widget_desc("Input") { + longdesc + || + Input widget takes one variable path, and displays current value in + optional "value" labeled sub-element. + + Click on optional "edit" labeled element opens keypad to edit value. + + Operation on current value is performed when click on sub-elements with + label starting with '=', '+' or '-' sign. Value after sign is used as + operand. + || + + shortdesc > Input field with predefined operation buttons + + arg name="format" accepts="string" > optional printf-like format + + path name="edit" accepts="HMI_INT, HMI_REAL, HMI_STRING" > single variable to edit + +} + +widget_class("Input") +|| + on_op_click(opstr) { + this.change_hmi_value(0, opstr); + } + edit_callback(new_val) { + this.apply_hmi_value(0, new_val); + } + + is_inhibited = false; + alert(msg){ + this.is_inhibited = true; + this.display = msg; + setTimeout(() => this.stopalert(), 1000); + this.request_animate(); + } + + stopalert(){ + this.is_inhibited = false; + this.display = this.last_value; + this.request_animate(); + } + + overshot(new_val, max) { + this.alert("max"); + } + + undershot(new_val, min) { + this.alert("min"); + } +|| + +widget_defs("Input") { + + const "value_elt" optional_labels("value"); + const "have_value","string-length($value_elt)>0"; + value "$value_elt"; + + const "edit_elt" optional_labels("edit"); + const "have_edit","string-length($edit_elt)>0"; + value "$edit_elt"; + + if "$have_value" + | frequency: 5, + + | dispatch: function(value) { + + + if "$have_value or $have_edit" { + choose{ + when "count(arg) = 1" { + | this.last_value = vsprintf("«arg[1]/@value»", [value]); + } + otherwise { + | this.last_value = value; + } + } + | if(!this.is_inhibited){ + | this.display = this.last_value; + if "$have_value" { + | this.request_animate(); + } + | } + } + | }, + + if "$have_value" { + | animate: function(){ + | this.value_elt.textContent = String(this.display); + | }, + } + + | init: function() { + + if "$have_edit" { + | this.edit_elt.onclick = () => edit_value("«path/@value»", "«path/@type»", this, this.last_value); + if "$have_value" { + | this.value_elt.style.pointerEvents = "none"; + } + } + + foreach "$hmi_element/*[regexp:test(@inkscape:label,'^[=+\-].+')]" { + | id("«@id»").onclick = () => this.on_op_click("«func:escape_quotes(@inkscape:label)»"); + } + + | }, +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_jsontable.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_jsontable.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,303 @@ +// widget_jsontable.ysl2 + +widget_desc("JsonTable") { + longdesc + || + Send given variables as POST to http URL argument, spread returned JSON in + SVG sub-elements of "data" labeled element. + + Documentation to be written. see svbghmi exemple. + || + + shortdesc > Http POST variables, spread JSON back + + arg name="url" accepts="string" > + + path name="edit" accepts="HMI_INT, HMI_REAL, HMI_STRING" > single variable to edit + +} + +widget_class("JsonTable") + || + // arbitrary defaults to avoid missing entries in query + cache = [0,0,0]; + init_common() { + this.spread_json_data_bound = this.spread_json_data.bind(this); + this.handle_http_response_bound = this.handle_http_response.bind(this); + this.fetch_error_bound = this.fetch_error.bind(this); + this.promised = false; + } + + handle_http_response(response) { + if (!response.ok) { + console.log("HTTP error, status = " + response.status); + } + return response.json(); + } + + fetch_error(e){ + console.log("HTTP fetch error, message = " + e.message + "Widget:" + this.element_id); + } + + do_http_request(...opt) { + this.abort_controller = new AbortController(); + return Promise.resolve().then(() => { + + const query = { + args: this.args, + range: this.cache[1], + position: this.cache[2], + visible: this.visible, + extra: this.cache.slice(4), + options: opt + }; + + const options = { + method: 'POST', + body: JSON.stringify(query), + headers: {'Content-Type': 'application/json'}, + signal: this.abort_controller.signal + }; + + return fetch(this.args[0], options) + .then(this.handle_http_response_bound) + .then(this.spread_json_data_bound) + .catch(this.fetch_error_bound); + }); + } + + unsub(){ + this.abort_controller.abort(); + super.unsub(); + } + + sub(...args){ + this.cache[0] = undefined; + super.sub(...args); + } + + dispatch(value, oldval, index) { + + if(this.cache[index] != value) + this.cache[index] = value; + else + return; + + if(!this.promised){ + this.promised = true; + this.do_http_request().finally(() => { + this.promised = false; + }); + } + } + make_on_click(...options){ + let that = this; + return function(evt){ + that.do_http_request(...options); + } + } + // on_click(evt, ...options) { + // this.do_http_request(...options); + // } + || + +gen_index_xhtml { + +template "svg:*", mode="json_table_elt_render" { + error > JsonTable Widget can't contain element of type «local-name()». +} + + +const "hmi_textstylelists_descs", "$parsed_widgets/widget[@type = 'TextStyleList']"; +const "hmi_textstylelists", "$hmi_elements[@id = $hmi_textstylelists_descs/@id]"; + +const "textstylelist_related" foreach "$hmi_textstylelists" list { + attrib "listid" value "@id"; + foreach "func:refered_elements(.)" elt { + attrib "eltid" value "@id"; + } +} +const "textstylelist_related_ns", "exsl:node-set($textstylelist_related)"; + +def "func:json_expressions" { + param "expressions"; + param "label"; + + // compute javascript expressions to access JSON data + // desscribed in given svg element's "label" + // knowing that parent element already has given "expressions". + + choose { + when "$label" { + const "suffixes", "str:split($label)"; + const "res" foreach "$suffixes" expression { + const "suffix","."; + const "pos","position()"; + // take last available expression (i.e can have more suffixes than expressions) + const "expr","$expressions[position() <= $pos][last()]/expression"; + choose { + when "contains($suffix,'=')" { + const "name", "substring-before($suffix,'=')"; + if "$expr/@name[. != $name]" + error > JsonTable : missplaced '=' or inconsistent names in Json data expressions. + attrib "name" value "$name"; + attrib "content" > «$expr/@content»«substring-after($suffix,'=')» + } + otherwise { + copy "$expr/@name"; + attrib "content" > «$expr/@content»«$suffix» + } + } + } + result "exsl:node-set($res)"; + } + // Empty labels are ignored, expressions are then passed as-is. + otherwise result "$expressions"; + } + +} + +const "initexpr" expression attrib "content" > jdata +const "initexpr_ns", "exsl:node-set($initexpr)"; + +template "svg:use", mode="json_table_elt_render" { + param "expressions"; + // cloned element must be part of a HMI:List + const "targetid", "substring-after(@xlink:href,'#')"; + const "from_list", "$hmi_lists[(@id | */@id) = $targetid]"; + + choose { + when "count($from_list) > 0" { + | id("«@id»").setAttribute("xlink:href", + // obtain new target id from HMI:List widget + | "#"+hmi_widgets["«$from_list/@id»"].items[«$expressions/expression[1]/@content»]); + } + otherwise + warning > Clones (svg:use) in JsonTable Widget must point to a valid HMI:List widget or item. Reference "«@xlink:href»" is not valid and will not be updated. + } +} + +template "svg:text", mode="json_table_elt_render" { + param "expressions"; + const "value_expr", "$expressions/expression[1]/@content"; + const "original", "@original"; + const "from_textstylelist", "$textstylelist_related_ns/list[elt/@eltid = $original]"; + choose { + + when "count($from_textstylelist) > 0" { + const "content_expr", "$expressions/expression[2]/@content"; + if "string-length($content_expr) = 0 or $expressions/expression[2]/@name != 'textContent'" + error > Clones (svg:use) in JsonTable Widget pointing to a HMI:TextStyleList widget or item must have a "textContent=.someVal" assignement following value expression in label. + | { + | let elt = id("«@id»"); + | elt.textContent = String(«$content_expr»); + | elt.style = hmi_widgets["«$from_textstylelist/@listid»"].styles[«$value_expr»]; + | } + } + otherwise { + | id("«@id»").textContent = String(«$value_expr»); + } + } +} + + +// only labels comming from Json widget are counted in +def "func:filter_non_widget_label" { + param "elt"; + param "widget_elts"; + const "eltid" choose { + when "$elt/@original" value "$elt/@original"; + otherwise value "$elt/@id"; + } + result "$widget_elts[@id=$eltid]/@inkscape:label"; +} + +template "svg:*", mode="json_table_render_except_comments"{ + param "expressions"; + param "widget_elts"; + + const "label", "func:filter_non_widget_label(., $widget_elts)"; + // filter out "# commented" elements + if "not(starts-with($label,'#'))" + apply ".", mode="json_table_render"{ + with "expressions", "$expressions"; + with "widget_elts", "$widget_elts"; + with "label", "$label"; + } +} + + +template "svg:*", mode="json_table_render" { + param "expressions"; + param "widget_elts"; + param "label"; + + const "new_expressions", "func:json_expressions($expressions, $label)"; + + const "elt","."; + foreach "$new_expressions/expression[position() > 1][starts-with(@name,'onClick')]" + | id("«$elt/@id»").onclick = this.make_on_click('«@name»', «@content»); + + apply ".", mode="json_table_elt_render" + with "expressions", "$new_expressions"; +} + +template "svg:g", mode="json_table_render" { + param "expressions"; + param "widget_elts"; + param "label"; + + // use intermediate variables for optimization + const "varprefix" > obj_«@id»_ + | try { + + foreach "$expressions/expression"{ + | let «$varprefix»«position()» = «@content»; + | if(«$varprefix»«position()» == undefined) { + | throw null; + | } + } + + // because we put values in a variables, we can replace corresponding expression with variable name + const "new_expressions" foreach "$expressions/expression" xsl:copy { + copy "@name"; + attrib "content" > «$varprefix»«position()» + } + + // revert hiding in case it did happen before + | id("«@id»").style = "«@style»"; + + apply "*", mode="json_table_render_except_comments" { + with "expressions", "func:json_expressions(exsl:node-set($new_expressions), $label)"; + with "widget_elts", "$widget_elts"; + } + | } catch(err) { + | id("«@id»").style = "display:none"; + | } +} + +} + +widget_defs("JsonTable") { + labels("data"); + const "data_elt", "$result_svg_ns//*[@id = $hmi_element/@id]/*[@inkscape:label = 'data']"; + | visible: «count($data_elt/*[@inkscape:label])», + | spread_json_data: function(janswer) { + | let [range,position,jdata] = janswer; + | [[1, range], [2, position], [3, this.visible]].map(([i,v]) => { + | this.apply_hmi_value(i,v); + | this.cache[i] = v; + | }); + apply "$data_elt", mode="json_table_render_except_comments" { + with "expressions","$initexpr_ns"; + with "widget_elts","$hmi_element/*[@inkscape:label = 'data']/descendant::svg:*"; + } + | }, + | init() { + | this.init_common(); + foreach "$hmi_element/*[starts-with(@inkscape:label,'action_')]" { + | id("«@id»").onclick = this.make_on_click("«func:escape_quotes(@inkscape:label)»"); + } + | } + +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_jump.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_jump.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,157 @@ +// widget_jump.ysl2 + +widget_desc("Jump") { + longdesc + || + Jump widget brings focus to a different page. Mandatory single argument + gives name of the page. + + Optional single path is used as new reference when jumping to a relative + page, it must point to a HMI_NODE. + + "active"+"inactive" labeled elements can be provided and reflect current + page being shown. + + "disabled" labeled element, if provided, is shown instead of "active" or + "inactive" widget when pointed HMI_NODE is null. + || + + shortdesc > Jump to given page + + arg name="page" accepts="string" > name of page to jump to + + path name="reference" count="optional" accepts="HMI_NODE" > reference for relative jump +} + +widget_class("Jump") { +|| + activable = false; + active = false; + disabled = false; + frequency = 2; + + update_activity() { + if(this.active) { + /* show active */ + this.active_elt.style.display = ""; + /* hide inactive */ + this.inactive_elt.style.display = "none"; + } else { + /* show inactive */ + this.inactive_elt.style.display = ""; + /* hide active */ + this.active_elt.style.display = "none"; + } + } + + update_disability() { + if(this.disabled) { + /* show disabled */ + this.disabled_elt.style.display = ""; + /* hide inactive */ + this.inactive_elt.style.display = "none"; + /* hide active */ + this.active_elt.style.display = "none"; + } else { + /* hide disabled */ + this.disabled_elt.style.display = "none"; + this.update_activity(); + } + } + + make_on_click() { + let that = this; + const name = this.args[0]; + return function(evt){ + /* TODO: in order to allow jumps to page selected through for exemple a dropdown, + support path pointing to local variable whom value + would be an HMI_TREE index and then jump to a relative page not hard-coded in advance */ + + if(!that.disabled) { + const index = that.indexes.length > 0 ? that.indexes[0] + that.offset : undefined; + switch_page(name, index); + } + } + } + + notify_page_change(page_name, index) { + if(this.activable) { + const ref_index = this.indexes.length > 0 ? this.indexes[0] + this.offset : undefined; + const ref_name = this.args[0]; + this.active = ((ref_name == undefined || ref_name == page_name) && index == ref_index); + this.update_state(); + } + } + + dispatch(value) { + this.disabled = !Number(value); + this.update_state(); + } +|| +} + +widget_defs("Jump") { + // TODO: ensure both active and inactive are provided + const "activity" optional_labels("active inactive"); + const "have_activity","string-length($activity)>0"; + value "$activity"; + + const "disability" optional_labels("disabled"); + const "have_disability","$have_activity and string-length($disability)>0"; + value "$disability"; + + | init: function() { + | this.element.onclick = this.make_on_click(); + if "$have_activity" { + | this.activable = true; + } + if "not($have_disability)" { + | this.unsubscribable = true; + } + > this.update_state = + choose { + when "$have_disability" { + > this.update_disability + } + when "$have_activity" { + > this.update_activity + } + otherwise > null + } + > ;\n + | }, + +} + +widget_page("Jump"){ + param "page_desc"; + /* check that given path is compatible with page's reference path */ + if "path" { + /* TODO: suport local variable containing an HMI_TREE index to jump to a relative page */ + /* when no page name provided, check for same page */ + const "target_page_name" choose { + when "arg" value "arg[1]/@value"; + otherwise value "$page_desc/arg[1]/@value"; + } + const "target_page_path" choose { + when "arg" value "$hmi_pages_descs[arg[1]/@value = $target_page_name]/path[1]/@value"; + otherwise value "$page_desc/path[1]/@value"; + } + + if "not(func:same_class_paths($target_page_path, path[1]/@value))" + error > Jump id="«@id»" to page "«$target_page_name»" with incompatible path "«path[1]/@value» (must be same class as "«$target_page_path»") + } +} + +emit "declarations:jump" +|| +var jumps_need_update = false; +var jump_history = [[default_page, undefined]]; + +function update_jumps() { + page_desc[current_visible_page].jumps.map(w=>w.notify_page_change(current_visible_page,current_page_index)); + jumps_need_update = false; +}; + +|| + diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_keypad.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_keypad.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,141 @@ +// widget_keypad.ysl2 + +widget_desc("Keypad") { + longdesc + || + Keypad - to be written + || + + shortdesc > Keypad + + arg name="supported_types" accepts="string" > keypad can input those types + +} + +emit "declarations:keypad" { + | + | var keypads = { + foreach "$keypads_descs"{ + const "keypad_id","@id"; + foreach "arg"{ + const "g", "$geometry[@Id = $keypad_id]"; + | "«@value»":["«$keypad_id»", «$g/@x», «$g/@y»], + } + } + | } +} + +widget_class("Keypad") + || + on_key_click(symbols) { + var syms = symbols.split(" "); + this.shift |= this.caps; + this.editstr += syms[this.shift?syms.length-1:0]; + this.shift = false; + this.update(); + } + + on_Esc_click() { + end_modal.call(this); + } + + on_Enter_click() { + let coercedval = (typeof this.initial) == "number" ? Number(this.editstr) : this.editstr; + if(typeof coercedval == 'number' && isNaN(coercedval)){ + // revert to initial so it explicitely shows input was ignored + this.editstr = String(this.initial); + this.update(); + } else { + let callback_obj = this.result_callback_obj; + end_modal.call(this); + callback_obj.edit_callback(coercedval); + } + } + + on_BackSpace_click() { + this.editstr = this.editstr.slice(0,this.editstr.length-1); + this.update(); + } + + on_Sign_click() { + if(this.editstr[0] == "-") + this.editstr = this.editstr.slice(1,this.editstr.length); + else + this.editstr = "-" + this.editstr; + this.update(); + } + + on_NumDot_click() { + if(this.editstr.indexOf(".") == "-1"){ + this.editstr += "."; + this.update(); + } + } + + on_Space_click() { + this.editstr += " "; + this.update(); + } + + caps = false; + _caps = undefined; + on_CapsLock_click() { + this.caps = !this.caps; + this.update(); + } + + shift = false; + _shift = undefined; + on_Shift_click() { + this.shift = !this.shift; + this.caps = false; + this.update(); + } + editstr = ""; + _editstr = undefined; + result_callback_obj = undefined; + start_edit(info, valuetype, callback_obj, initial,size) { + show_modal.call(this,size); + this.editstr = String(initial); + this.result_callback_obj = callback_obj; + this.Info_elt.textContent = info; + this.shift = false; + this.caps = false; + this.initial = initial; + + this.update(); + } + + update() { + if(this.editstr != this._editstr){ + this._editstr = this.editstr; + this.Value_elt.textContent = this.editstr; + } + if(this.Shift_sub && this.shift != this._shift){ + this._shift = this.shift; + (this.shift?this.activate_activable:this.inactivate_activable)(this.Shift_sub); + } + if(this.CapsLock_sub && this.caps != this._caps){ + this._caps = this.caps; + (this.caps?this.activate_activable:this.inactivate_activable)(this.CapsLock_sub); + } + } + || + +widget_defs("Keypad") { + labels("Esc Enter BackSpace Keys Info Value"); + optional_labels("Sign Space NumDot"); + activable_labels("CapsLock Shift"); + | init: function() { + foreach "$hmi_element/*[@inkscape:label = 'Keys']/*" { + | id("«@id»").setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_key_click('«func:escape_quotes(@inkscape:label)»')"); + } + foreach "str:split('Esc Enter BackSpace Sign Space NumDot CapsLock Shift')" { + | if(this.«.»_elt) + | this.«.»_elt.setAttribute("onclick", "hmi_widgets['«$hmi_element/@id»'].on_«.»_click()"); + } + | }, + | + const "g", "$geometry[@Id = $hmi_element/@id]"; + | coordinates: [«$g/@x», «$g/@y»], +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_list.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_list.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,25 @@ +// widget_list.ysl2 +widget_desc("List") { + // TODO +} + +widget_defs("List") { + | items: { + foreach "$hmi_element/*[@inkscape:label]" { + | «@inkscape:label»: "«@id»", + } + | }, +} + +widget_defs("TextStyleList") { + // TODO +} + +widget_defs("TextStyleList") { + | styles: { + foreach "$hmi_element/*[@inkscape:label]" { + const "style", "func:refered_elements(.)[self::svg:text]/@style"; + | «@inkscape:label»: "«$style»", + } + | }, +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_meter.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_meter.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,65 @@ +// widget_meter.ysl2 + +widget_desc("Meter") { + longdesc + || + Meter widget moves the end of "needle" labeled path along "range" labeled + path, according to value of the single accepted variable. + + Needle is reduced to a single segment. If "min" a "max" labeled texts + are provided, or if first and second argument are given, then they are used + as respective minimum and maximum value. Otherwise, value is expected to be + in between 0 and 100. + + If "value" labeled text is found, then its content is replaced by value. + || + + shortdesc > Moves "needle" along "range" + + arg name="min" count="optional" accepts="int,real" > minimum value + + arg name="max" count="optional" accepts="int,real" > maximum value + + // TODO: add printf-like format + + path name="value" accepts="HMI_INT,HMI_REAL" > Value to display + +} + +widget_class("Meter"){ + || + frequency = 10; + origin = undefined; + range = undefined; + + dispatch(value) { + this.display_val = value; + this.request_animate(); + } + + animate(){ + if(this.value_elt) + this.value_elt.textContent = String(this.display_val); + let [min,max,totallength] = this.range; + let length = Math.max(0,Math.min(totallength,(Number(this.display_val)-min)*totallength/(max-min))); + let tip = this.range_elt.getPointAtLength(length); + this.needle_elt.setAttribute('d', "M "+this.origin.x+","+this.origin.y+" "+tip.x+","+tip.y); + } + + init() { + let [min,max] = [[this.min_elt,0],[this.max_elt,100]].map(([elt,def],i)=>elt? + Number(elt.textContent) : + this.args.length >= i+1 ? this.args[i] : def); + + this.range = [min, max, this.range_elt.getTotalLength()] + this.origin = this.needle_elt.getPointAtLength(0); + } + || +} + +widget_defs("Meter") { + labels("needle range"); + optional_labels("value min max"); +} + + diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_multistate.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_multistate.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,80 @@ +// widget_multistate.ysl2 + +widget_defs("MultiState") { + + longdesc + || + Mutlistateh widget hides all subelements whose label do not match given + variable value representation. For exemple if given variable type + is HMI_INT and value is 1, then elements with label '1' will be displayed. + Label can have comments, so '1#some comment' would also match. If matching + variable of type HMI_STRING, then double quotes must be used. For exemple, + '"hello"' or '"hello"#another comment' match HMI_STRING 'hello'. + + Click on widget changes variable value to next value in given list, or to + first one if not initialized to value already part of the list. + || + + shortdesc > Show elements whose label match value. + + // TODO: add optional format/precision argument to support floating points + + path name="value" accepts="HMI_INT,HMI_STRING" > value to compare to labels + +} + +widget_class("MultiState") + || + frequency = 5; + state = 0; + dispatch(value) { + this.state = value; + for(let choice of this.choices){ + if(this.state != choice.value){ + choice.elt.setAttribute("style", "display:none"); + } else { + choice.elt.setAttribute("style", choice.style); + } + } + } + + on_click(evt) { + //get current selected value + let next_ind; + for(next_ind=0; next_ind next_ind){ + this.state = this.choices[next_ind].value; + } + else{ + this.state = this.choices[0].value; + } + + //post value to plc + this.apply_hmi_value(0, this.state); + } + + init() { + this.element.setAttribute("onclick", "hmi_widgets['"+this.element_id+"'].on_click(evt)"); + } + || + +widget_defs("MultiState") { + | choices: [ + const "regex",!"'^(\"[^\"].*\"|\-?[0-9]+|false|true)(#.*)?$'"!; + foreach "$result_svg_ns//*[@id = $hmi_element/@id]//*[regexp:test(@inkscape:label,$regex)]" { + const "literal", "regexp:match(@inkscape:label,$regex)[2]"; + | { + | elt:id("«@id»"), + | style:"«@style»", + | value:«$literal» + | }`if "position()!=last()" > ,` + } + | ], +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_scrollbar.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_scrollbar.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,122 @@ +// widget_scrollbar.ysl2 +widget_desc("ScrollBar") { + longdesc + || + ScrollBar - documentation to be written + || + + shortdesc > ScrollBar + + path name="value" accepts="HMI_INT" > value + path name="range" accepts="HMI_INT" > range + path name="visible" accepts="HMI_INT" > visible + +} + +widget_class("ScrollBar") { + || + frequency = 10; + position = undefined; + range = undefined; + size = undefined; + mincursize = 0.1; + + dispatch(value,oldval, index) { + switch(index) { + case 0: + this.range = Math.max(1,value); + break; + case 1: + this.position = value; + break; + case 2: + this.size = value; + break; + } + + this.request_animate(); + } + + get_ratios() { + let range = this.range; + let size = Math.max(this.range * this.mincursize, Math.min(this.size, range)); + let maxh = this.range_elt.height.baseVal.value; + let pixels = maxh; + let units = range; + return [size, maxh, range, pixels, units]; + } + + animate(){ + if(this.position == undefined || this.range == undefined || this.size == undefined) + return; + let [size, maxh, range, pixels, units] = this.get_ratios(); + + let new_y = this.range_elt.y.baseVal.value + Math.round(Math.min(this.position,range-size) * pixels / units); + let new_height = Math.round(maxh * size/range); + + this.cursor_elt.y.baseVal.value = new_y; + this.cursor_elt.height.baseVal.value = new_height; + } + + init_mandatory() { + this.cursor_elt.onpointerdown = () => this.on_cursor_down(); + + this.bound_drag = this.drag.bind(this); + this.bound_drop = this.drop.bind(this); + } + + apply_position(position){ + this.position = Math.round(Math.max(Math.min(position, this.range - this.size), 0)); + this.apply_hmi_value(1, this.position); + } + + on_page_click(is_up){ + this.apply_position(is_up ? this.position-this.size + : this.position+this.size); + } + + on_cursor_down(e){ + // get scrollbar -> root transform + let ctm = this.range_elt.getCTM(); + // relative motion -> discard translation + ctm.e = 0; + ctm.f = 0; + // root -> scrollbar transform + this.invctm = ctm.inverse(); + svg_root.addEventListener("pointerup", this.bound_drop, true); + svg_root.addEventListener("pointermove", this.bound_drag, true); + this.dragpos = this.position; + } + + drop(e) { + svg_root.removeEventListener("pointerup", this.bound_drop, true); + svg_root.removeEventListener("pointermove", this.bound_drag, true); + } + + drag(e) { + let [size, maxh, range, pixels, units] = this.get_ratios(); + if(pixels == 0) return; + let point = new DOMPoint(e.movementX, e.movementY); + let movement = point.matrixTransform(this.invctm).y; + this.dragpos += movement * units / pixels; + this.apply_position(this.dragpos); + } + || +} + +widget_defs("ScrollBar") { + labels("cursor range"); + + const "pagebuttons" optional_labels("pageup pagedown"); + const "have_pagebuttons","string-length($pagebuttons)>0"; + value "$pagebuttons"; + + | init: function() { + | this.init_mandatory(); + + if "$have_pagebuttons" { + | this.pageup_elt.onclick = () => this.on_page_click(true); + | this.pagedown_elt.onclick = () => this.on_page_click(false); + } + | }, +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_slider.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_slider.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,357 @@ +// widget_slider.ysl2 + +widget_desc("Slider") { + longdesc + || + Slider - DEPRECATED - use ScrollBar or PathSlider instead + || + + shortdesc > Slider - DEPRECATED - use ScrollBar instead + + path name="value" accepts="HMI_INT" > value + path name="range" accepts="HMI_INT" > range + path name="visible" accepts="HMI_INT" > visible + +} + +widget_class("Slider") + || + frequency = 5; + range = undefined; + handle_orig = undefined; + scroll_size = undefined; + scroll_range = 0; + scroll_visible = 7; + min_size = 0.07; + fi = undefined; + curr_value = 0; + drag = false; + enTimer = false; + handle_click = undefined; + last_drag = false; + + dispatch(value,oldval, index) { + if (index == 0){ + let [min,max,start,totallength] = this.range; + //save current value inside widget + this.curr_value = value; + + //check if in range + if (this.curr_value > max){ + this.curr_value = max; + this.apply_hmi_value(0, this.curr_value); + } + else if (this.curr_value < min){ + this.curr_value = min; + this.apply_hmi_value(0, this.curr_value); + } + + if(this.value_elt) + this.value_elt.textContent = String(value); + } + else if(index == 1){ + this.scroll_range = value; + this.set_scroll(); + } + else if(index == 2){ + this.scroll_visible = value; + this.set_scroll(); + } + + //don't update if draging and setpoint ghost doesn't exist + if(!this.drag || (this.setpoint_elt != undefined)){ + this.update_DOM(this.curr_value, this.handle_elt); + } + } + + set_scroll(){ + //check if range is bigger than visible and set scroll size + if(this.scroll_range > this.scroll_visible){ + this.scroll_size = this.scroll_range - this.scroll_visible; + this.range[0] = 0; + this.range[1] = this.scroll_size; + } + else{ + this.scroll_size = 1; + this.range[0] = 0; + this.range[1] = 1; + } + } + + update_DOM(value, elt){ + let [min,max,start,totallength] = this.range; + // check if handle is resizeable + if (this.scroll_size != undefined){ //size changes + //get parameters + let length = Math.max(min,Math.min(max,(Number(value)-min)*max/(max-min))); + let tip = this.range_elt.getPointAtLength(length); + let handle_min = totallength*this.min_size; + + let step = 1; + //check if range is bigger than max displayed and recalculate step + if ((totallength/handle_min) < (max-min+1)){ + step = (max-min+1)/(totallength/handle_min-1); + } + + let kx,ky,offseY,offseX = undefined; + //scale on x or y axes + if (this.fi > 0.75){ + //get scale factor + if(step > 1){ + ky = handle_min/this.handle_orig.height; + } + else{ + ky = (totallength-handle_min*(max-min))/this.handle_orig.height; + } + kx = 1; + //get 0 offset to stay inside range + offseY = start.y - (this.handle_orig.height + this.handle_orig.y) * ky; + offseX = 0; + //get distance from value + tip.y =this.range_elt.getPointAtLength(0).y - length/step *handle_min; + } + else{ + //get scale factor + if(step > 1){ + kx = handle_min/this.handle_orig.width; + } + else{ + kx = (totallength-handle_min*(max-min))/this.handle_orig.width; + } + ky = 1; + //get 0 offset to stay inside range + offseX = start.x - (this.handle_orig.x * kx); + offseY = 0; + //get distance from value + tip.x =this.range_elt.getPointAtLength(0).x + length/step *handle_min; + } + elt.setAttribute('transform',"matrix("+(kx)+" 0 0 "+(ky)+" "+(tip.x-start.x+offseX)+" "+(tip.y-start.y+offseY)+")"); + } + else{ //size stays the same + let length = Math.max(0,Math.min(totallength,(Number(value)-min)*totallength/(max-min))); + let tip = this.range_elt.getPointAtLength(length); + elt.setAttribute('transform',"translate("+(tip.x-start.x)+","+(tip.y-start.y)+")"); + } + + // show or hide ghost if exists + if(this.setpoint_elt != undefined){ + if(this.last_drag!= this.drag){ + if(this.drag){ + this.setpoint_elt.setAttribute("style", this.setpoint_style); + }else{ + this.setpoint_elt.setAttribute("style", "display:none"); + } + this.last_drag = this.drag; + } + } + } + + on_release(evt) { + //unbind events + window.removeEventListener("touchmove", this.on_bound_drag, true); + window.removeEventListener("mousemove", this.on_bound_drag, true); + + window.removeEventListener("mouseup", this.bound_on_release, true); + window.removeEventListener("touchend", this.bound_on_release, true); + window.removeEventListener("touchcancel", this.bound_on_release, true); + + //reset drag flag + if(this.drag){ + this.drag = false; + } + + // get final position + this.update_position(evt); + + } + + on_drag(evt){ + //ignore drag event for X amount of time and if not selected + if(this.enTimer && this.drag){ + this.update_position(evt); + + //reset timer + this.enTimer = false; + setTimeout("{hmi_widgets['"+this.element_id+"'].enTimer = true;}", 100); + } + } + + update_position(evt){ + var html_dist = 0; + let [min,max,start,totallength] = this.range; + + //calculate size of widget in html + var range_borders = this.range_elt.getBoundingClientRect(); + var [minX,minY,maxX,maxY] = [range_borders.left,range_borders.bottom,range_borders.right,range_borders.top]; + var range_length = Math.sqrt( range_borders.height*range_borders.height + range_borders.width*range_borders.width ); + + //get range and mouse coordinates + var mouseX = undefined; + var mouseY = undefined; + if (evt.type.startsWith("touch")){ + mouseX = Math.ceil(evt.touches[0].clientX); + mouseY = Math.ceil(evt.touches[0].clientY); + } + else{ + mouseX = evt.pageX; + mouseY = evt.pageY; + } + + // calculate position + if (this.handle_click){ //if clicked on handle + let moveDist = 0, resizeAdd = 0; + let range_percent = 1; + + //set paramters for resizeable handle + if (this.scroll_size != undefined){ + // add one more object to stay inside range + resizeAdd = 1; + + //chack if range is bigger than display option and + // calculate percent of range with out handle + if(((max/(max*this.min_size)) < (max-min+1))){ + range_percent = 1-this.min_size; + } + else{ + range_percent = 1-(max-max*this.min_size*(max-min))/max; + } + } + + //calculate value difference on x or y axis + if(this.fi > 0.7){ + moveDist = ((max-min+resizeAdd)/(range_length*range_percent))*((this.handle_click[1]-mouseY)/Math.sin(this.fi)); + } + else{ + moveDist = ((max-min+resizeAdd)/(range_length*range_percent))*((mouseX-this.handle_click[0])/Math.cos(this.fi)); + } + + this.curr_value = Math.ceil(this.handle_click[2] + moveDist); + } + else{ //if clicked on widget + //get handle distance from mouse position + if (minX > mouseX && minY < mouseY){ + html_dist = 0; + } + else if (maxX < mouseX && maxY > mouseY){ + html_dist = range_length; + } + else{ + if(this.fi > 0.7){ + html_dist = (minY - mouseY)/Math.sin(this.fi); + } + else{ + html_dist = (mouseX - minX)/Math.cos(this.fi); + } + } + //calculate distance + this.curr_value=Math.ceil((html_dist/range_length)*(this.range[1]-this.range[0])+this.range[0]); + } + + //check if in range and apply + if (this.curr_value > max){ + this.curr_value = max; + } + else if (this.curr_value < min){ + this.curr_value = min; + } + this.apply_hmi_value(0, this.curr_value); + + //redraw handle + this.request_animate(); + + } + + animate(){ + // redraw handle on screen refresh + // check if setpoint(ghost) handle exsist otherwise update main handle + if(this.setpoint_elt != undefined){ + this.update_DOM(this.curr_value, this.setpoint_elt); + } + else{ + this.update_DOM(this.curr_value, this.handle_elt); + } + } + + on_select(evt){ + //enable drag flag and timer + this.drag = true; + this.enTimer = true; + + //bind events + window.addEventListener("touchmove", this.on_bound_drag, true); + window.addEventListener("mousemove", this.on_bound_drag, true); + + window.addEventListener("mouseup", this.bound_on_release, true); + window.addEventListener("touchend", this.bound_on_release, true); + window.addEventListener("touchcancel", this.bound_on_release, true); + + // check if handle was pressed + if (evt.currentTarget == this.handle_elt){ + //get mouse position on the handle + let mouseX = undefined; + let mouseY = undefined; + if (evt.type.startsWith("touch")){ + mouseX = Math.ceil(evt.touches[0].clientX); + mouseY = Math.ceil(evt.touches[0].clientY); + } + else{ + mouseX = evt.pageX; + mouseY = evt.pageY; + } + //save coordinates and orig value + this.handle_click = [mouseX,mouseY,this.curr_value]; + } + else{ + // get new handle position and reset if handle was not pressed + this.handle_click = undefined; + this.update_position(evt); + } + + //prevent next events + evt.stopPropagation(); + + } + + + init() { + //set min max value if not defined + let min = this.min_elt ? + Number(this.min_elt.textContent) : + this.args.length >= 1 ? this.args[0] : 0; + let max = this.max_elt ? + Number(this.max_elt.textContent) : + this.args.length >= 2 ? this.args[1] : 100; + + + // save initial parameters + this.range_elt.style.strokeMiterlimit="0"; + this.range = [min, max, this.range_elt.getPointAtLength(0),this.range_elt.getTotalLength()]; + let start = this.range_elt.getPointAtLength(0); + let end = this.range_elt.getPointAtLength(this.range_elt.getTotalLength()); + this.fi = Math.atan2(start.y-end.y, end.x-start.x); + this.handle_orig = this.handle_elt.getBBox(); + + //bind functions + this.bound_on_select = this.on_select.bind(this); + this.bound_on_release = this.on_release.bind(this); + this.on_bound_drag = this.on_drag.bind(this); + + this.handle_elt.addEventListener("mousedown", this.bound_on_select); + this.element.addEventListener("mousedown", this.bound_on_select); + this.element.addEventListener("touchstart", this.bound_on_select); + //touch recognised as page drag without next command + document.body.addEventListener("touchstart", function(e){}, false); + + //save ghost style + if(this.setpoint_elt != undefined){ + this.setpoint_style = this.setpoint_elt.getAttribute("style"); + this.setpoint_elt.setAttribute("style", "display:none"); + } + + } + || + +widget_defs("Slider") { + labels("handle range"); + optional_labels("value min max setpoint"); +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_switch.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_switch.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,55 @@ +// widget_switch.ysl2 + +widget_desc("Switch") { + longdesc + || + Switch widget hides all subelements whose label do not match given + variable current value representation. For exemple if given variable type + is HMI_INT and value is 1, then elements with label '1' will be displayed. + Label can have comments, so '1#some comment' would also match. If matching + variable of type HMI_STRING, then double quotes must be used. For exemple, + '"hello"' or '"hello"#another comment' match HMI_STRING 'hello'. + || + + shortdesc > Show elements whose label match value. + + // TODO: add optional format/precision argument to support floating points + // TODO: support (in)equations and ranges + + path name="value" accepts="HMI_INT,HMI_STRING" > value to compare to labels + +} + +widget_class("Switch") + || + frequency = 5; + dispatch(value) { + for(let choice of this.choices){ + if(value != choice.value){ + choice.elt.setAttribute("style", "display:none"); + } else { + choice.elt.setAttribute("style", choice.style); + } + } + } + || + +widget_defs("Switch") { + | choices: [ + const "regex",!"'^(\"[^\"].*\"|\-?[0-9]+|false|true)(#.*)?$'"!; + + const "subelts", "$result_widgets[@id = $hmi_element/@id]//*"; + const "subwidgets", "$subelts//*[@id = $hmi_widgets/@id]"; + const "accepted", "$subelts[not(ancestor-or-self::*/@id = $subwidgets/@id)]"; + + foreach "$accepted[regexp:test(@inkscape:label,$regex)]" { + const "literal", "regexp:match(@inkscape:label,$regex)[2]"; + | { + | elt:id("«@id»"), + // TODO : use style.display = "none" to hide element + | style:"«@style»", + | value:«$literal» + | }`if "position()!=last()" > ,` + } + | ], +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widget_tooglebutton.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_tooglebutton.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,62 @@ +// widget_tooglebutton.ysl2 + + +widget_desc("ToggleButton") { + longdesc + || + Button widget takes one boolean variable path, and reflect current true + or false value by showing "active" or "inactive" labeled element + respectively. Clicking or touching button toggles variable. + || + + shortdesc > Toggle button reflecting given boolean variable + + path name="value" accepts="HMI_BOOL" > Boolean variable + +} + +widget_class("ToggleButton") { + || + frequency = 5; + state = 0; + active_style = undefined; + inactive_style = undefined; + + dispatch(value) { + this.state = value; + //redraw toggle button + this.request_animate(); + } + + on_click(evt) { + //toggle state and apply + this.state = this.state ? false : true; + this.apply_hmi_value(0, this.state); + + //redraw toggle button + this.request_animate(); + } + + activate(val) { + let [active, inactive] = val ? ["none",""] : ["", "none"]; + if (this.active_elt) + this.active_elt.style.display = active; + if (this.inactive_elt) + this.inactive_elt.style.display = inactive; + } + + animate(){ + // redraw toggle button on screen refresh + this.activate(this.state); + } + + init() { + this.activate(false); + this.element.onclick = (evt) => this.on_click(evt); + } + || +} + +widget_defs("ToggleButton") { + optional_labels("active inactive"); +} diff -r 577118ebd179 -r c89fc366bebd svghmi/widgetlib/keypads/alphanumeric_keypad.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/keypads/alphanumeric_keypad.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,931 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + Q + + + + W + + + + E + + + + R + + + + T + + + + Y + + + + U + + + + I + + + + O + + + + P + + + + A + + + + S + + + + D + + + + F + + + + G + + + + H + + + + J + + + + K + + + + L + + + + Z + + + + X + + + + C + + + + V + + + + B + + + + N + + + + M + + + + . + : + + + + ; + , + + + + 1 + + + + 2 + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + - + + + + 0 + + + + + + + Esc + + + + + + + + + + + + + Caps + Lock + + + + Caps + Lock + + + + text + + + + Shift + + Shift + + + + Shift + + Shift + + + information + + diff -r 577118ebd179 -r c89fc366bebd svghmi/widgetlib/keypads/numeric_keypad.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/keypads/numeric_keypad.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,348 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + number + + + + + + + + 7 + + + + 4 + + + + 1 + + + + 8 + + + + 5 + + + + 2 + + + + 9 + + + + 6 + + + + 3 + + + + 0 + + + + + Esc + + + + + + + + +/- + + information + + + . + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/widgetlib/meter_template.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/meter_template.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + A sophisticated meter looking like real + + + 0 + 10000 + [value] + + diff -r 577118ebd179 -r c89fc366bebd svghmi/widgetlib/modern_knob_1.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/modern_knob_1.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,267 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + 65% + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/widgetlib/rounded_scrollbar.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/rounded_scrollbar.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,95 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/widgetlib/spinctrl.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/spinctrl.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,130 @@ + + + + + + + + + + image/svg+xml + + + + + + + 8 + + + + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/widgetlib/voltmeter.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/voltmeter.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,793 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Old Style Russian Voltmeter, from openclipart https://openclipart.org/detail/205486/voltmeter-and-ammeter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd svghmi/widgets_common.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgets_common.ysl2 Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,438 @@ +// widgets_common.ysl2 + +in xsl decl labels(*ptr, name="defs_by_labels") alias call-template { + with "hmi_element", "$hmi_element"; + with "labels"{text *ptr}; + content; +}; + +decl optional_labels(*ptr) alias - { + /* TODO add some per label xslt variable to check if exist */ + labels(*ptr){ + with "mandatory","'no'"; + content; + } +}; + +decl activable_labels(*ptr) alias - { + optional_labels(*ptr) { + with "subelements","'active inactive'"; + content; + } +}; + +in xsl decl widget_desc(%name, match="widget[@type='%name']", mode="widget_desc") alias template { + type > «@type» + content; +}; + +in xsl decl widget_class(%name, *clsname="%nameWidget", match="widget[@type='%name']", mode="widget_class") alias template { + | class `text **clsname` extends Widget{ + content; + | } +}; + +in xsl decl widget_defs(%name, match="widget[@type='%name']", mode="widget_defs") alias template { + param "hmi_element"; + content; +}; + +in xsl decl widget_page(%name, match="widget[@type='%name']", mode="widget_page") alias template { + param "page_desc"; + content; +}; + +decl gen_index_xhtml alias - { + content; +}; + +template "svg:*", mode="hmi_widgets" { + const "widget", "func:widget(@id)"; + const "eltid","@id"; + const "args" foreach "$widget/arg" > "«func:escape_quotes(@value)»"`if "position()!=last()" > ,` + const "indexes" foreach "$widget/path" { + choose { + when "not(@index)" { + choose { + when "not(@type)" { + warning > Widget «$widget/@type» id="«$eltid»" : No match for path "«@value»" in HMI tree + > undefined + } + when "@type = 'PAGE_LOCAL'" + > "«@value»" + when "@type = 'HMI_LOCAL'" + > hmi_local_index("«@value»") + otherwise + error > Internal error while processing widget's non indexed HMI tree path : unknown type + } + } + otherwise { + > «@index» + } + } + if "position()!=last()" > , + } + + const "minmaxes" foreach "$widget/path" { + choose { + when "@min and @max" + > [«@min»,«@max»] + otherwise + > undefined + } + if "position()!=last()" > , + } + + | "«@id»": new «$widget/@type»Widget ("«@id»",[«$args»],[«$indexes»],[«$minmaxes»],{ + apply "$widget", mode="widget_defs" with "hmi_element","."; + | })`if "position()!=last()" > ,` +} + +emit "preamble:local-variable-indexes" { + || + + let hmi_locals = {}; + var last_remote_index = hmitree_types.length - 1; + var next_available_index = hmitree_types.length; + let cookies = new Map(document.cookie.split("; ").map(s=>s.split("="))); + + const local_defaults = { + || + foreach "$parsed_widgets/widget[starts-with(@type,'VarInit')]"{ + if "count(path) != 1" error > VarInit «@id» must have only one variable given. + if "path/@type != 'PAGE_LOCAL' and path/@type != 'HMI_LOCAL'" error > VarInit «@id» only applies to HMI variable. + > "«path/@value»": + choose { + when "@type = 'VarInitPersistent'" > cookies.has("«path/@value»")?cookies.get("«path/@value»"):«arg[1]/@value» + otherwise > «arg[1]/@value» + } + > \n + if "position()!=last()" > , + } + || + }; + + const persistent_locals = new Set([ + || + foreach "$parsed_widgets/widget[@type='VarInitPersistent']"{ + | "«path/@value»"`if "position()!=last()" > ,` + } + || + ]); + var persistent_indexes = new Map(); + var cache = hmitree_types.map(_ignored => undefined); + var updates = new Map(); + + function page_local_index(varname, pagename){ + let pagevars = hmi_locals[pagename]; + let new_index; + if(pagevars == undefined){ + new_index = next_available_index++; + hmi_locals[pagename] = {[varname]:new_index} + } else { + let result = pagevars[varname]; + if(result != undefined) { + return result; + } + + new_index = next_available_index++; + pagevars[varname] = new_index; + } + let defaultval = local_defaults[varname]; + if(defaultval != undefined) { + cache[new_index] = defaultval; + updates.set(new_index, defaultval); + if(persistent_locals.has(varname)) + persistent_indexes.set(new_index, varname); + } + return new_index; + } + + function hmi_local_index(varname){ + return page_local_index(varname, "HMI_LOCAL"); + } + || +} + +emit "preamble:widget-base-class" { + || + var pending_widget_animates = []; + + class Widget { + offset = 0; + frequency = 10; /* FIXME arbitrary default max freq. Obtain from config ? */ + unsubscribable = false; + pending_animate = false; + + constructor(elt_id,args,indexes,minmaxes,members){ + this.element_id = elt_id; + this.element = id(elt_id); + this.args = args; + this.indexes = indexes; + this.minmaxes = minmaxes; + Object.keys(members).forEach(prop => this[prop]=members[prop]); + this.lastapply = indexes.map(() => undefined); + this.inhibit = indexes.map(() => undefined); + this.pending = indexes.map(() => undefined); + this.bound_unhinibit = this.unhinibit.bind(this); + } + + unsub(){ + /* remove subsribers */ + if(!this.unsubscribable) + for(let i = 0; i < this.indexes.length; i++) { + /* flush updates pending because of inhibition */ + let inhibition = this.inhibit[i]; + if(inhibition != undefined){ + clearTimeout(inhibition); + this.lastapply[i] = undefined; + this.unhinibit(i); + } + let index = this.indexes[i]; + if(this.relativeness[i]) + index += this.offset; + subscribers(index).delete(this); + } + this.offset = 0; + this.relativeness = undefined; + } + + sub(new_offset=0, relativeness, container_id){ + this.offset = new_offset; + this.relativeness = relativeness; + this.container_id = container_id ; + /* add this's subsribers */ + if(!this.unsubscribable) + for(let i = 0; i < this.indexes.length; i++) { + let index = this.get_variable_index(i); + if(index == undefined) continue; + subscribers(index).add(this); + } + need_cache_apply.push(this); + } + + apply_cache() { + if(!this.unsubscribable) for(let index in this.indexes){ + /* dispatch current cache in newly opened page widgets */ + let realindex = this.get_variable_index(index); + if(realindex == undefined) continue; + let cached_val = cache[realindex]; + if(cached_val != undefined) + this._dispatch(cached_val, cached_val, index); + } + } + + get_variable_index(varnum) { + let index = this.indexes[varnum]; + if(typeof(index) == "string"){ + index = page_local_index(index, this.container_id); + } else { + if(this.relativeness[varnum]){ + index += this.offset; + } + } + return index; + } + + overshot(new_val, max) { + } + + undershot(new_val, min) { + } + + clip_min_max(index, new_val) { + let minmax = this.minmaxes[index]; + if(minmax !== undefined && typeof new_val == "number") { + let [min,max] = minmax; + if(new_val < min){ + this.undershot(new_val, min); + return min; + } + if(new_val > max){ + this.overshot(new_val, max); + return max; + } + } + return new_val; + } + + change_hmi_value(index, opstr) { + let realindex = this.get_variable_index(index); + if(realindex == undefined) return undefined; + let old_val = cache[realindex]; + let new_val = eval_operation_string(old_val, opstr); + new_val = this.clip_min_max(index, new_val); + return apply_hmi_value(realindex, new_val); + } + + _apply_hmi_value(index, new_val) { + let realindex = this.get_variable_index(index); + if(realindex == undefined) return undefined; + new_val = this.clip_min_max(index, new_val); + return apply_hmi_value(realindex, new_val); + } + + unhinibit(index){ + this.inhibit[index] = undefined; + let new_val = this.pending[index]; + this.pending[index] = undefined; + return this.apply_hmi_value(index, new_val); + } + + apply_hmi_value(index, new_val) { + if(this.inhibit[index] == undefined){ + let now = Date.now(); + let min_interval = 1000/this.frequency; + let lastapply = this.lastapply[index]; + if(lastapply == undefined || now > lastapply + min_interval){ + this.lastapply[index] = now; + return this._apply_hmi_value(index, new_val); + } + else { + let elapsed = now - lastapply; + this.pending[index] = new_val; + this.inhibit[index] = setTimeout(this.bound_unhinibit, min_interval - elapsed, index); + } + } + else { + this.pending[index] = new_val; + return new_val; + } + } + + new_hmi_value(index, value, oldval) { + // TODO avoid searching, store index at sub() + for(let i = 0; i < this.indexes.length; i++) { + let refindex = this.get_variable_index(i); + if(refindex == undefined) continue; + + if(index == refindex) { + this._dispatch(value, oldval, i); + break; + } + } + } + + _dispatch(value, oldval, varnum) { + let dispatch = this.dispatch; + if(dispatch != undefined){ + try { + dispatch.call(this, value, oldval, varnum); + } catch(err) { + console.log(err); + } + } + } + + _animate(){ + this.animate(); + this.pending_animate = false; + } + + request_animate(){ + if(!this.pending_animate){ + pending_widget_animates.push(this); + this.pending_animate = true; + requestHMIAnimation(); + } + + } + + activate_activable(eltsub) { + eltsub.inactive.style.display = "none"; + eltsub.active.style.display = ""; + } + + inactivate_activable(eltsub) { + eltsub.active.style.display = "none"; + eltsub.inactive.style.display = ""; + } + } + || +} + +const "excluded_types", "str:split('Page VarInit VarInitPersistent')"; + +// Key to filter unique types +key "TypesKey", "widget", "@type"; + +emit "declarations:hmi-classes" { + const "used_widget_types", """$parsed_widgets/widget[ + generate-id() = generate-id(key('TypesKey', @type)) and + not(@type = $excluded_types)]"""; + apply "$used_widget_types", mode="widget_class"; +} + +template "widget", mode="widget_class" +|| +class «@type»Widget extends Widget{ + /* empty class, as «@type» widget didn't provide any */ +} +|| + +const "included_ids","$parsed_widgets/widget[not(@type = $excluded_types) and not(@id = $discardable_elements/@id)]/@id"; +const "hmi_widgets","$hmi_elements[@id = $included_ids]"; +const "result_widgets","$result_svg_ns//*[@id = $hmi_widgets/@id]"; + +emit "declarations:hmi-elements" { + | var hmi_widgets = { + apply "$hmi_widgets", mode="hmi_widgets"; + | } +} + +function "defs_by_labels" { + param "labels","''"; + param "mandatory","'yes'"; + param "subelements","/.."; + param "hmi_element"; + const "widget_type","@type"; + foreach "str:split($labels)" { + const "name","."; + const "elt","$result_widgets[@id = $hmi_element/@id]//*[@inkscape:label=$name][1]"; + choose { + when "not($elt/@id)" { + if "$mandatory='yes'" { + error > «$widget_type» widget must have a «$name» element + } + // otherwise produce nothing + } + otherwise { + | «$name»_elt: id("«$elt/@id»"), + if "$subelements" { + | «$name»_sub: { + foreach "str:split($subelements)" { + const "subname","."; + const "subelt","$elt/*[@inkscape:label=$subname][1]"; + choose { + when "not($subelt/@id)" { + if "$mandatory='yes'" { + error > «$widget_type» widget must have a «$name»/«$subname» element + } + | /* missing «$name»/«$subname» element */ + } + otherwise { + | "«$subname»": id("«$subelt/@id»")`if "position()!=last()" > ,` + } + } + } + | }, + } + } + } + } +} + +def "func:escape_quotes" { + param "txt"; + // have to use a python string to enter escaped quote + // const "frstln", "string-length($frst)"; + choose { + when !"contains($txt,'\"')"! { + result !"concat(substring-before($txt,'\"'),'\\\"',func:escape_quotes(substring-after($txt,'\"')))"!; + } + otherwise { + result "$txt"; + } + } +} + diff -r 577118ebd179 -r c89fc366bebd targets/Linux/plc_Linux_main.c --- a/targets/Linux/plc_Linux_main.c Wed Jun 30 15:44:32 2021 +0200 +++ b/targets/Linux/plc_Linux_main.c Thu Sep 02 21:36:29 2021 +0200 @@ -235,3 +235,57 @@ { pthread_mutex_lock(&python_mutex); } + +struct RT_to_nRT_signal_s { + pthread_cond_t WakeCond; + pthread_mutex_t WakeCondLock; +}; + +typedef struct RT_to_nRT_signal_s RT_to_nRT_signal_t; + +#define _LogAndReturnNull(text) \ + {\ + char mstr[256] = text " for ";\ + strncat(mstr, name, 255);\ + LogMessage(LOG_CRITICAL, mstr, strlen(mstr));\ + return NULL;\ + } + +void *create_RT_to_nRT_signal(char* name){ + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)malloc(sizeof(RT_to_nRT_signal_t)); + + if(!sig) + _LogAndReturnNull("Failed allocating memory for RT_to_nRT signal"); + + pthread_cond_init(&sig->WakeCond, NULL); + pthread_mutex_init(&sig->WakeCondLock, NULL); + + return (void*)sig; +} + +void delete_RT_to_nRT_signal(void* handle){ + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)handle; + + pthread_cond_destroy(&sig->WakeCond); + pthread_mutex_destroy(&sig->WakeCondLock); + + free(sig); +} + +int wait_RT_to_nRT_signal(void* handle){ + int ret; + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)handle; + pthread_mutex_lock(&sig->WakeCondLock); + ret = pthread_cond_wait(&sig->WakeCond, &sig->WakeCondLock); + pthread_mutex_unlock(&sig->WakeCondLock); + return ret; +} + +int unblock_RT_to_nRT_signal(void* handle){ + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)handle; + return pthread_cond_signal(&sig->WakeCond); +} + +void nRT_reschedule(void){ + sched_yield(); +} diff -r 577118ebd179 -r c89fc366bebd targets/Win32/plc_Win32_main.c --- a/targets/Win32/plc_Win32_main.c Wed Jun 30 15:44:32 2021 +0200 +++ b/targets/Win32/plc_Win32_main.c Thu Sep 02 21:36:29 2021 +0200 @@ -26,10 +26,10 @@ return res; } -struct _timeb timetmp; +struct timeb timetmp; void PLC_GetTime(IEC_TIME *CURRENT_TIME) { - _ftime(&timetmp); + ftime(&timetmp); (*CURRENT_TIME).tv_sec = timetmp.time; (*CURRENT_TIME).tv_nsec = timetmp.millitm * 1000000; @@ -262,3 +262,63 @@ DeleteCriticalSection(&Atomic64CS); } +struct RT_to_nRT_signal_s { + HANDLE sem; +}; + +typedef struct RT_to_nRT_signal_s RT_to_nRT_signal_t; + +#define _LogAndReturnNull(text) \ + {\ + char mstr[256] = text " for ";\ + strncat(mstr, name, 255);\ + LogMessage(LOG_CRITICAL, mstr, strlen(mstr));\ + return NULL;\ + } + +void *create_RT_to_nRT_signal(char* name){ + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)malloc(sizeof(RT_to_nRT_signal_t)); + + if(!sig) + _LogAndReturnNull("Failed allocating memory for RT_to_nRT signal"); + + sig->sem = CreateSemaphore( + NULL, // default security attributes + 1, // initial count + 1, // maximum count + NULL); // unnamed semaphore + + if(sig->sem == NULL) + { + char mstr[256]; + snprintf(mstr, 255, "startPLC CreateSemaphore %s error: %d\n", name, GetLastError()); + LogMessage(LOG_CRITICAL, mstr, strlen(mstr)); + return NULL; + } + + return (void*)sig; +} + +void delete_RT_to_nRT_signal(void* handle){ + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)handle; + + CloseHandle(python_sem); + + free(sig); +} + +int wait_RT_to_nRT_signal(void* handle){ + int ret; + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)handle; + return WaitForSingleObject(sig->sem, INFINITE); +} + +int unblock_RT_to_nRT_signal(void* handle){ + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)handle; + return ReleaseSemaphore(sig->sem, 1, NULL); +} + +void nRT_reschedule(void){ + SwitchToThread(); +} + diff -r 577118ebd179 -r c89fc366bebd targets/Xenomai/__init__.py --- a/targets/Xenomai/__init__.py Wed Jun 30 15:44:32 2021 +0200 +++ b/targets/Xenomai/__init__.py Thu Sep 02 21:36:29 2021 +0200 @@ -37,7 +37,7 @@ if xeno_config: from util.ProcessLogger import ProcessLogger status, result, _err_result = ProcessLogger(self.CTRInstance.logger, - xeno_config + " --skin=posix --skin=alchemy --no-auto-init --"+flagsname, + xeno_config + " --skin=posix --skin=alchemy "+flagsname, no_stdout=True).spin() if status: self.CTRInstance.logger.write_error(_("Unable to get Xenomai's %s \n") % flagsname) @@ -45,9 +45,9 @@ return [] def getBuilderLDFLAGS(self): - xeno_ldflags = self.getXenoConfig("ldflags") + xeno_ldflags = self.getXenoConfig("--no-auto-init --ldflags") return toolchain_gcc.getBuilderLDFLAGS(self) + xeno_ldflags + ["-shared"] def getBuilderCFLAGS(self): - xeno_cflags = self.getXenoConfig("cflags") + xeno_cflags = self.getXenoConfig("--cflags") return toolchain_gcc.getBuilderCFLAGS(self) + xeno_cflags + ["-fPIC"] diff -r 577118ebd179 -r c89fc366bebd targets/Xenomai/plc_Xenomai_main.c --- a/targets/Xenomai/plc_Xenomai_main.c Wed Jun 30 15:44:32 2021 +0200 +++ b/targets/Xenomai/plc_Xenomai_main.c Thu Sep 02 21:36:29 2021 +0200 @@ -18,24 +18,12 @@ unsigned int PLC_state = 0; #define PLC_STATE_TASK_CREATED 1 -#define PLC_STATE_DEBUG_FILE_OPENED 2 -#define PLC_STATE_DEBUG_PIPE_CREATED 4 -#define PLC_STATE_PYTHON_FILE_OPENED 8 -#define PLC_STATE_PYTHON_PIPE_CREATED 16 -#define PLC_STATE_WAITDEBUG_FILE_OPENED 32 -#define PLC_STATE_WAITDEBUG_PIPE_CREATED 64 -#define PLC_STATE_WAITPYTHON_FILE_OPENED 128 -#define PLC_STATE_WAITPYTHON_PIPE_CREATED 256 - -#define WAITDEBUG_PIPE_DEVICE "/dev/rtp0" -#define WAITDEBUG_PIPE_MINOR 0 -#define DEBUG_PIPE_DEVICE "/dev/rtp1" -#define DEBUG_PIPE_MINOR 1 -#define WAITPYTHON_PIPE_DEVICE "/dev/rtp2" -#define WAITPYTHON_PIPE_MINOR 2 -#define PYTHON_PIPE_DEVICE "/dev/rtp3" -#define PYTHON_PIPE_MINOR 3 -#define PIPE_SIZE 1 +#define PLC_STATE_DEBUG_PIPE_CREATED 2 +#define PLC_STATE_PYTHON_PIPE_CREATED 8 +#define PLC_STATE_WAITDEBUG_PIPE_CREATED 16 +#define PLC_STATE_WAITPYTHON_PIPE_CREATED 32 + +#define PIPE_SIZE 1 // rt-pipes commands @@ -64,14 +52,36 @@ } RT_TASK PLC_task; -RT_PIPE WaitDebug_pipe; -RT_PIPE WaitPython_pipe; -RT_PIPE Debug_pipe; -RT_PIPE Python_pipe; -int WaitDebug_pipe_fd; -int WaitPython_pipe_fd; -int Debug_pipe_fd; -int Python_pipe_fd; +void *WaitDebug_handle; +void *WaitPython_handle; +void *Debug_handle; +void *Python_handle; +void *svghmi_handle; + +struct RT_to_nRT_signal_s { + int used; + RT_PIPE pipe; + int pipe_fd; + char *name; +}; +typedef struct RT_to_nRT_signal_s RT_to_nRT_signal_t; + +#define max_RT_to_nRT_signals 16 + +static RT_to_nRT_signal_t RT_to_nRT_signal_pool[max_RT_to_nRT_signals]; + +int recv_RT_to_nRT_signal(void* handle, char* payload){ + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)handle; + if(!sig->used) return -EINVAL; + return read(sig->pipe_fd, payload, 1); +} + +int send_RT_to_nRT_signal(void* handle, char payload){ + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)handle; + if(!sig->used) return -EINVAL; + return rt_pipe_write(&sig->pipe, &payload, 1, P_NORMAL); +} + int PLC_shutdown = 0; @@ -91,22 +101,94 @@ if (PLC_shutdown) break; rt_task_wait_period(NULL); } - /* since xenomai 3 it is not enough to close() + /* since xenomai 3 it is not enough to close() file descriptor to unblock read()... */ { /* explicitely finish python thread */ char msg = PYTHON_FINISH; - rt_pipe_write(&WaitPython_pipe, &msg, sizeof(msg), P_NORMAL); + send_RT_to_nRT_signal(WaitPython_handle, msg); } { /* explicitely finish debug thread */ char msg = DEBUG_FINISH; - rt_pipe_write(&WaitDebug_pipe, &msg, sizeof(msg), P_NORMAL); + send_RT_to_nRT_signal(WaitDebug_handle, msg); } } static unsigned long __debug_tick; +#define _LogAndReturnNull(text) \ + {\ + char mstr[256] = text " for ";\ + strncat(mstr, name, 255);\ + LogMessage(LOG_CRITICAL, mstr, strlen(mstr));\ + return NULL;\ + } + +void *create_RT_to_nRT_signal(char* name){ + int new_index = -1; + RT_to_nRT_signal_t *sig; + char pipe_dev[64]; + + /* find a free slot */ + for(int i=0; i < max_RT_to_nRT_signals; i++){ + sig = &RT_to_nRT_signal_pool[i]; + if(!sig->used){ + new_index = i; + break; + } + } + + /* fail if none found */ + if(new_index == -1) { + _LogAndReturnNull("Maximum count of RT-PIPE reached while creating pipe"); + } + + /* create rt pipe */ + if(rt_pipe_create(&sig->pipe, name, new_index, PIPE_SIZE) < 0){ + _LogAndReturnNull("Failed opening real-time end of RT-PIPE"); + } + + /* open pipe's userland */ + snprintf(pipe_dev, 64, "/dev/rtp%d", new_index); + if((sig->pipe_fd = open(pipe_dev, O_RDWR)) == -1){ + rt_pipe_delete(&sig->pipe); + _LogAndReturnNull("Failed opening non-real-time end of RT-PIPE"); + } + + sig->used = 1; + sig->name = name; + + return sig; +} + +void delete_RT_to_nRT_signal(void* handle){ + RT_to_nRT_signal_t *sig = (RT_to_nRT_signal_t*)handle; + + if(!sig->used) return; + + rt_pipe_delete(&sig->pipe); + + close(sig->pipe_fd); + + sig->used = 0; +} + +int wait_RT_to_nRT_signal(void* handle){ + char cmd; + int ret = recv_RT_to_nRT_signal(handle, &cmd); + return (ret == 1) ? 0 : ((ret == 0) ? ENODATA : -ret); +} + +int unblock_RT_to_nRT_signal(void* handle){ + int ret = send_RT_to_nRT_signal(handle, 0); + return (ret == 1) ? 0 : ((ret == 0) ? EINVAL : -ret); +} + +void nRT_reschedule(void){ + sched_yield(); +} + void PLC_cleanup_all(void) { if (PLC_state & PLC_STATE_TASK_CREATED) { @@ -115,45 +197,24 @@ } if (PLC_state & PLC_STATE_WAITDEBUG_PIPE_CREATED) { - rt_pipe_delete(&WaitDebug_pipe); + delete_RT_to_nRT_signal(WaitDebug_handle); PLC_state &= ~PLC_STATE_WAITDEBUG_PIPE_CREATED; } - if (PLC_state & PLC_STATE_WAITDEBUG_FILE_OPENED) { - close(WaitDebug_pipe_fd); - PLC_state &= ~PLC_STATE_WAITDEBUG_FILE_OPENED; - } - if (PLC_state & PLC_STATE_WAITPYTHON_PIPE_CREATED) { - rt_pipe_delete(&WaitPython_pipe); + delete_RT_to_nRT_signal(WaitPython_handle); PLC_state &= ~PLC_STATE_WAITPYTHON_PIPE_CREATED; } - if (PLC_state & PLC_STATE_WAITPYTHON_FILE_OPENED) { - close(WaitPython_pipe_fd); - PLC_state &= ~PLC_STATE_WAITPYTHON_FILE_OPENED; - } - if (PLC_state & PLC_STATE_DEBUG_PIPE_CREATED) { - rt_pipe_delete(&Debug_pipe); + delete_RT_to_nRT_signal(Debug_handle); PLC_state &= ~PLC_STATE_DEBUG_PIPE_CREATED; } - if (PLC_state & PLC_STATE_DEBUG_FILE_OPENED) { - close(Debug_pipe_fd); - PLC_state &= ~PLC_STATE_DEBUG_FILE_OPENED; - } - if (PLC_state & PLC_STATE_PYTHON_PIPE_CREATED) { - rt_pipe_delete(&Python_pipe); + delete_RT_to_nRT_signal(Python_handle); PLC_state &= ~PLC_STATE_PYTHON_PIPE_CREATED; } - - if (PLC_state & PLC_STATE_PYTHON_FILE_OPENED) { - close(Python_pipe_fd); - PLC_state &= ~PLC_STATE_PYTHON_FILE_OPENED; - } - } int stopPLC() @@ -197,49 +258,27 @@ /* no memory swapping for that process */ mlockall(MCL_CURRENT | MCL_FUTURE); + /* memory initialization */ PLC_shutdown = 0; - - /*** RT Pipes creation and opening ***/ + bzero(RT_to_nRT_signal_pool, sizeof(RT_to_nRT_signal_pool)); + + /*** RT Pipes ***/ /* create Debug_pipe */ - if(rt_pipe_create(&Debug_pipe, "Debug_pipe", DEBUG_PIPE_MINOR, PIPE_SIZE) < 0) - _startPLCLog(FO "Debug_pipe real-time end"); + if(!(Debug_handle = create_RT_to_nRT_signal("Debug_pipe"))) goto error; PLC_state |= PLC_STATE_DEBUG_PIPE_CREATED; - /* open Debug_pipe*/ - if((Debug_pipe_fd = open(DEBUG_PIPE_DEVICE, O_RDWR)) == -1) - _startPLCLog(FO DEBUG_PIPE_DEVICE); - PLC_state |= PLC_STATE_DEBUG_FILE_OPENED; - /* create Python_pipe */ - if(rt_pipe_create(&Python_pipe, "Python_pipe", PYTHON_PIPE_MINOR, PIPE_SIZE) < 0) - _startPLCLog(FO "Python_pipe real-time end"); + if(!(Python_handle = create_RT_to_nRT_signal("Python_pipe"))) goto error; PLC_state |= PLC_STATE_PYTHON_PIPE_CREATED; - /* open Python_pipe*/ - if((Python_pipe_fd = open(PYTHON_PIPE_DEVICE, O_RDWR)) == -1) - _startPLCLog(FO PYTHON_PIPE_DEVICE); - PLC_state |= PLC_STATE_PYTHON_FILE_OPENED; - /* create WaitDebug_pipe */ - if(rt_pipe_create(&WaitDebug_pipe, "WaitDebug_pipe", WAITDEBUG_PIPE_MINOR, PIPE_SIZE) < 0) - _startPLCLog(FO "WaitDebug_pipe real-time end"); + if(!(WaitDebug_handle = create_RT_to_nRT_signal("WaitDebug_pipe"))) goto error; PLC_state |= PLC_STATE_WAITDEBUG_PIPE_CREATED; - /* open WaitDebug_pipe*/ - if((WaitDebug_pipe_fd = open(WAITDEBUG_PIPE_DEVICE, O_RDWR)) == -1) - _startPLCLog(FO WAITDEBUG_PIPE_DEVICE); - PLC_state |= PLC_STATE_WAITDEBUG_FILE_OPENED; - /* create WaitPython_pipe */ - if(rt_pipe_create(&WaitPython_pipe, "WaitPython_pipe", WAITPYTHON_PIPE_MINOR, PIPE_SIZE) < 0) - _startPLCLog(FO "WaitPython_pipe real-time end"); + if(!(WaitPython_handle = create_RT_to_nRT_signal("WaitPython_pipe"))) goto error; PLC_state |= PLC_STATE_WAITPYTHON_PIPE_CREATED; - /* open WaitPython_pipe*/ - if((WaitPython_pipe_fd = open(WAITPYTHON_PIPE_DEVICE, O_RDWR)) == -1) - _startPLCLog(FO WAITPYTHON_PIPE_DEVICE); - PLC_state |= PLC_STATE_WAITPYTHON_FILE_OPENED; - /*** create PLC task ***/ if(rt_task_create(&PLC_task, "PLC_task", 0, 50, T_JOINABLE)) _startPLCLog("Failed creating PLC task"); @@ -279,11 +318,11 @@ void LeaveDebugSection(void) { - if(AtomicCompareExchange( &debug_state, + if(AtomicCompareExchange( &debug_state, DEBUG_BUSY, DEBUG_FREE) == DEBUG_BUSY){ char msg = DEBUG_UNLOCK; /* signal to NRT for wakeup */ - rt_pipe_write(&Debug_pipe, &msg, sizeof(msg), P_NORMAL); + send_RT_to_nRT_signal(Debug_handle, msg); } } @@ -295,8 +334,8 @@ int res; if (PLC_shutdown) return -1; /* Wait signal from PLC thread */ - res = read(WaitDebug_pipe_fd, &cmd, sizeof(cmd)); - if (res == sizeof(cmd) && cmd == DEBUG_PENDING_DATA){ + res = recv_RT_to_nRT_signal(WaitDebug_handle, &cmd); + if (res == 1 && cmd == DEBUG_PENDING_DATA){ *tick = __debug_tick; return 0; } @@ -311,7 +350,7 @@ /* remember tick */ __debug_tick = __tick; /* signal debugger thread it can read data */ - rt_pipe_write(&WaitDebug_pipe, &msg, sizeof(msg), P_NORMAL); + send_RT_to_nRT_signal(WaitDebug_handle, msg); } int suspendDebug(int disable) @@ -323,7 +362,7 @@ DEBUG_FREE, DEBUG_BUSY) != DEBUG_FREE && cmd == DEBUG_UNLOCK){ - if(read(Debug_pipe_fd, &cmd, sizeof(cmd)) != sizeof(cmd)){ + if(recv_RT_to_nRT_signal(Debug_handle, &cmd) != 1){ return -1; } } @@ -343,11 +382,11 @@ static long python_state = PYTHON_FREE; int WaitPythonCommands(void) -{ +{ char cmd; if (PLC_shutdown) return -1; /* Wait signal from PLC thread */ - if(read(WaitPython_pipe_fd, &cmd, sizeof(cmd))==sizeof(cmd) && cmd==PYTHON_PENDING_COMMAND){ + if(recv_RT_to_nRT_signal(WaitPython_handle, &cmd) == 1 && cmd==PYTHON_PENDING_COMMAND){ return 0; } return -1; @@ -357,7 +396,7 @@ void UnBlockPythonCommands(void) { char msg = PYTHON_PENDING_COMMAND; - rt_pipe_write(&WaitPython_pipe, &msg, sizeof(msg), P_NORMAL); + send_RT_to_nRT_signal(WaitPython_handle, msg); } int TryLockPython(void) @@ -378,7 +417,7 @@ PYTHON_FREE, PYTHON_BUSY) != PYTHON_FREE && cmd == UNLOCK_PYTHON){ - read(Python_pipe_fd, &cmd, sizeof(cmd)); + recv_RT_to_nRT_signal(Python_handle, &cmd); } } @@ -390,7 +429,7 @@ PYTHON_FREE) == PYTHON_BUSY){ if(rt_task_self()){/*is that the real time task ?*/ char cmd = UNLOCK_PYTHON; - rt_pipe_write(&Python_pipe, &cmd, sizeof(cmd), P_NORMAL); + send_RT_to_nRT_signal(Python_handle, cmd); }/* otherwise, no signaling from non real time */ } /* as plc does not wait for lock. */ } diff -r 577118ebd179 -r c89fc366bebd targets/beremiz.h --- a/targets/beremiz.h Wed Jun 30 15:44:32 2021 +0200 +++ b/targets/beremiz.h Thu Sep 02 21:36:29 2021 +0200 @@ -26,5 +26,10 @@ #endif long AtomicCompareExchange(long* atomicvar,long compared, long exchange); +void *create_RT_to_nRT_signal(char* name); +void delete_RT_to_nRT_signal(void* handle); +int wait_RT_to_nRT_signal(void* handle); +int unblock_RT_to_nRT_signal(void* handle); +void nRT_reschedule(void); #endif diff -r 577118ebd179 -r c89fc366bebd tests/svghmi/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi/beremiz.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi/plc.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,991 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TargetPressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TargetPressure + + + + + + + + + + + + + + Sloth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sloth + + + + + + + + + + + Pressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 100 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + strout + + + + + + + TargetPressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + strin + + + + + + + boolin + + + + + + + + + + + boolout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + floating + + + + + + + Sloth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + REAL#100.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + 0 + + + + + + + + + + + Sloth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sloth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi/py_ext_0@py_ext/baseconfnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi/py_ext_0@py_ext/baseconfnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi/py_ext_0@py_ext/pyfile.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi/py_ext_0@py_ext/pyfile.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,99 @@ + + + + + + + + + + 0 and extra[0] != "": + fAlarms = [alrm for alrm in Alarms if alrm[1].find(extra[0])!=-1] + else: + fAlarms = Alarms[:] + fAlarms.reverse() + new_range = len(fAlarms) + delta = new_range - visible + new_position = 0 if delta <= 0 else delta if old_position > delta else old_position + new_visible = new_range if delta <= 0 else visible + + visible_alarms = [] + for ts, text, status, alarmid in fAlarms[new_position:new_position + new_visible]: + visible_alarms.append({ + "time": time.ctime(ts), + "text": text, # TODO translate text + "status": status, + "alarmid": alarmid + }) + + return new_range, new_position, visible_alarms + + +]]> + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi/svghmi_0@svghmi/baseconfnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi/svghmi_0@svghmi/baseconfnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi/svghmi_0@svghmi/confnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi/svghmi_0@svghmi/confnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi/svghmi_0@svghmi/svghmi.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi/svghmi_0@svghmi/svghmi.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,7476 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Home + + + + + + + + + Settings + + + + 8888 + + + + + + + + 8888 + + + + 0 + 10000 + 000 + bar + + SetPoint + Actual + Pressure + + + + nastavljena vrednost + dejanska vrednost + pritisk + Settings + Home + + + + + 0 + 10000 + 000 + €£$¥ + + + 8888 + + + + →0← + + + + -10 + + + + -100 + + + + +100 + + + + +10 + + + 8888 + 8888 + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + + + + number + + + + + + + + 7 + + + + 4 + + + + 1 + + + + 8 + + + + 5 + + + + 2 + + + + 9 + + + + 6 + + + + 3 + + + + 0 + + + + + Esc + + + + + + + + +/- + + information + + + . + + + + + + + + + Q + + + + W + + + + E + + + + R + + + + T + + + + Y + + + + U + + + + I + + + + O + + + + P + + + + A + + + + S + + + + D + + + + F + + + + G + + + + H + + + + J + + + + K + + + + L + + + + Z + + + + X + + + + C + + + + V + + + + B + + + + N + + + + M + + + + . + : + + + + ; + , + + + + 1 + + + + 2 + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + - + + + + 0 + + + + + + + Esc + + + + + + + + + + + + + Caps + Lock + + + + Caps + Lock + + + + text + + + + Shift + + Shift + + + + Shift + + Shift + + + information + + + + + sel_0 + + + + + 8 + + + + + + + + + + up + + + + + + + + + + + + + + + + + + + + + + 4 + + + + 3 + + + + 2 + + + + 1 + + + + + + + + message + + + + OK + + + information + + + + + 0 + 10000 + 000 + bar + + + + + + + + + + + + + + + + + + + + + 8888 + + + + + + + + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + 8888 + + + + + + Pump 0 + + + + + + + + Pump 1 + + + + + + + + Pump 2 + + + + + + + + Pump 3 + + + 8888 + 8888 + 8888 + 8888 + + + + + + + + + + + + + + Pump + 8888 + + + + + + + + + + + + + + + + Pump + 8888 + + + + + + + + + + + + + + + + Pump + 8888 + + + + + + + + + + + + + + + + Pump + 8888 + + + + + + +1 + + + + -1 + + + 8888 + Multiple variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + HMI_LOCAL variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + PAGE_LOCAL variables + + + + 0 + 10000 + 000 + bar + + + 8888 + + + + + + + + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + HMI_LOCAL variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + PAGE_LOCAL variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + HMI_LOCAL variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + PAGE_LOCAL variables + + + + + + + + Alarm Page + + + + + + + + + + + + + + + + + + + + + + + + reset + + + + + + + + + + + + + + + + + 8888 + + + + + + + + + + + + value + value + value + value + + + + + + + + + 8888 + + + + + + 8888 + + + + + range + position + notify + + + 8888 + + + + + + + + trigger + + + + + 8888 + + + ack + + + + disabled + + + + active + + + + alarm + + + Alarm Text + Status + + + + + + Alarms + + + 8888 + + 8888 + + + filter + + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + + + + + + + + Home + + + + + + + Alarm Page 2 + + + + + + + + + + + + + + + + + + + + 8888 + + + + + + 8888 + + + + + + 8888 + + + + + range + position + notify + + + 8888 + + + + + + + + trigger + + + + + 8888 + + + ack + + + + disabled + + + + active + + + + alarm + + + Alarm Text + Status + + 8888 + + + filter + + + + + + Alarms2 + + + + + + + + Alarms + + + + + + + + + page node + + + + + up + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/beremiz.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/plc.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TargetPressure + + + + + + + + + + + + + selection + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/svghmi_0@svghmi/baseconfnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/svghmi_0@svghmi/baseconfnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/svghmi_0@svghmi/confnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/svghmi_0@svghmi/confnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/svghmi_0@svghmi/fr_FR.mo Binary file tests/svghmi_i18n/svghmi_0@svghmi/fr_FR.mo has changed diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/svghmi_0@svghmi/fr_FR.po --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/svghmi_0@svghmi/fr_FR.po Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,42 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-02-14 18:36+CET\n" +"PO-Revision-Date: 2021-02-14 18:37+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fr_FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: SVGHMI 1.0\n" +"X-Generator: Poedit 2.4.2\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "height is %d meters" +msgstr "la hauteur est de %d metres" + +msgid "This is an integer value : %d" +msgstr "C'est un nombre entier : %d" + +msgid "Some \"other\" ČĆĐš english text" +msgstr "De l'\"autre\" texte en français žfšŽŠĐĆČ" + +msgid "" +"Some english text\n" +"another line\n" +"a third one" +msgstr "" +"Trois lignes en francais\n" +"blah\n" +"blah" + +#~ msgid "Some english text" +#~ msgstr "Du texte en français" + +#~ msgid "Blah" +#~ msgstr "Blup" diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/svghmi_0@svghmi/messages.pot --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/svghmi_0@svghmi/messages.pot Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,36 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-15 14:45+CET\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: SVGHMI 1.0\n" + + +#:svghmi.svg: format:text424 +msgid "height is %d meters" +msgstr "" + +#:svghmi.svg: format:text5271 +msgid "This is an integer value : %d" +msgstr "" + +#:svghmi.svg: someothertext:text5267 +msgid "Some \"other\" ČĆĐš english text" +msgstr "" + +#:svghmi.svg: sometext:text5283 +msgid "" +"Some english text\n" +"another line\n" +"a third one" +msgstr "" + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/svghmi_0@svghmi/sl_SI.mo Binary file tests/svghmi_i18n/svghmi_0@svghmi/sl_SI.mo has changed diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/svghmi_0@svghmi/sl_SI.po --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/svghmi_0@svghmi/sl_SI.po Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,37 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"POT-Creation-Date: 2021-02-14 18:36+CET\n" +"PO-Revision-Date: 2021-02-14 18:38+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: sl_SI\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: SVGHMI 1.0\n" +"X-Generator: Poedit 2.4.2\n" +"Plural-Forms: nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100>=3 && n" +"%100<=4 ? 2 : 3);\n" + +msgid "height is %d meters" +msgstr "To je celo število %d m" + +msgid "This is an integer value : %d" +msgstr "To je celo število %d" + +msgid "Some \"other\" ČĆĐš english text" +msgstr "En drug angleški tekt" + +msgid "" +"Some english text\n" +"another line\n" +"a third one" +msgstr "" +"En angleški tekst\n" +"druga vrstica\n" +"tretja vrstica" diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_i18n/svghmi_0@svghmi/svghmi.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/svghmi_0@svghmi/svghmi.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2632 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Q + + + + W + + + + E + + + + R + + + + T + + + + Y + + + + U + + + + I + + + + O + + + + P + + + + A + + + + S + + + + D + + + + F + + + + G + + + + H + + + + J + + + + K + + + + L + + + + Z + + + + X + + + + C + + + + V + + + + B + + + + N + + + + M + + + + . + : + + + + ; + , + + + + 1 + + + + 2 + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + - + + + + 0 + + + + + + + Esc + + + + + + + + + + + + + Caps + Lock + + + + Caps + Lock + + + + text + + + + Shift + + Shift + + + + Shift + + Shift + + + information + + + + + + + number + + + + + + + + 7 + + + + 4 + + + + 1 + + + + 8 + + + + 5 + + + + 2 + + + + 9 + + + + 6 + + + + 3 + + + + 0 + + + + + Esc + + + + + + + + +/- + + information + + + . + + + + + + + + + + + + + + This is an integer value : %d + + Some "other" ČĆĐš english text + 8888 + Some english textanother linea third one + + + 1234 + + + +1 + + + + + + + Language (Country) + + + + height is %d meters + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_real/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_real/beremiz.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_real/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_real/plc.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + var0 + + + + + + + + + + + + + var1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_real/svghmi_0@svghmi/baseconfnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_real/svghmi_0@svghmi/baseconfnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_real/svghmi_0@svghmi/confnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_real/svghmi_0@svghmi/confnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_real/svghmi_0@svghmi/svghmi.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_real/svghmi_0@svghmi/svghmi.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,647 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + number + + + + + + + + 7 + + + + 4 + + + + 1 + + + + 8 + + + + 5 + + + + 2 + + + + 9 + + + + 6 + + + + 3 + + + + 0 + + + + + Esc + + + + + + + + +/- + + information + + + . + + + + + + 1234 + + + -1 + + + + -10 + + + + +1 + + + + +10 + + + + +.1 + + + + -.1 + + + + %.2f + + + %d + + + temp: %.2f℃ + + + ratio: %.2f%% + + + padded: %'04d + + + this way, %d and %.3f are together + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_scrollbar/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_scrollbar/beremiz.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_scrollbar/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_scrollbar/plc.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + var0 + + + + + + + + + + + + + var1 + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_scrollbar/svghmi_0@svghmi/baseconfnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_scrollbar/svghmi_0@svghmi/baseconfnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_scrollbar/svghmi_0@svghmi/confnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_scrollbar/svghmi_0@svghmi/confnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_scrollbar/svghmi_0@svghmi/messages.pot --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_scrollbar/svghmi_0@svghmi/messages.pot Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,17 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR ORGANIZATION +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"POT-Creation-Date: 2021-02-12 21:55+CET\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: SVGHMI 1.0\n" + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_scrollbar/svghmi_0@svghmi/svghmi.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_scrollbar/svghmi_0@svghmi/svghmi.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,891 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + number + + + + + + + + 7 + + + + 4 + + + + 1 + + + + 8 + + + + 5 + + + + 2 + + + + 9 + + + + 6 + + + + 3 + + + + 0 + + + + + Esc + + + + + + + + +/- + + information + + + . + + + + + + 1234 + + + -1 + + + + -10 + + + + +1 + + + + +10 + + + + + 1234 + + + -1 + + + + -10 + + + + +1 + + + + +10 + + + + + 1234 + + + -1 + + + + -10 + + + + +1 + + + + +10 + + + + + + + + + + + + Position + Range + Size + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_v2/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_v2/beremiz.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_v2/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_v2/plc.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,585 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TargetPressure + + + + + + + TestButton + + + + + + + + + + + + + TestLocal + + + + + + + Multistate + + + + + + + + + + + MultistateExt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TargetPressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sloth + + + + + + + + + + + Pressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 100 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + strout + + + + + + + TargetPressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + strin + + + + + + + boolin + + + + + + + + + + + boolout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + Sloth + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_v2/py_ext_0@py_ext/baseconfnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_v2/py_ext_0@py_ext/baseconfnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_v2/py_ext_0@py_ext/pyfile.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_v2/py_ext_0@py_ext/pyfile.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_v2/svghmi_0@svghmi/baseconfnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_v2/svghmi_0@svghmi/baseconfnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_v2/svghmi_0@svghmi/confnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_v2/svghmi_0@svghmi/confnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_v2/svghmi_0@svghmi/svghmi.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_v2/svghmi_0@svghmi/svghmi.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,1619 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 10 + 000 + + + Test + + + + + + + + + + Q + + + + W + + + + E + + + + R + + + + T + + + + Y + + + + U + + + + I + + + + O + + + + P + + + + A + + + + S + + + + D + + + + F + + + + G + + + + H + + + + J + + + + K + + + + L + + + + Z + + + + X + + + + C + + + + V + + + + B + + + + N + + + + M + + + + . + : + + + + ; + , + + + + 1 + + + + 2 + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + - + + + + 0 + + + + + + + Esc + + + + + + + + + + + + + Caps + Lock + + + + Caps + Lock + + + + text + + + + Shift + + Shift + + + + Shift + + Shift + + + information + + + + + + + + + + + + + 0 + 1000 + 000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 000 + + + + + + + + + + + + + 000 + + + + + + + + + + + + + + + + + + + + <img xmlns="http://www.w3.org/1999/xhtml" id="img" src="https://thumbs.gfycat.com/ImpoliteSoupyKakapo-size_restricted.gif" width="100%" height="80%" /> <a xmlns="http://www.w3.org/1999/xhtml" href='www.gmail.com'>Gmail</a> <p xmlns="http://www.w3.org/1999/xhtml">Koj kurac to ne dela</p> + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_widgets/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_widgets/beremiz.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_widgets/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_widgets/plc.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,922 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TargetPressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TargetPressure + + + + + + + + + + + + + + Sloth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sloth + + + + + + + + + + + Pressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 100 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + strout + + + + + + + TargetPressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + strin + + + + + + + boolin + + + + + + + + + + + boolout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Pressure + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + floating + + + + + + + Sloth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + REAL#100.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + 0 + + + + + + + + + + + Sloth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sloth + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_widgets/py_ext_0@py_ext/baseconfnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_widgets/py_ext_0@py_ext/baseconfnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_widgets/py_ext_0@py_ext/pyfile.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_widgets/py_ext_0@py_ext/pyfile.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,93 @@ + + + + + + + + + + 0 and extra[0] != "": + fAlarms = [alrm for alrm in Alarms if alrm[1].find(extra[0])!=-1] + else: + fAlarms = Alarms + new_range = len(fAlarms) + delta = new_range - visible + new_position = 0 if delta <= 0 else delta if old_position > delta else old_position + new_visible = new_range if delta <= 0 else visible + + visible_alarms = [] + for ts, text, status, alarmid in fAlarms[new_position:new_position + new_visible]: + visible_alarms.append({ + "time": time.ctime(ts), + "text": text, # TODO translate text + "status": status, + "alarmid": alarmid + }) + + return new_range, new_position, visible_alarms + + +]]> + + + + + + + + + + + + + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_widgets/svghmi_0@svghmi/baseconfnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_widgets/svghmi_0@svghmi/baseconfnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_widgets/svghmi_0@svghmi/confnode.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_widgets/svghmi_0@svghmi/confnode.xml Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 577118ebd179 -r c89fc366bebd tests/svghmi_widgets/svghmi_0@svghmi/svghmi.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_widgets/svghmi_0@svghmi/svghmi.svg Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,13525 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Home + + + + + + + + + Settings + + + + 8888 + + + + + + + + 8888 + + + + 0 + 10000 + 000 + bar + + SetPoint + Actual + Pressure + + + + nastavljena vrednost + dejanska vrednost + pritisk + Settings + Home + + + + + 0 + 10000 + 000 + €£$¥ + + + 8888 + + + + →0← + + + + -10 + + + + -100 + + + + +100 + + + + +10 + + + 8888 + 8888 + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + + + + number + + + + + + + + 7 + + + + 4 + + + + 1 + + + + 8 + + + + 5 + + + + 2 + + + + 9 + + + + 6 + + + + 3 + + + + 0 + + + + + Esc + + + + + + + + +/- + + information + + + . + + + + + + + + + Q + + + + W + + + + E + + + + R + + + + T + + + + Y + + + + U + + + + I + + + + O + + + + P + + + + A + + + + S + + + + D + + + + F + + + + G + + + + H + + + + J + + + + K + + + + L + + + + Z + + + + X + + + + C + + + + V + + + + B + + + + N + + + + M + + + + . + : + + + + ; + , + + + + 1 + + + + 2 + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + 9 + - + + + + 0 + + + + + + + Esc + + + + + + + + + + + + + Caps + Lock + + + + Caps + Lock + + + + text + + + + Shift + + Shift + + + + Shift + + Shift + + + information + + + + + sel_0 + + + + + 8 + + + + + + + + + + up + + + + + + + + + + + + + + + + + + + + + + + + message + + + + OK + + + information + + + + + 0 + 10000 + 000 + bar + + + + + + + + + + + + + + + + + + + + + 8888 + + + + + + + + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + 8888 + + + + + + Pump 0 + + + + + + + + Pump 1 + + + + + + + + Pump 2 + + + + + + + + Pump 3 + + + 8888 + 8888 + 8888 + 8888 + + + + + + + + Pump + 8888 + + + + + + + + + + Pump + 8888 + + + + + + + + + + Pump + 8888 + + + + + + + + + + Pump + 8888 + + + + + + +1 + + + + -1 + + + 8888 + Multiple variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + HMI_LOCAL variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + PAGE_LOCAL variables + + + + 0 + 10000 + 000 + bar + + + 8888 + + + + + + + + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + HMI_LOCAL variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + PAGE_LOCAL variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + HMI_LOCAL variables + 8888 + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + PAGE_LOCAL variables + + + + + + + + Alarm Page + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 8888 + + + + + + + + + + + + + + + value + value + value + value + + + + + + + + + 8888 + + + + + + 8888 + + + + + range + position + notify + + + 8888 + + + + + + + + trigger + + + + + 8888 + + + ack + + + + disabled + + + + active + + + + alarm + + + Alarm Text + Status + + + + + + Alarms + + + 8888 + + 8888 + + + filter + + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + + 8888 + + + + dhu + + + + plop + + + + mhoo + + + + yodl + + + + mhe + + + + + + + + + + Home + + + + + + + + + 0 + 10000 + [value] + + + + + 0 + 10000 + [value] + + + + 5,150 + + + + + + + + + + + 5,150 + + + + + + 5,150 + + + + + + 5,150 + + + + HMI:Meter:[min:max]@path + + clone +stroke color + template (usually out of page)needle has undefinedstroke paint + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + https://openclipart.org/detail/205486/voltmeter-and-ammeter + + + [max] + [min] + [max] + [min] + Second point of "needle" labeled SVG path moves along "range" labeled SVG path according to value. Value min and max range is either given as arguments or as "min" and "max" labeled SVG texts, or default to 0...100 if unspecified. + needle + range + needle + range + + + + + + + + + + 65% + + + + + + + + + HMI:CircularBar:[min:max]@path + https://openclipart.org/detail/203239/gauges-vectorbased-superb-quality + + [value] + + 0 + 100 + + [max] + [min] + Inkscape's Arc labeled "path" End angle varies according to value. Arc cannot be closed yet, use wide stroke to fill.Value min and max range is either given as arguments or as "min" and "max" labeled SVG texts, or default to 0...100 if unspecified. + + + + + + + + 65% + + + + + + + + + + + + + 65% + + + + + + + + + + + + + + + + HMI:Switch@path + Display only childs of widget's element (a SVG group) who's label match value. + + HMI:SwitchHMI_INT + 1 + 2 + 3 + 42 + 4 + 5 + + HMI:SwitchHMI_STRING + "aa" + "abba" + "mhoo" + "ggg" + "wtf" + "xxx" + + + + + + + + + + + + + + + + + + + + + + + + + + + + HMI:SwitchHMI_INT + 1 + 2 + 3 + 42 + 4 + 5 + + + + + + + + + + + + clone + + + + + + + + + + HMI:Switchcan containother widgets(or clonesof them) + + + + HMI:DropDown:[item0:item1:....]@path + TODO + + + + HMI:Input@path + TODO + + + + HMI:Display@path + TODO + + + + + Home + + + + + + + + + + + + + + + + + + + + + + + + + HMI:Button@path + TODO + + + + HMI:ForEach@path + TODO + + + + HMI:JsonTable@path + TODO + + + + HMI:Page:PageName[@RootPath]HMI:Jump:PageName[@RelativePath]HMI:Back + Pages are full screen, only one is displayed at the same time.If widget's bounding box is included in page bounding box, then widget is part of page.Page change is triggered by HMI:Jump and HMI:Back (TODO: /CURRENTPAGE).HMI:Back takes no parameter and just go back one step in page change history.HMI:Jump can have "inactive", "active" and "disabled" labeled children: - "inactive" is shown when target page is not currently displayed - "active" is shown when target page is currently displayed - "disabled" is shown when relative page's RootPath is set to 0, disabling jump. + When [@RootPath] is given, page is a relative page.When using HMI:Jump to reach a relative page, a compatible [@RelativePath] may be provided.To be compatible, RootPath and RelativePath must both point to same HMI_NODE (i.e same POU).Every widget using a path descendant of RootPath in a relative page is relative.Relative widgets get the RootPath section of their path replaced by RelativePath. + Relative pages + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + / + HMI:MyPage@/A/B/C0 + + HMI:Jump:MyPage + + HMI:Jump:MyPage@/A/B/C1 + + HMI:Jump:MyPage@/A/B/C2 + + HMI:Jump:MyPage@/D/E/F + A + B + C0 + C1 + C2 + D + E + F + HMI:Jump:MyPage@/A/B/C0 + G + H + HMI:Widget@/A/B/C0/G + HMI:Widget@/A/B/C0/H + HMI:MyPage@/A/B/C0 + G + H + HMI:Widget@/A/B/C0/G + HMI:Widget@/A/B/C0/H + HMI:MyPage@/A/B/C0 + G + H + HMI:Widget@/A/B/C0/G + HMI:Widget@/A/B/C0/H + HMI:MyPage@/A/B/C0 + G + H + HMI:Widget@/A/B/C0/G + HMI:Widget@/A/B/C0/H + real path: /A/B/C0/G + real path: /A/B/C0/H + real path: /A/B/C1/G + real path: /A/B/C1/H + real path: /A/B/C2/G + real path: /A/B/C3/H + real path: /D/E/F/G + real path: /D/E/F/H + + HMI:SomePage + HMI:Widget@/A/I + HMI:Widget@/D/J + I + J + + HMI:Jump:SomePage + HMI_TREE root + + + HMI_NODE in a POU + HMI_* variable + + + + + HMI:KeyPad:HMI_TYPE[:HMI_TYPE...] + KeyPad widget let user draw keyboard for different types of input.It is shown for example when editing a value from HMI:Input widget. + + + HMI:Keypad:HMI_STRING:HMI_LOCAL:PAGE_LOCAL + + + + HMI:Keypad:HMI_INT:HMI_REAL + + + + + + + + + + Info + Esc + Shift + Enter + [...] + Keys + 0 + + 9 - + 8 + [...] + q Q + w W + + [...] + , ; + . : + HMI:KeyPad + + Value + + + + + + + + + + Info + Esc + Sign + Enter + [...] + Keys + 9 + 8 + 7 + 6 + 5 + + + + + + + + + + 4 + 3 + [...] + HMI:KeyPad + + Value + + NumDot + + + + + HMI:SliderHMI:CircularSlider + TODO + + + + + + + + TO BE REWRITTEN, DO NOT USE + + + + sel_0 + + + + + HMI:ToggleButton@path + TODO + + + + HMI:Multistate@path + TODO + + + + HMI:CustomHtml@path + TODO + + diff -r 577118ebd179 -r c89fc366bebd util/ProcessLogger.py --- a/util/ProcessLogger.py Wed Jun 30 15:44:32 2021 +0200 +++ b/util/ProcessLogger.py Thu Sep 02 21:36:29 2021 +0200 @@ -139,6 +139,9 @@ else: self.timeout = None + if _debug: + self.logger.write("(DEBUG) launching:\n" + self.Command_str + "\n") + self.Proc = subprocess.Popen(self.Command, **popenargs) self.outt = outputThread( @@ -191,7 +194,7 @@ if self.timeout: self.timeout.cancel() self.exitcode = ecode - if _debug or self.exitcode != 0: + if self.exitcode != 0: self.log_the_end(ecode, pid) if self.finish_callback is not None: self.finish_callback(self, ecode, pid) diff -r 577118ebd179 -r c89fc366bebd xmlclass/xmlclass.py --- a/xmlclass/xmlclass.py Wed Jun 30 15:44:32 2021 +0200 +++ b/xmlclass/xmlclass.py Thu Sep 02 21:36:29 2021 +0200 @@ -1522,7 +1522,7 @@ raise ValueError("Wrong path!") if attributes[parts[0]]["attr_type"]["basename"] == "boolean": setattr(self, parts[0], value) - elif attributes[parts[0]]["use"] == "optional" and value == "": + elif attributes[parts[0]]["use"] == "optional" and value == None: if "default" in attributes[parts[0]]: setattr(self, parts[0], attributes[parts[0]]["attr_type"]["extract"](