# HG changeset patch # User Edouard Tisserant # Date 1617954426 -7200 # Node ID d5b2369a103f65599b1764ff08157924dc2e5ee6 # Parent 60a8531efa458504000369edbafb974945d63c70# Parent 2b00f90c688891e4a4827658a7bb4c592a9a70eb Merged default in SVGHMI diff -r 2b00f90c6888 -r d5b2369a103f .hgignore --- a/.hgignore Fri Apr 09 09:45:28 2021 +0200 +++ b/.hgignore Fri Apr 09 09:47:06 2021 +0200 @@ -1,5 +1,7 @@ .project +.svghmithumbs + .directory .pytest_cache .cache diff -r 2b00f90c6888 -r d5b2369a103f BeremizIDE.py --- a/BeremizIDE.py Fri Apr 09 09:45:28 2021 +0200 +++ b/BeremizIDE.py Fri Apr 09 09:47:06 2021 +0200 @@ -122,86 +122,87 @@ self.risecall = risecall # to prevent rapid fire on rising log panel self.rising_timer = 0 - self.lock = Lock() + self.StackLock = Lock() self.YieldLock = Lock() self.RefreshLock = Lock() self.TimerAccessLock = Lock() self.stack = [] self.LastRefreshTime = gettime() self.LastRefreshTimer = None + self.refreshPending = False def write(self, s, style=None): - if self.lock.acquire(): - self.stack.append((s, style)) - self.lock.release() - current_time = gettime() - self.TimerAccessLock.acquire() - if self.LastRefreshTimer: + self.StackLock.acquire() + self.stack.append((s, style)) + self.StackLock.release() + current_time = gettime() + with self.TimerAccessLock: + if self.LastRefreshTimer is not None: self.LastRefreshTimer.cancel() self.LastRefreshTimer = None - self.TimerAccessLock.release() - if current_time - self.LastRefreshTime > REFRESH_PERIOD and self.RefreshLock.acquire(False): - self._should_write() - else: - self.TimerAccessLock.acquire() - self.LastRefreshTimer = Timer(REFRESH_PERIOD, self._timer_expired) - self.LastRefreshTimer.start() - self.TimerAccessLock.release() + elapsed = current_time - self.LastRefreshTime + if elapsed > REFRESH_PERIOD: + self._should_write() + else: + with self.TimerAccessLock: + if self.LastRefreshTimer is None: + self.LastRefreshTimer = Timer(REFRESH_PERIOD - elapsed, self._timer_expired) + self.LastRefreshTimer.start() def _timer_expired(self): - if self.RefreshLock.acquire(False): - self._should_write() - else: - self.TimerAccessLock.acquire() - self.LastRefreshTimer = Timer(REFRESH_PERIOD, self._timer_expired) - self.LastRefreshTimer.start() - self.TimerAccessLock.release() + self._should_write() + with self.TimerAccessLock: + self.LastRefreshTimer = None def _should_write(self): - app = wx.GetApp() - if app is not None: - wx.CallAfter(self._write) - if MainThread == currentThread().ident: + app = wx.GetApp() if app is not None: + self._write() if self.YieldLock.acquire(0): app.Yield() self.YieldLock.release() + else: + with self.RefreshLock: + if not self.refreshPending: + self.refreshPending = True + wx.CallAfter(self._write) def _write(self): if self.output: - self.output.Freeze() - self.lock.acquire() - for s, style in self.stack: - if style is None: - style = self.black_white - if style != self.black_white: - self.output.StartStyling(self.output.GetLength(), 0xff) - - # Temporary deactivate read only mode on StyledTextCtrl for - # adding text. It seems that text modifications, even - # programmatically, are disabled in StyledTextCtrl when read - # only is active - start_pos = self.output.GetLength() - self.output.SetReadOnly(False) - self.output.AppendText(s) - self.output.SetReadOnly(True) - text_len = self.output.GetLength() - start_pos - - if style != self.black_white: - self.output.SetStyling(text_len, style) - self.stack = [] - self.lock.release() - self.output.Thaw() - self.LastRefreshTime = gettime() - try: - self.RefreshLock.release() - except Exception: - pass - newtime = time.time() - if newtime - self.rising_timer > 1: - self.risecall(self.output) - self.rising_timer = newtime + with self.RefreshLock: + self.output.Freeze() + self.output.AnnotationClearAll() + self.StackLock.acquire() + for s, style in self.stack: + if style is None: + style = self.black_white + if style != self.black_white: + self.output.StartStyling(self.output.GetLength(), 0xff) + + # Temporary deactivate read only mode on StyledTextCtrl for + # adding text. It seems that text modifications, even + # programmatically, are disabled in StyledTextCtrl when read + # only is active + start_pos = self.output.GetLength() + self.output.SetReadOnly(False) + self.output.AppendText(s) + self.output.SetReadOnly(True) + text_len = self.output.GetLength() - start_pos + + if style != self.black_white: + self.output.SetStyling(text_len, style) + self.stack = [] + self.StackLock.release() + self.output.ScrollToEnd() + self.output.Thaw() + self.LastRefreshTime = gettime() + newtime = time.time() + if newtime - self.rising_timer > 1: + self.risecall(self.output) + self.rising_timer = newtime + self.refreshPending = False + def write_warning(self, s): self.write(s, self.red_white) @@ -220,6 +221,16 @@ def isatty(self): return False + def progress(self, text): + l = self.output.GetLineCount()-2 + self.output.AnnotationSetText(l, text) + self.output.AnnotationSetVisible(wx.stc.STC_ANNOTATION_BOXED) + self.output.AnnotationSetStyle(l, self.black_white) + if self.YieldLock.acquire(0): + app = wx.GetApp() + app.Yield() + self.YieldLock.release() + ID_FILEMENURECENTPROJECTS = wx.NewId() @@ -325,6 +336,7 @@ ("Run", wx.WXK_F5), ("Transfer", wx.WXK_F6), ("Connect", wx.WXK_F7), + ("Clean", wx.WXK_F9), ("Build", wx.WXK_F11)]: def OnMethodGen(obj, meth): def OnMethod(evt): diff -r 2b00f90c6888 -r d5b2369a103f PLCGenerator.py --- a/PLCGenerator.py Fri Apr 09 09:45:28 2021 +0200 +++ b/PLCGenerator.py Fri Apr 09 09:47:06 2021 +0200 @@ -458,10 +458,12 @@ return resrce # Generate the entire program for current project - def GenerateProgram(self): + def GenerateProgram(self, log): + log("Collecting data types") # Find all data types defined for datatype in self.Project.getdataTypes(): self.DatatypeComputed[datatype.getname()] = False + log("Collecting POUs") # Find all data types defined for pou in self.Project.getpous(): self.PouComputed[pou.getname()] = False @@ -471,12 +473,15 @@ self.Program += [("TYPE\n", ())] # Generate every data types defined for datatype_name in self.DatatypeComputed.keys(): + log("Generate Data Type %s"%datatype_name) self.GenerateDataType(datatype_name) self.Program += [("END_TYPE\n\n", ())] # Generate every POUs defined for pou_name in self.PouComputed.keys(): + log("Generate POU %s"%pou_name) self.GeneratePouProgram(pou_name) # Generate every configurations defined + log("Generate Config(s)") for config in self.Project.getconfigurations(): self.Program += self.GenerateConfiguration(config) @@ -1762,5 +1767,12 @@ def GenerateCurrentProgram(controler, project, errors, warnings): generator = ProgramGenerator(controler, project, errors, warnings) - generator.GenerateProgram() + if hasattr(controler, "logger"): + def log(txt): + controler.logger.write(" "+txt+"\n") + else: + def log(txt): + pass + + generator.GenerateProgram(log) return generator.GetGeneratedProgram() diff -r 2b00f90c6888 -r d5b2369a103f ProjectController.py --- a/ProjectController.py Fri Apr 09 09:45:28 2021 +0200 +++ b/ProjectController.py Fri Apr 09 09:47:06 2021 +0200 @@ -99,11 +99,12 @@ class Iec2CSettings(object): - def __init__(self): + def __init__(self, controler): self.iec2c = None self.iec2c_buildopts = None self.ieclib_path = self.findLibPath() self.ieclib_c_path = self.findLibCPath() + self.controler = controler def findObject(self, paths, test): path = None @@ -155,11 +156,11 @@ try: # Invoke compiler. # Output files are listed to stdout, errors to stderr - _status, result, _err_result = ProcessLogger(None, buildcmd, + _status, result, _err_result = ProcessLogger(self.controler.logger, buildcmd, no_stdout=True, no_stderr=True).spin() except Exception: - self.logger.write_error(_("Couldn't launch IEC compiler to determine compatible options.\n")) + self.controler.logger.write_error(_("Couldn't launch IEC compiler to determine compatible options.\n")) return buildopt for opt in options: @@ -242,7 +243,7 @@ ConfigTreeNode.__init__(self) if ProjectController.iec2c_cfg is None: - ProjectController.iec2c_cfg = Iec2CSettings() + ProjectController.iec2c_cfg = Iec2CSettings(self) self.MandatoryParams = None self._builder = None diff -r 2b00f90c6888 -r d5b2369a103f XSLTransform.py --- a/XSLTransform.py Fri Apr 09 09:45:28 2021 +0200 +++ b/XSLTransform.py Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f controls/DurationCellEditor.py --- a/controls/DurationCellEditor.py Fri Apr 09 09:45:28 2021 +0200 +++ b/controls/DurationCellEditor.py Fri Apr 09 09:47:06 2021 +0200 @@ -122,21 +122,18 @@ self.CellControl.SetValue(self.Table.GetValueByName(row, self.Colname)) self.CellControl.SetFocus() - def EndEditInternal(self, row, col, grid, old_duration): - duration = self.CellControl.GetValue() - changed = duration != old_duration + def EndEdit(self, row, col, grid, oldval): + value = self.CellControl.GetValue() + changed = value != oldval if changed: - self.Table.SetValueByName(row, self.Colname, duration) + return value + else: + return None + + def ApplyEdit(self, row, col, grid): + value = self.CellControl.GetValue() + self.Table.SetValueByName(row, self.Colname, value) self.CellControl.Disable() - return changed - - if wx.VERSION >= (3, 0, 0): - def EndEdit(self, row, col, grid, oldval): - return self.EndEditInternal(row, col, grid, oldval) - else: - def EndEdit(self, row, col, grid): - oldval = self.Table.GetValueByName(row, self.Colname) - return self.EndEditInternal(row, col, grid, oldval) def SetSize(self, rect): self.CellControl.SetDimensions(rect.x + 1, rect.y, diff -r 2b00f90c6888 -r d5b2369a103f docutil/docsvg.py --- a/docutil/docsvg.py Fri Apr 09 09:45:28 2021 +0200 +++ b/docutil/docsvg.py Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f features.py --- a/features.py Fri Apr 09 09:45:28 2021 +0200 +++ b/features.py Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f images/AddFont.png Binary file images/AddFont.png has changed diff -r 2b00f90c6888 -r d5b2369a103f images/DelFont.png Binary file images/DelFont.png has changed diff -r 2b00f90c6888 -r d5b2369a103f images/EditPO.png Binary file images/EditPO.png has changed diff -r 2b00f90c6888 -r d5b2369a103f images/EditSVG.png Binary file images/EditSVG.png has changed diff -r 2b00f90c6888 -r d5b2369a103f images/ImportSVG.png Binary file images/ImportSVG.png has changed diff -r 2b00f90c6888 -r d5b2369a103f images/OpenPOT.png Binary file images/OpenPOT.png has changed diff -r 2b00f90c6888 -r d5b2369a103f images/SVGHMI.png Binary file images/SVGHMI.png has changed diff -r 2b00f90c6888 -r d5b2369a103f images/icons.svg --- a/images/icons.svg Fri Apr 09 09:45:28 2021 +0200 +++ b/images/icons.svg Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f runtime/PLCObject.py --- a/runtime/PLCObject.py Fri Apr 09 09:45:28 2021 +0200 +++ b/runtime/PLCObject.py Fri Apr 09 09:47:06 2021 +0200 @@ -459,6 +459,7 @@ self.PythonThreadAcknowledge(cmd) self.PythonRuntimeCall("start") self.LogMessage("Python extensions started") + self.PostStartPLC() self.PythonThreadLoop() self.PythonRuntimeCall("stop", reverse_order=True) elif cmd == "Finish": @@ -496,6 +497,16 @@ """ pass + def PostStartPLC(self): + """ + Here goes actions to be taken after PLC is started, + with all libraries and python object already created, + and python extensions "Start" methods being called. + This is called before python thread processing py_eval blocks starts. + For example : attach additional ressource to web services + """ + pass + @RunInMain def StartPLC(self): diff -r 2b00f90c6888 -r d5b2369a103f svghmi/Makefile --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/Makefile Fri Apr 09 09:47:06 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 +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 2b00f90c6888 -r d5b2369a103f svghmi/README --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/README Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,1 @@ +SVG HMI diff -r 2b00f90c6888 -r d5b2369a103f svghmi/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/__init__.py Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/default.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/default.svg Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,92 @@ + + + + + + + + image/svg+xml + + + + + + + + + This is description for page 0 + +all lines in the form "name: value" +are used as js object definition initializer + +role: "page" +name: "Home" + +after triple opening braces is global JavaScript code + +{{{ +/* JS style Comment */ +alert("Hello World"); +}}} + +after triple closing braces is back to description + + + path: "count" +format: "%4.4d"8888 + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/detachable_pages.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/detachable_pages.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,226 @@ +// 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()" > ,` + } + | } + /* TODO generate some code for init() instead */ + apply "$parsed_widgets/widget[@id = $all_page_widgets/@id]", mode="per_page_widget_template"{ + 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="per_page_widget_template"; + + +emit "debug:detachable-pages" { + | + | DETACHABLES: + foreach "$detachable_elements"{ + | «@id» + } + | In Foreach: + foreach "$in_forEach_widget_ids"{ + | «.» + } + | Overlapping + apply "$overlapping_geometry", mode="testtree"; +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/fonts.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/fonts.py Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/gen_dnd_widget_svg.xslt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/gen_dnd_widget_svg.xslt Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @ + + + + + + + + + + + + + Widget dropped in Inkscape from Beremiz + + + + + No widget detected on selected SVG + + + + + Multiple widget DnD not yet supported + + + + + Widget incompatible with selected HMI tree node + + + + + + + + + + + + + + + + + + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/gen_dnd_widget_svg.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/gen_dnd_widget_svg.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,74 @@ +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" + xmlns:xhtml="http://www.w3.org/1999/xhtml" + + /* 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" { + + param "hmi_path"; + const "svg", "/svg:svg"; + const "hmi_elements", "//svg:*[starts-with(@inkscape:label, 'HMI:')]"; + const "subhmitree", "ns:GetSubHMITree()"; + + 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 "selected_node_type","local-name($subhmitree)"; + 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)"; + + svgtmpl "@*", mode="inline_svg" xsl:copy; + + svgtmpl "@inkscape:label[starts-with(., 'HMI:')]", mode="inline_svg" { + attrib "inkscape:label" > «substring-before(., '@')»@«$hmi_path» + } + + template "node()", mode="inline_svg" { + xsl:copy apply "@* | node()", mode="inline_svg"; + } + + + const "NODES_TYPES","str:split('HMI_ROOT HMI_NODE')"; + const "HMI_NODES_COMPAT","str:split('Page Jump Foreach')"; + 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 + when """$selected_node_type = $NODES_TYPES and \ + not($svg_widget_type = $HMI_NODES_COMPAT)""" + error > Widget incompatible with selected HMI tree node + } + + const "testmsg" { + msg value "$hmi_path"; + msg value "$selected_node_type"; + msg value "$svg_widget_type"; + } + + value "ns:GiveDetails($testmsg)"; + + apply "/", mode="inline_svg"; + } +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/gen_index_xhtml.xslt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/gen_index_xhtml.xslt Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,7920 @@ + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + =" + + " + + + + + + + + + + + + + + /* + + */ + + + + 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)]; + + } + + } + + + + + + + + + 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 + + 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)]; + + } + + } + + + + + + + + + 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)"); + + } + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + 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 + + + + + + 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 + + + + + + + + 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 + + + + + + + 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 + + + + + + function numb_event(e) { + + e.stopPropagation(); + + } + + 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", numb_event, true); + + svg_root.removeEventListener("pointerup", 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++; + + } + + } + + 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", numb_event, true); + + svg_root.addEventListener("pointerup", 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 + + 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(); + + } + + } + + + + 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(" + + "); + + + }, + + + + 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(" + + "); + + + } + + + + class JumpWidget extends Widget{ + + + + activable = false; + + active = false; + + disabled = false; + + frequency = 2; + + + + update_activity() { + + if(this.active) { + + /* show active */ + + this.active_elt.setAttribute("style", this.active_elt_style); + + /* hide inactive */ + + this.inactive_elt.setAttribute("style", "display:none"); + + } else { + + /* show inactive */ + + this.inactive_elt.setAttribute("style", this.inactive_elt_style); + + /* hide active */ + + this.active_elt.setAttribute("style", "display:none"); + + } + + } + + + + make_on_click() { + + let that = this; + + const name = this.args[0]; + + return function(evt){ + + /* TODO: suport path pointing to local variable whom value + + would be an HMI_TREE index to jump to a relative page */ + + 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_activity(); + + } + + } + + + + dispatch(value) { + + this.disabled = !Number(value); + + if(this.disabled) { + + /* show disabled */ + + this.disabled_elt.setAttribute("style", this.disabled_elt_style); + + /* hide inactive */ + + this.inactive_elt.setAttribute("style", "display:none"); + + /* hide active */ + + this.active_elt.setAttribute("style", "display:none"); + + } else { + + /* hide disabled */ + + this.disabled_elt.setAttribute("style", "display:none"); + + this.update_activity(); + + } + + } + + } + + + + + + + + + active inactive + + + + + + + + + + + disabled + + + + + + + init: function() { + + this.element.onclick = this.make_on_click(); + + + this.active_elt_style = this.active_elt.getAttribute("style"); + + this.inactive_elt_style = this.inactive_elt.getAttribute("style"); + + this.activable = true; + + + + + this.disabled_elt_style = this.disabled_elt.getAttribute("style"); + + + + this.unsubscribable = true; + + + + }, + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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; + + }; + + + + + + + + + + + /* + + */ + + + + + + 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: { + + + + + + : " + + ", + + + }, + + + + 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 + + + + + + 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: + + + + } + + , + + + + + ], + + + + 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); + + + }, + + + + 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 + + + + + + + + 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: + + + + } + + , + + + + + ], + + + + 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 2b00f90c6888 -r d5b2369a103f svghmi/gen_index_xhtml.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/gen_index_xhtml.ysl2 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/geometry.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/geometry.ysl2 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/hmi_tree.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/hmi_tree.py Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/hmi_tree.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/hmi_tree.ysl2 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/i18n.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/i18n.py Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/i18n.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/i18n.ysl2 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/inline_svg.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/inline_svg.ysl2 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/parse_labels.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/parse_labels.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,80 @@ +// 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"; +// } +// +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 { + const "pathminmax", "str:split(.,',')"; + const "path", "$pathminmax[1]"; + const "pathminmaxcount", "count($pathminmax)"; + attrib "value" > «$path» + choose { + when "$pathminmaxcount = 3" { + attrib "min" > «$pathminmax[2]» + attrib "max" > «$pathminmax[3]» + } + when "$pathminmaxcount = 2" { + error > Widget id:«$id» label:«$label» has wrong syntax of path section «$pathminmax» + } + } + 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» + } + } + } + } + } + } +} + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/pous.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/pous.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/svghmi.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/svghmi.c Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,370 @@ +#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 +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; + + /* zero means not subscribed */ + uint16_t refresh_period_ms; + uint16_t age_ms; + + /* 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) +{ + if(AtomicCompareExchange(&dsc->wlock, 0, 1) == 0) + { + if(dsc->wstate == buf_set){ + /* if being subscribed */ + if(dsc->refresh_period_ms){ + if(dsc->age_ms + ticktime_ms < dsc->refresh_period_ms){ + dsc->age_ms += ticktime_ms; + }else{ + dsc->wstate = buf_tosend; + global_write_dirty = 1; + } + } + } + + 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); + + /* if new value differs from previous one */ + USINT sz = __get_type_enum_size(dsc->type); + if(__Is_a_string(dsc)){ + sz = ((STRING*)visible_value_p)->len + 1; + } + if(dsc->wstate == buf_new /* just subscribed + or already subscribed having value change */ + || (dsc->refresh_period_ms > 0 && memcmp(dest_p, visible_value_p, sz) != 0)){ + /* copy and flag as set */ + memcpy(dest_p, visible_value_p, sz); + /* if not already marked/signaled, do it */ + if(dsc->wstate != buf_set && dsc->wstate != buf_tosend) { + if(dsc->wstate == buf_new || ticktime_ms > dsc->refresh_period_ms){ + dsc->wstate = buf_tosend; + global_write_dirty = 1; + } else { + dsc->wstate = buf_set; + } + dsc->age_ms = 0; + } + } + + AtomicCompareExchange(&dsc->wlock, 1, 0); + } + // else ... : PLC can't wait, variable will be updated next turn + return 0; +} + +static int send_iterator(uint32_t index, hmi_tree_item_t *dsc) +{ + while(AtomicCompareExchange(&dsc->wlock, 0, 1)) sched_yield(); + + if(dsc->wstate == 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 = 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, uint16_t refresh_period_ms) +{ + while(AtomicCompareExchange(&dsc->wlock, 0, 1)) sched_yield(); + if(refresh_period_ms) { + if(!dsc->refresh_period_ms) + { + dsc->wstate = buf_new; + } + } else { + dsc->wstate = buf_free; + } + dsc->refresh_period_ms = refresh_period_ms; + AtomicCompareExchange(&dsc->wlock, 1, 0); +} + +static int reset_iterator(uint32_t index, hmi_tree_item_t *dsc) +{ + update_refresh_period(dsc, 0); + return 0; +} + +void SVGHMI_SuspendFromPythonThread(void); +void SVGHMI_WakeupFromRTThread(void); + +static int continue_collect; + +int __init_svghmi() +{ + bzero(rbuf,sizeof(rbuf)); + bzero(wbuf,sizeof(wbuf)); + + continue_collect = 1; + + return 0; +} + +void __cleanup_svghmi() +{ + continue_collect = 0; + SVGHMI_WakeupFromRTThread(); +} + +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_send_collect(uint32_t *size, char **ptr){ + + SVGHMI_SuspendFromPythonThread(); + + if(continue_collect) { + int res; + sbufidx = HMI_HASH_SIZE; + 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; + +// Returns : +// 0 is OK, <0 is error, 1 is heartbeat +int svghmi_recv_dispatch(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)) sched_yield(); + + 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; + 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, 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 2b00f90c6888 -r d5b2369a103f svghmi/svghmi.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/svghmi.js Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,517 @@ +// 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 = new WebSocket(window.location.href.replace(/^http(s?:\/\/[^\/]*)\/.*$/, 'ws$1/ws')); +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 prepare_svg() { + // prevents context menu from appearing on right click and long touch + document.body.addEventListener('contextmenu', e => { + 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 2b00f90c6888 -r d5b2369a103f svghmi/svghmi.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/svghmi.py Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,709 @@ +#!/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 + +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 + + """ + 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 + + if hmi_tree_root is None: + self.FatalError("SVGHMI : Library is selected but not used. Please either deselect it in project config or add a SVGHMI node to project.") + + # 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())) + } + + 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 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 CTNGenerate_C(self, buildpath, locations): + + location_str = "_".join(map(str, self.GetCurrentLocation())) + view_name = self.BaseParams.getName() + + 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 " + view_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(""" + + +

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(port="8008", name=view_name))) + + ")") 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_watchdog_trigger(): + {svghmi_cmds[Watchdog]} + +svghmi_watchdog = None + +def _runtime_{location}_svghmi_start(): + global svghmi_watchdog + svghmi_root.putChild( + '{view_name}', + NoCacheFile('{xhtml}', + defaultType='application/xhtml+xml')) + + {svghmi_cmds[Start]} + + svghmi_watchdog = Watchdog( + {watchdog_initial}, + {watchdog_interval}, + svghmi_watchdog_trigger) + +def _runtime_{location}_svghmi_stop(): + global svghmi_watchdog + if svghmi_watchdog is not None: + svghmi_watchdog.cancel() + svghmi_watchdog = None + + svghmi_root.delEntity('{view_name}') + {svghmi_cmds[Stop]} + + """.format(location=location_str, + xhtml=target_fname, + view_name=view_name, + svghmi_cmds=svghmi_cmds, + watchdog_initial = self.GetParamsAttributes("SVGHMI.WatchdogInitial")["value"], + watchdog_interval = self.GetParamsAttributes("SVGHMI.WatchdogInterval")["value"], + )) + + 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 _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): + svgfile = None + 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) + + def CTNGlobalInstances(self): + # view_name = self.BaseParams.getName() + # return [ (view_name + "_" + name, iec_type, "") for name, iec_type in SPECIAL_NODES] + # TODO : move to library level for multiple hmi + return [(name, iec_type, "") for name, iec_type in SPECIAL_NODES] + + def GetIconName(self): + return "SVGHMI" diff -r 2b00f90c6888 -r d5b2369a103f svghmi/svghmi_server.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/svghmi_server.py Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,204 @@ +#!/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 + +# TODO multiclient : +# session list lock +# svghmi_sessions = [] +# svghmi_watchdogs = [] + +svghmi_session = None +svghmi_watchdog = None + +svghmi_send_collect = PLCBinary.svghmi_send_collect +svghmi_send_collect.restype = ctypes.c_int # error or 0 +svghmi_send_collect.argtypes = [ + ctypes.POINTER(ctypes.c_uint32), # size + ctypes.POINTER(ctypes.c_void_p)] # data ptr +# TODO multiclient : switch to arrays + +svghmi_recv_dispatch = PLCBinary.svghmi_recv_dispatch +svghmi_recv_dispatch.restype = ctypes.c_int # error or 0 +svghmi_recv_dispatch.argtypes = [ + ctypes.c_uint32, # size + ctypes.c_char_p] # data ptr +# TODO multiclient : switch to arrays + +class HMISession(object): + def __init__(self, protocol_instance): + global svghmi_session + + # Single client : + # Creating a new HMISession closes pre-existing HMISession + if svghmi_session is not None: + svghmi_session.close() + svghmi_session = self + self.protocol_instance = protocol_instance + + # TODO multiclient : + # svghmi_sessions.append(self) + # get a unique bit index amont other svghmi_sessions, + # so that we can match flags passed by C->python callback + + def close(self): + global svghmi_session + if svghmi_session == self: + svghmi_session = None + self.protocol_instance.sendClose(WebSocketProtocol.CLOSE_STATUS_CODE_NORMAL) + + def onMessage(self, msg): + # pass message to the C side recieve_message() + return svghmi_recv_dispatch(len(msg), msg) + + # TODO multiclient : pass client index as well + + def sendMessage(self, msg): + 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() + # wait for initial timeout on re-start + self.feed(rearm=False) + +class HMIProtocol(WebSocketServerProtocol): + + def __init__(self, *args, **kwargs): + self._hmi_session = None + WebSocketServerProtocol.__init__(self, *args, **kwargs) + + def onOpen(self): + assert(self._hmi_session is None) + self._hmi_session = HMISession(self) + + def onClose(self, wasClean, code, reason): + self._hmi_session = None + + def onMessage(self, msg, isBinary): + assert(self._hmi_session is not None) + + result = self._hmi_session.onMessage(msg) + if result == 1 : # was heartbeat + if svghmi_watchdog is not None: + svghmi_watchdog.feed() + +class HMIWebSocketServerFactory(WebSocketServerFactory): + protocol = HMIProtocol + +svghmi_root = None +svghmi_listener = None +svghmi_send_thread = None + +def SendThreadProc(): + global svghmi_session + size = ctypes.c_uint32() + ptr = ctypes.c_void_p() + res = 0 + while True: + res=svghmi_send_collect(ctypes.byref(size), ctypes.byref(ptr)) + if res == 0: + # TODO multiclient : dispatch to sessions + if svghmi_session is not None: + 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 watchdog_trigger(): + print("SVGHMI watchdog trigger") + + +# Called by PLCObject at start +def _runtime_00_svghmi_start(): + global svghmi_listener, svghmi_root, svghmi_send_thread + + svghmi_root = Resource() + svghmi_root.putChild("ws", WebSocketResource(HMIWebSocketServerFactory())) + + svghmi_listener = reactor.listenTCP(8008, Site(svghmi_root), interface='localhost') + + # 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_listener, svghmi_root, svghmi_send_thread, svghmi_session + + if svghmi_session is not None: + svghmi_session.close() + svghmi_root.delEntity("ws") + svghmi_root = None + svghmi_listener.stopListening() + svghmi_listener = None + # 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 2b00f90c6888 -r d5b2369a103f svghmi/ui.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/ui.py Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,356 @@ +#!/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 +from tempfile import NamedTemporaryFile + +import wx + +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__) + +class HMITreeSelector(wx.TreeCtrl): + def __init__(self, parent): + global on_hmitree_update + wx.TreeCtrl.__init__(self, parent, style=( + wx.TR_MULTIPLE | + wx.TR_HAS_BUTTONS | + wx.SUNKEN_BORDER | + wx.TR_LINES_AT_ROOT)) + + self.MakeTree() + + 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 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: + self._recurseTree(lib_dir, self.root, []) + self.Expand(self.root) + + self.Thaw() + +_conf_key = "SVGHMIWidgetLib" +_preview_height = 200 +class WidgetLibBrowser(wx.Panel): + def __init__(self, parent, id=wx.ID_ANY, pos=wx.DefaultPosition, + size=wx.DefaultSize): + + wx.Panel.__init__(self, parent, id, pos, size) + + self.bmp = None + self.msg = None + self.hmitree_node = None + self.selected_SVG = None + + self.Config = wx.ConfigBase.Get() + self.libdir = self.RecallLibDir() + + sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=3, vgap=0) + sizer.AddGrowableCol(0) + sizer.AddGrowableRow(1) + self.libbutton = wx.Button(self, -1, _("Select SVG widget library")) + self.widgetpicker = WidgetPicker(self, self.libdir) + self.preview = wx.Panel(self, size=(-1, _preview_height + 10)) + sizer.AddWindow(self.libbutton, flag=wx.GROW) + sizer.AddWindow(self.widgetpicker, flag=wx.GROW) + sizer.AddWindow(self.preview, flag=wx.GROW) + sizer.Layout() + self.SetAutoLayout(True) + self.SetSizer(sizer) + sizer.Fit(self) + self.Bind(wx.EVT_BUTTON, self.OnSelectLibDir, self.libbutton) + self.preview.Bind(wx.EVT_PAINT, self.OnPaint) + self.preview.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown) + + self.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnWidgetSelection, self.widgetpicker) + + self.msg = _("Drag selected Widget from here to Inkscape") + self.tempf = None + + 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, 5) + + if self.msg: + dc.SetFont(self.GetFont()) + dc.DrawText(self.msg, 25,25) + + + 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.ValidateWidget() + except IOError: + self.msg = _("Widget library must be writable") + + self.Refresh() + event.Skip() + + def OnHMITreeNodeSelection(self, hmitree_node): + self.hmitree_node = hmitree_node + self.ValidateWidget() + self.Refresh() + + 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 GiveDetails(self, _context, msgs): + for msg in msgs: + self.msg += msg.text + "\n" + + def GetSubHMITree(self, _context): + return [self.hmitree_node.etree()] + + def ValidateWidget(self): + self.msg = "" + + 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")) + if self.hmitree_node is None: + raise Exception(_("No HMI tree node selected")) + + transform = XSLTransform( + os.path.join(ScriptDirectory, "gen_dnd_widget_svg.xslt"), + [("GetSubHMITree", self.GetSubHMITree), + ("GiveDetails", self.GiveDetails)]) + + svgdom = etree.parse(self.selected_SVG) + + result = transform.transform( + svgdom, hmi_path = self.hmitree_node.hmi_path()) + + 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)) + self.Bind(wx.EVT_TREE_SEL_CHANGED, + self.OnHMITreeNodeSelection, self.SelectionTree) + + def OnHMITreeNodeSelection(self, event): + item_pydata = self.SelectionTree.GetPyData(event.GetItem()) + self.Staging.OnHMITreeNodeSelection(item_pydata) + + def HMITreeUpdate(self, hmi_tree_root): + self.SelectionTree.MakeTree(hmi_tree_root) + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_animate.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_animate.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,53 @@ +// widget_animate.ysl2 + +template "widget[@type='Animate']", mode="widget_class"{ + || + 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)]; + } + } + || +} + + +template "widget[@type='Animate']", mode="widget_defs" { + param "hmi_element"; + |, +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_animaterotation.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_animaterotation.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,51 @@ +// widget_animaterotation.ysl2 + +template "widget[@type='AnimateRotation']", mode="widget_class"{ + || + 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 + 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)]; + } + } + || +} + + +template "widget[@type='AnimateRotation']", mode="widget_defs" { + param "hmi_element"; + |, +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_back.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_back.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,17 @@ +// widget_back.ysl2 + +template "widget[@type='Back']", mode="widget_class" + || + 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)"); + } + } + || diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_button.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_button.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,155 @@ +// widget_button.ysl2 + +// 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); + +// 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»); +} + +template "widget[@type='Button']", mode="widget_class"{ + const "fsm","exsl:node-set($_button_fsm)"; + | class ButtonWidget extends Widget{ + | 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)); + | } + | } +} + + +template "widget[@type='Button']", mode="widget_defs" { + param "hmi_element"; + optional_labels("active inactive"); +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_circularbar.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_circularbar.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,60 @@ +// widget_circularbar.ysl2 + +template "widget[@type='CircularBar']", mode="widget_class"{ + || + 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]; + } + } + || +} + +template "widget[@type='CircularBar']", mode="widget_defs" { + param "hmi_element"; + labels("path"); + optional_labels("value min max"); +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_circularslider.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_circularslider.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,239 @@ +// widget_circuralslider.ysl2 + +template "widget[@type='CircularSlider']", mode="widget_class" + || + 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= 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"); + } + + } + } + || + +template "widget[@type='CircularSlider']", mode="widget_defs" { + param "hmi_element"; + labels("handle range"); + optional_labels("value min max setpoint"); + |, +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_custom.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_custom.ysl2 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/widget_customhtml.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_customhtml.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,33 @@ +// widget_customhtml.ysl2 + +template "widget[@type='CustomHtml']", mode="widget_class"{ + || + 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 =' '+ + this.code_elt.textContent+ + ' '; + } + } + || +} + + +template "widget[@type='CustomHtml']", mode="widget_defs" { + param "hmi_element"; + labels("container code"); + |, +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_display.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_display.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,291 @@ +// widget_display.ysl2 + + +template "widget[@type='Display']", mode="widget_class" + || + class DisplayWidget extends Widget{ + frequency = 5; + dispatch(value, oldval, index) { + this.fields[index] = value; + this.request_animate(); + } + } + || + +template "widget[@type='Display']", mode="widget_defs" { + param "hmi_element"; + + 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 2b00f90c6888 -r d5b2369a103f svghmi/widget_dropdown.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_dropdown.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,353 @@ +// widget_dropdown.ysl2 + +template "widget[@type='DropDown']", mode="widget_class"{ +|| + function numb_event(e) { + e.stopPropagation(); + } + 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", numb_event, true); + svg_root.removeEventListener("pointerup", 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++; + } + } + 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", numb_event, true); + svg_root.addEventListener("pointerup", 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; + } + } +|| +} + +template "widget[@type='DropDown']", mode="widget_defs" { + param "hmi_element"; + 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 2b00f90c6888 -r d5b2369a103f svghmi/widget_foreach.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_foreach.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,122 @@ + +template "widget[@type='ForEach']", mode="widget_defs" { + param "hmi_element"; + + 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, +} + +template "widget[@type='ForEach']", mode="widget_class" +|| +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(); + } +} +|| + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_input.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_input.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,95 @@ +// widget_input.ysl2 + +template "widget[@type='Input']", mode="widget_class"{ +|| + 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"); + } + + + } +|| +} + +template "widget[@type='Input']", mode="widget_defs" { + param "hmi_element"; + + 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 2b00f90c6888 -r d5b2369a103f svghmi/widget_jsontable.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_jsontable.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,285 @@ +// widget_jsontable.ysl2 + +template "widget[@type='JsonTable']", mode="widget_class" + || + 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); + // } + } + || + +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"; + | } +} + +template "widget[@type='JsonTable']", mode="widget_defs" { + param "hmi_element"; + 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 2b00f90c6888 -r d5b2369a103f svghmi/widget_jump.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_jump.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,124 @@ +// widget_jump.ysl2 + +template "widget[@type='Jump']", mode="widget_class"{ +|| + class JumpWidget extends Widget{ + + activable = false; + active = false; + disabled = false; + frequency = 2; + + update_activity() { + if(this.active) { + /* show active */ + this.active_elt.setAttribute("style", this.active_elt_style); + /* hide inactive */ + this.inactive_elt.setAttribute("style", "display:none"); + } else { + /* show inactive */ + this.inactive_elt.setAttribute("style", this.inactive_elt_style); + /* hide active */ + this.active_elt.setAttribute("style", "display:none"); + } + } + + make_on_click() { + let that = this; + const name = this.args[0]; + return function(evt){ + /* TODO: suport path pointing to local variable whom value + would be an HMI_TREE index to jump to a relative page */ + 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_activity(); + } + } + + dispatch(value) { + this.disabled = !Number(value); + if(this.disabled) { + /* show disabled */ + this.disabled_elt.setAttribute("style", this.disabled_elt_style); + /* hide inactive */ + this.inactive_elt.setAttribute("style", "display:none"); + /* hide active */ + this.active_elt.setAttribute("style", "display:none"); + } else { + /* hide disabled */ + this.disabled_elt.setAttribute("style", "display:none"); + this.update_activity(); + } + } + } +|| +} + +template "widget[@type='Jump']", mode="widget_defs" { + param "hmi_element"; + 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.active_elt_style = this.active_elt.getAttribute("style"); + | this.inactive_elt_style = this.inactive_elt.getAttribute("style"); + | this.activable = true; + } + choose { + when "$have_disability" { + | this.disabled_elt_style = this.disabled_elt.getAttribute("style"); + } + otherwise { + | this.unsubscribable = true; + } + } + | }, +} + +template "widget[@type='Jump']", mode="per_page_widget_template"{ + 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 2b00f90c6888 -r d5b2369a103f svghmi/widget_keypad.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_keypad.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,133 @@ +// widget_keypad.ysl2 + +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»], + } + } + | } +} + +template "widget[@type='Keypad']", mode="widget_class" + || + 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); + } + } + } + || + +template "widget[@type='Keypad']", mode="widget_defs" { + param "hmi_element"; + 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 2b00f90c6888 -r d5b2369a103f svghmi/widget_list.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_list.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,20 @@ +// widget_list.ysl2 + +template "widget[@type='List']", mode="widget_defs" { + param "hmi_element"; + | items: { + foreach "$hmi_element/*[@inkscape:label]" { + | «@inkscape:label»: "«@id»", + } + | }, +} + +template "widget[@type='TextStyleList']", mode="widget_defs" { + param "hmi_element"; + | styles: { + foreach "$hmi_element/*[@inkscape:label]" { + const "style", "func:refered_elements(.)[self::svg:text]/@style"; + | «@inkscape:label»: "«$style»", + } + | }, +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_meter.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_meter.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,43 @@ +// widget_meter.ysl2 + +template "widget[@type='Meter']", mode="widget_class"{ + || + 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); + } + + } + || +} + +template "widget[@type='Meter']", mode="widget_defs" { + param "hmi_element"; + labels("needle range"); + optional_labels("value min max"); +} + + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_multistate.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_multistate.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,60 @@ +// widget_multistate.ysl2 + +template "widget[@type='MultiState']", mode="widget_class" + || + 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 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)"); + } + } + || + +template "widget[@type='MultiState']", mode="widget_defs" { + param "hmi_element"; + | 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 2b00f90c6888 -r d5b2369a103f svghmi/widget_scrollbar.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_scrollbar.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,112 @@ +// widget_scrollbar.ysl2 + +template "widget[@type='ScrollBar']", mode="widget_class"{ + || + 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); + } + } + || +} + +template "widget[@type='ScrollBar']", mode="widget_defs" { + param "hmi_element"; + 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 2b00f90c6888 -r d5b2369a103f svghmi/widget_slider.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_slider.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,347 @@ +// widget_slider.ysl2 + +template "widget[@type='Slider']", mode="widget_class" + || + 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"); + } + + } + } + || + +template "widget[@type='Slider']", mode="widget_defs" { + param "hmi_element"; + labels("handle range"); + optional_labels("value min max setpoint"); + |, +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_switch.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_switch.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,37 @@ +// widget_switch.ysl2 + +template "widget[@type='Switch']", mode="widget_class" + || + 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); + } + } + } + } + || + +template "widget[@type='Switch']", mode="widget_defs" { + param "hmi_element"; + | 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»"), + | style:"«@style»", + | value:«$literal» + | }`if "position()!=last()" > ,` + } + | ], +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widget_tooglebutton.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_tooglebutton.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,51 @@ +// widget_tooglebutton.ysl2 + + +template "widget[@type='ToggleButton']", mode="widget_class"{ + || + 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); + } + } + || +} + +template "widget[@type='ToggleButton']", mode="widget_defs" { + param "hmi_element"; + optional_labels("active inactive"); +} diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widgetlib/keypads/alphanumeric_keypad.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/keypads/alphanumeric_keypad.svg Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f svghmi/widgetlib/keypads/numeric_keypad.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/keypads/numeric_keypad.svg Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,348 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + number + + + + + + + + 7 + + + + 4 + + + + 1 + + + + 8 + + + + 5 + + + + 2 + + + + 9 + + + + 6 + + + + 3 + + + + 0 + + + + + Esc + + + + + + + + +/- + + information + + + . + + + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widgetlib/meter_template.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/meter_template.svg Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + A sophisticated meter looking like real + +@position : HMI_INT, HMI_REAL # the position as int or real + + + + 0 + 10000 + [value] + + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widgetlib/modern_knob_1.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/modern_knob_1.svg Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,267 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + 65% + + + + + + + + + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widgetlib/voltmeter.svg --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgetlib/voltmeter.svg Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,791 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 2b00f90c6888 -r d5b2369a103f svghmi/widgets_common.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widgets_common.ysl2 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,413 @@ +// 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; + } +}; + +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 2b00f90c6888 -r d5b2369a103f targets/Linux/plc_Linux_main.c --- a/targets/Linux/plc_Linux_main.c Fri Apr 09 09:45:28 2021 +0200 +++ b/targets/Linux/plc_Linux_main.c Fri Apr 09 09:47:06 2021 +0200 @@ -235,3 +235,18 @@ { pthread_mutex_lock(&python_mutex); } + +static pthread_cond_t svghmi_send_WakeCond = PTHREAD_COND_INITIALIZER; +static pthread_mutex_t svghmi_send_WakeCondLock = PTHREAD_MUTEX_INITIALIZER; + +void SVGHMI_SuspendFromPythonThread(void) +{ + pthread_mutex_lock(&svghmi_send_WakeCondLock); + pthread_cond_wait(&svghmi_send_WakeCond, &svghmi_send_WakeCondLock); + pthread_mutex_unlock(&svghmi_send_WakeCondLock); +} + +void SVGHMI_WakeupFromRTThread(void) +{ + pthread_cond_signal(&svghmi_send_WakeCond); +} diff -r 2b00f90c6888 -r d5b2369a103f targets/Xenomai/plc_Xenomai_main.c --- a/targets/Xenomai/plc_Xenomai_main.c Fri Apr 09 09:45:28 2021 +0200 +++ b/targets/Xenomai/plc_Xenomai_main.c Fri Apr 09 09:47:06 2021 +0200 @@ -26,6 +26,8 @@ #define PLC_STATE_WAITDEBUG_PIPE_CREATED 64 #define PLC_STATE_WAITPYTHON_FILE_OPENED 128 #define PLC_STATE_WAITPYTHON_PIPE_CREATED 256 +#define PLC_STATE_SVGHMI_FILE_OPENED 512 +#define PLC_STATE_SVGHMI_PIPE_CREATED 1024 #define WAITDEBUG_PIPE_DEVICE "/dev/rtp0" #define WAITDEBUG_PIPE_MINOR 0 @@ -35,6 +37,8 @@ #define WAITPYTHON_PIPE_MINOR 2 #define PYTHON_PIPE_DEVICE "/dev/rtp3" #define PYTHON_PIPE_MINOR 3 +#define SVGHMI_PIPE_DEVICE "/dev/rtp4" +#define SVGHMI_PIPE_MINOR 4 #define PIPE_SIZE 1 // rt-pipes commands @@ -68,10 +72,12 @@ RT_PIPE WaitPython_pipe; RT_PIPE Debug_pipe; RT_PIPE Python_pipe; +RT_PIPE svghmi_pipe; int WaitDebug_pipe_fd; int WaitPython_pipe_fd; int Debug_pipe_fd; int Python_pipe_fd; +int svghmi_pipe_fd; int PLC_shutdown = 0; @@ -114,6 +120,16 @@ PLC_state &= ~PLC_STATE_TASK_CREATED; } + if (PLC_state & PLC_STATE_SVGHMI_PIPE_CREATED) { + rt_pipe_delete(&svghmi_pipe); + PLC_state &= ~PLC_STATE_SVGHMI_PIPE_CREATED; + } + + if (PLC_state & PLC_STATE_SVGHMI_FILE_OPENED) { + close(svghmi_pipe_fd); + PLC_state &= ~PLC_STATE_SVGHMI_FILE_OPENED; + } + if (PLC_state & PLC_STATE_WAITDEBUG_PIPE_CREATED) { rt_pipe_delete(&WaitDebug_pipe); PLC_state &= ~PLC_STATE_WAITDEBUG_PIPE_CREATED; @@ -240,6 +256,16 @@ _startPLCLog(FO WAITPYTHON_PIPE_DEVICE); PLC_state |= PLC_STATE_WAITPYTHON_FILE_OPENED; + /* create svghmi_pipe */ + if(rt_pipe_create(&svghmi_pipe, "svghmi_pipe", SVGHMI_PIPE_MINOR, PIPE_SIZE) < 0) + _startPLCLog(FO "svghmi_pipe real-time end"); + PLC_state |= PLC_STATE_SVGHMI_PIPE_CREATED; + + /* open svghmi_pipe*/ + if((svghmi_pipe_fd = open(SVGHMI_PIPE_DEVICE, O_RDWR)) == -1) + _startPLCLog(FO SVGHMI_PIPE_DEVICE); + PLC_state |= PLC_STATE_SVGHMI_FILE_OPENED; + /*** create PLC task ***/ if(rt_task_create(&PLC_task, "PLC_task", 0, 50, T_JOINABLE)) _startPLCLog("Failed creating PLC task"); @@ -395,6 +421,18 @@ } /* as plc does not wait for lock. */ } +void SVGHMI_SuspendFromPythonThread(void) +{ + char cmd = 1; /*whatever*/ + read(svghmi_pipe_fd, &cmd, sizeof(cmd)); +} + +void SVGHMI_WakeupFromRTThread(void) +{ + char cmd; + rt_pipe_write(&svghmi_pipe, &cmd, sizeof(cmd), P_NORMAL); +} + #ifndef HAVE_RETAIN int CheckRetainBuffer(void) { diff -r 2b00f90c6888 -r d5b2369a103f tests/svghmi/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi/beremiz.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 2b00f90c6888 -r d5b2369a103f tests/svghmi/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi/plc.xml Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,7364 @@ + + + + + + + + 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 2b00f90c6888 -r d5b2369a103f tests/svghmi_i18n/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/beremiz.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 2b00f90c6888 -r d5b2369a103f tests/svghmi_i18n/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_i18n/plc.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TargetPressure + + + + + + + + + + + + + selection + + + + + + + + + + + + + + + + + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f tests/svghmi_i18n/svghmi_0@svghmi/fr_FR.mo Binary file tests/svghmi_i18n/svghmi_0@svghmi/fr_FR.mo has changed diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f tests/svghmi_i18n/svghmi_0@svghmi/sl_SI.mo Binary file tests/svghmi_i18n/svghmi_0@svghmi/sl_SI.mo has changed diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f tests/svghmi_real/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_real/beremiz.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 2b00f90c6888 -r d5b2369a103f tests/svghmi_real/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_real/plc.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + var0 + + + + + + + + + + + + + var1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f tests/svghmi_scrollbar/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_scrollbar/beremiz.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 2b00f90c6888 -r d5b2369a103f tests/svghmi_scrollbar/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_scrollbar/plc.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + var0 + + + + + + + + + + + + + var1 + + + + + + + + + + + + + + + + + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f tests/svghmi_v2/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_v2/beremiz.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 2b00f90c6888 -r d5b2369a103f tests/svghmi_v2/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_v2/plc.xml Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f tests/svghmi_widgets/beremiz.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_widgets/beremiz.xml Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,5 @@ + + + + + diff -r 2b00f90c6888 -r d5b2369a103f tests/svghmi_widgets/plc.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/svghmi_widgets/plc.xml Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 2021 +0200 @@ -0,0 +1,2 @@ + + diff -r 2b00f90c6888 -r d5b2369a103f 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 Fri Apr 09 09:47:06 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 2b00f90c6888 -r d5b2369a103f util/ProcessLogger.py --- a/util/ProcessLogger.py Fri Apr 09 09:45:28 2021 +0200 +++ b/util/ProcessLogger.py Fri Apr 09 09:47:06 2021 +0200 @@ -28,7 +28,8 @@ import sys import subprocess import ctypes -from threading import Timer, Lock, Thread, Semaphore +import time +from threading import Timer, Lock, Thread, Semaphore, Condition import signal _debug = os.path.exists("BEREMIZ_DEBUG") @@ -154,6 +155,10 @@ self.errt.start() self.startsem.release() + self.spinwakeuplock = Lock() + self.spinwakeupcond = Condition(self.spinwakeuplock) + self.spinwakeuptimer = None + def output(self, v): if v and self.output_encoding: v = v.decode(self.output_encoding) @@ -192,6 +197,7 @@ self.finish_callback(self, ecode, pid) self.errt.join() self.finishsem.release() + self.spinwakeup() def kill(self, gently=True): # avoid running kill before start is finished @@ -222,7 +228,25 @@ if not self.outt.finished and self.kill_it: self.kill() self.finishsem.release() + self.spinwakeup() + + def spinwakeup(self): + with self.spinwakeuplock: + if self.spinwakeuptimer is not None: + self.spinwakeuptimer.cancel() + self.spinwakeuptimer = None + self.spinwakeupcond.notify() def spin(self): - self.finishsem.acquire() + start = time.time() + if self.logger: + while not self.finishsem.acquire(0): + with self.spinwakeuplock: + self.spinwakeuptimer = Timer(0.1, self.spinwakeup) + self.spinwakeuptimer.start() + self.spinwakeupcond.wait() + self.logger.progress("%.3fs"%(time.time() - start)) + else: + self.finishsem.acquire() + return [self.exitcode, "".join(self.outdata), "".join(self.errdata)]