# HG changeset patch # User Tomaz Orac # Date 1677502056 -3600 # Node ID ed1ec3136c2b4ee899384d9228cdf9050326ef15 # Parent 7e8db0b44e42df4aa4b0fe2284305e04f767a22c# Parent d1acf20e8e7ced22fd9de2d26fbf5034f1433563 Merge. diff -r d1acf20e8e7c -r ed1ec3136c2b controls/PouInstanceVariablesPanel.py --- a/controls/PouInstanceVariablesPanel.py Sun Feb 19 08:37:27 2023 +0000 +++ b/controls/PouInstanceVariablesPanel.py Mon Feb 27 13:47:36 2023 +0100 @@ -174,6 +174,19 @@ self.DebugInstanceImage: _ButtonCallbacks( self.DebugButtonCallback, self.DebugButtonDClickCallback)} + self.FilterCtrl = wx.SearchCtrl(self) + self.FilterCtrl.ShowCancelButton(True) + self.FilterCtrl.Bind(wx.EVT_TEXT, self.OnFilterUpdate) + self.FilterCtrl.Bind(wx.EVT_SEARCHCTRL_CANCEL_BTN, self.OnFilterCancel) + + searchMenu = wx.Menu() + item = searchMenu.AppendCheckItem(-1, _("Match Case")) + self.Bind(wx.EVT_MENU, self.OnSearchMenu, item) + item = searchMenu.AppendCheckItem(-1, _("Whole Words")) + self.Bind(wx.EVT_MENU, self.OnSearchMenu, item) + self.FilterCtrl.SetMenu(searchMenu) + + buttons_sizer = wx.FlexGridSizer(cols=3, hgap=0, rows=1, vgap=0) buttons_sizer.AddWindow(self.ParentButton) buttons_sizer.AddWindow(self.InstanceChoice, flag=wx.GROW) @@ -181,9 +194,10 @@ buttons_sizer.AddGrowableCol(1) buttons_sizer.AddGrowableRow(0) - main_sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=2, vgap=0) + main_sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=3, vgap=0) main_sizer.AddSizer(buttons_sizer, flag=wx.GROW) main_sizer.AddWindow(self.VariablesList, flag=wx.GROW) + main_sizer.AddWindow(self.FilterCtrl, flag=wx.GROW) main_sizer.AddGrowableCol(0) main_sizer.AddGrowableRow(1) @@ -199,6 +213,11 @@ self.PouInfos = None self.PouInstance = None + self.Filter = None + self.FilterCaseSensitive = False + self.FilterWholeWord = False + + def __del__(self): self.Controller = None @@ -236,6 +255,21 @@ self.RefreshView() + def OnSearchMenu(self, event): + searchMenu = self.FilterCtrl.GetMenu().GetMenuItems() + self.FilterCaseSensitive = searchMenu[0].IsChecked() + self.FilterWholeWord = searchMenu[1].IsChecked() + self.RefreshView() + + def OnFilterUpdate(self, event): + self.Filter = self.FilterCtrl.GetValue() + self.RefreshView() + event.Skip() + + def OnFilterCancel(self, event): + self.FilterCtrl.SetValue('') + event.Skip() + def RefreshView(self): self.Freeze() self.VariablesList.DeleteAllItems() @@ -252,6 +286,15 @@ if self.PouInfos is not None: root = self.VariablesList.AddRoot("", data=self.PouInfos) for var_infos in self.PouInfos.variables: + if self.Filter: + pattern = self.Filter + varname = var_infos.name + if not self.FilterCaseSensitive: + pattern = pattern.upper() + varname = varname.upper() + if ((pattern != varname) if self.FilterWholeWord else + (pattern not in varname)): + continue if var_infos.type is not None: text = "%s (%s)" % (var_infos.name, var_infos.type) else: diff -r d1acf20e8e7c -r ed1ec3136c2b exemples/svghmi_references/plc.xml --- a/exemples/svghmi_references/plc.xml Sun Feb 19 08:37:27 2023 +0000 +++ b/exemples/svghmi_references/plc.xml Mon Feb 27 13:47:36 2023 +0100 @@ -1,7 +1,7 @@ - + @@ -22,12 +22,12 @@ - + - + - + @@ -39,7 +39,7 @@ - LocalVar0 + PLCHMIVAR @@ -50,7 +50,7 @@ - LocalVar1 + LocalVar0 diff -r d1acf20e8e7c -r ed1ec3136c2b exemples/svghmi_references/svghmi_0@svghmi/svghmi.svg --- a/exemples/svghmi_references/svghmi_0@svghmi/svghmi.svg Sun Feb 19 08:37:27 2023 +0000 +++ b/exemples/svghmi_references/svghmi_0@svghmi/svghmi.svg Mon Feb 27 13:47:36 2023 +0100 @@ -25,7 +25,7 @@ image/svg+xml - + @@ -90,9 +90,9 @@ inkscape:window-height="836" id="namedview4" showgrid="false" - inkscape:zoom="0.92709556" - inkscape:cx="1883.1062" - inkscape:cy="712.84763" + inkscape:zoom="0.23177389" + inkscape:cx="1502.9251" + inkscape:cy="-465.32787" inkscape:window-x="0" inkscape:window-y="27" inkscape:window-maximized="1" @@ -103,7 +103,8 @@ fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" - fit-margin-bottom="0" /> + fit-margin-bottom="0" + inkscape:pagecheckerboard="true" /> + style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.99999988;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:4, 4;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> Switch widget - - - - - Home - - - - - Swith - - - - - Buttons - - - - declaration of "position" HMI local variable - - - - - Show popup 1 - - - - Show popup 2 - - - + x="76.354057" + y="466.02609" /> + x="496.35406" + y="466.02609" /> + height="190.44576" + x="254.41968" + y="382.29605" /> Page + x="141.59323" + y="402.47458">Page (inkscape) final position in page offset positionoffset positionfor "B" offset positionfor "C" HMI:Switch@... (group) |-. "A" (group) | |- reference (rect) | |- ... |-. "B" (group) | |- frame (rect) | |- ... |-. "C" (group) | |- frame (rect) | |- ... @@ -774,74 +592,280 @@ sodipodi:role="line" x="317.2059" y="99.850906" - id="tspan1259" - style="text-align:start;text-anchor:start;stroke-width:1px">groups that represent the possible states of the widget.groups that represent the possible states of the widget. Since all groups need to appear in the same place, they overlap and Since all groups need to appear in the same place, they overlap the drawing becomes hard to understand and maintain.and the drawing becomes hard to understand and maintain.Using specially labelled "reference" and "frame" rectangles, Using specially labelled "reference" and "frame" rectangles, groups can be spread out. Theses rectangles can be used groups can be spread out. + style="text-align:start;text-anchor:start;stroke-width:1px" + id="tspan474">in widget or anywhere in the drawing, and do not appear in final result. reference + x="137.59323" + y="560.47461">reference frame frame + x="557.59326" + y="560.47461">frame + Button widgets + HMI:Switch@... (group) |- reference (rect) |-. "A" (group) | |- ... |-. "B" (group) | |- frame (rect) | |- ... |-. "C" (group) | |- frame (rect) | |- ... + or + + + simple + + + + with widgets + + + user choice : %s + + + selected dialog : %s + + Switch and Assign widgets can be used together to simulate behavior modal dialog or "popup" with user feedback."selection" and "userChoice" local HMI are used to respectivelyselect dialog to be shown and store user choice.Here, "reference" and "frame" rectangles are necessary toto spread out dialogs and page, otherwise overlapping. + inkscape:label=""simple""> - - - - Close - - A MODAL DIALOG + sodipodi:role="line">A SIMPLE MODAL DIALOG + + + OK + + + + Cancel + + + + + X + + + inkscape:label=""withWidgets""> - - - - Close - - A MODAL DIALOGA MODAL DIALOGwith widgets + y="1068.5115">with widgets + + + + X + + + + :dialog="None" +:return="Applied" +:plcvar=uservar +@dialog=selection +@return=userChoice +@uservar=.position +@plcvar=/PLCHMIVAR + + Apply + + + + In this example, 3 types of button ar connected to the sameHMI local variable. Here, "reference" and "frame" rectangles are used toseparate active and inactive state of buttons + A + B + C + + + Page (final result) + + + A + + + + B + + + + C + + + + + + + Home + + + + + Swith + + + + + Buttons - + + declaration of user_level HMI local variabledeclaration of "position" HMI local variable + + + declaration of 'selection' local variable + + + declaration of 'userChoice' local variable + + + (not a PLC variable) - - - declaration of "range" HMI local variable + + + declaration of "range" HMI local variable - - - declaration of "size" HMI local variable + + + declaration of "size" HMI local variable - - Button widgets - - declaration of "position" HMI local variable + id="text193">declaration of "position" HMI local variable + diff -r d1acf20e8e7c -r ed1ec3136c2b svghmi/analyse_widget.xslt --- a/svghmi/analyse_widget.xslt Sun Feb 19 08:37:27 2023 +0000 +++ b/svghmi/analyse_widget.xslt Mon Feb 27 13:47:36 2023 +0100 @@ -262,6 +262,42 @@ speed + + + + + + + + Arguments are either: + + + + - name=value: setting variable with literal value. + + - name=other_name: copy variable content into another + + + + "active"+"inactive" labeled elements can be provided to show feedback when pressed + + + + Exemples: + + + + HMI:Assign:notify=1@notify=/PLCVAR + + HMI:Assign:ack=2:notify=1@ack=.local_var@notify=/PLCVAR + + + + + + Assign variables on click + + diff -r d1acf20e8e7c -r ed1ec3136c2b svghmi/detachable_pages.ysl2 --- a/svghmi/detachable_pages.ysl2 Sun Feb 19 08:37:27 2023 +0000 +++ b/svghmi/detachable_pages.ysl2 Mon Feb 27 13:47:36 2023 +0100 @@ -25,6 +25,17 @@ emit "preamble:default-page" { | | var default_page = "«$default_page»"; + const "screensaverpage", "$hmi_pages_descs[arg[1]/@value = 'ScreenSaver']"; + const "delay" choose { + when "$screensaverpage" { + const "delaystr", "$screensaverpage/arg[2]/@value"; + if "not(regexp:test($delaystr,'^[0-9]+$'))" + error > ScreenSaver page has missing or malformed delay argument. + value "$delaystr"; + } + otherwise > null + } + | var screensaver_delay = «$delay»; } const "keypads_descs", "$parsed_widgets/widget[@type = 'Keypad']"; diff -r d1acf20e8e7c -r ed1ec3136c2b svghmi/gen_index_xhtml.xslt --- a/svghmi/gen_index_xhtml.xslt Sun Feb 19 08:37:27 2023 +0000 +++ b/svghmi/gen_index_xhtml.xslt Mon Feb 27 13:47:36 2023 +0100 @@ -555,6 +555,23 @@ + + + + + + + + + + + + + + + + + @@ -620,6 +637,27 @@ "; + + + + + + + + ScreenSaver page has missing or malformed delay argument. + + + + + + null + + + + var screensaver_delay = + + ; + @@ -657,7 +695,8 @@ - + + @@ -890,6 +929,14 @@ + DISCARDABLES: + + + + + + + In Foreach: @@ -945,6 +992,21 @@ + + + + + + + + + + + + + + + @@ -1516,8 +1578,6 @@ var cache = hmitree_types.map(_ignored => undefined); - var updates = new Map(); - function page_local_index(varname, pagename){ @@ -1530,7 +1590,7 @@ new_index = next_available_index++; - hmi_locals[pagename] = {[varname]:new_index} + hmi_locals[pagename] = {[varname]:new_index}; } else { @@ -1556,8 +1616,6 @@ cache[new_index] = defaultval; - updates.set(new_index, defaultval); - if(persistent_locals.has(varname)) persistent_indexes.set(new_index, varname); @@ -2656,6 +2714,199 @@ } + + + + + + + + Arguments are either: + + + + - name=value: setting variable with literal value. + + - name=other_name: copy variable content into another + + + + "active"+"inactive" labeled elements can be provided to show feedback when pressed + + + + Exemples: + + + + HMI:Assign:notify=1@notify=/PLCVAR + + HMI:Assign:ack=2:notify=1@ack=.local_var@notify=/PLCVAR + + + + + + Assign variables on click + + + + class + AssignWidget + extends Widget{ + + frequency = 2; + + + + onmouseup(evt) { + + svg_root.removeEventListener("pointerup", this.bound_onmouseup, true); + + if(this.enable_state) { + + this.activity_state = false + + this.request_animate(); + + this.assign(); + + } + + } + + + + onmousedown(){ + + if(this.enable_state) { + + svg_root.addEventListener("pointerup", this.bound_onmouseup, true); + + this.activity_state = true; + + this.request_animate(); + + } + + } + + + + } + + + + + + + + + /disabled + + + + + + + activable_sub:{ + + + + + + /active /inactive + + + no + + + + + + }, + + has_activity: + + , + + init: function() { + + this.bound_onmouseup = this.onmouseup.bind(this); + + this.element.addEventListener("pointerdown", this.onmousedown.bind(this)); + + }, + + assignments: {}, + + dispatch: function(value, oldval, varnum) { + + + + + + + + + if(varnum == + + ) this.assignments[" + + "] = value; + + + + + + }, + + assign: function() { + + + + + + + + + + + + + + + + const + + = this.assignments[" + + "]; + + if( + + != undefined) + + this.apply_hmi_value( + + , + + ); + + + + this.apply_hmi_value( + + , + + ); + + + + + }, + + @@ -2677,9 +2928,17 @@ if(jump_history.length > 1){ - jump_history.pop(); - - let [page_name, index] = jump_history.pop(); + let page_name, index; + + do { + + jump_history.pop(); // forget current page + + if(jump_history.length == 0) return; + + [page_name, index] = jump_history[jump_history.length-1]; + + } while(page_name == "ScreenSaver") // never go back to ScreenSaver switch_page(page_name, index); @@ -2689,7 +2948,7 @@ init() { - this.element.setAttribute("onclick", "hmi_widgets['"+this.element_id+"'].on_click(evt)"); + this.element.onclick = this.on_click.bind(this); } @@ -5923,39 +6182,31 @@ frequency = 2; - - - make_on_click() { - - let that = this; - - const name = this.args[0]; - - return function(evt){ - - /* TODO: in order to allow jumps to page selected through - - for exemple a dropdown, support path pointing to local - - variable whom value would be an HMI_TREE index and then - - jump to a relative page not hard-coded in advance - - */ - - if(that.enable_state) { - - const index = - - (that.is_relative && that.indexes.length > 0) ? - - that.indexes[0] + that.offset : undefined; - - fading_page_switch(name, index); - - that.notify(); - - } + target_page_is_current_page = false; + + button_beeing_pressed = false; + + + + onmouseup(evt) { + + svg_root.removeEventListener("pointerup", this.bound_onmouseup, true); + + if(this.enable_state) { + + const index = + + (this.is_relative && this.indexes.length > 0) ? + + this.indexes[0] + this.offset : undefined; + + this.button_beeing_pressed = false; + + this.activity_state = this.target_page_is_current_page || this.button_beeing_pressed; + + fading_page_switch(this.args[0], index); + + this.notify(); } @@ -5963,6 +6214,24 @@ + onmousedown(){ + + if(this.enable_state) { + + svg_root.addEventListener("pointerup", this.bound_onmouseup, true); + + this.button_beeing_pressed = true; + + this.activity_state = true; + + this.request_animate(); + + } + + } + + + notify_page_change(page_name, index) { // called from animate() @@ -5973,7 +6242,9 @@ const ref_name = this.args[0]; - this.activity_state = ((ref_name == undefined || ref_name == page_name) && index == ref_index); + this.target_page_is_current_page = ((ref_name == undefined || ref_name == page_name) && index == ref_index); + + this.activity_state = this.target_page_is_current_page || this.button_beeing_pressed; // Since called from animate, update activity directly @@ -6031,7 +6302,9 @@ init: function() { - this.element.onclick = this.make_on_click(); + this.bound_onmouseup = this.onmouseup.bind(this); + + this.element.addEventListener("pointerdown", this.onmousedown.bind(this)); this.activable = true; @@ -11045,7 +11318,1241 @@ - var ws_url = + 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 */ + + } + + }; + + + + // Called on requestAnimationFrame, modifies DOM + + var requestAnimationFrameID = null; + + function animate() { + + let rearm = true; + + do{ + + if(page_fading == "pending" || page_fading == "forced"){ + + if(page_fading == "pending") + + svg_root.classList.add("fade-out-page"); + + page_fading = "in_progress"; + + if(page_fading_args.length) + + setTimeout(function(){ + + switch_page(...page_fading_args); + + },1); + + break; + + } + + + + // Do the page swith if pending + + if(page_switch_in_progress){ + + if(current_subscribed_page != current_visible_page){ + + switch_visible_page(current_subscribed_page); + + } + + + + page_switch_in_progress = false; + + + + if(page_fading == "in_progress"){ + + svg_root.classList.remove("fade-out-page"); + + page_fading = "off"; + + } + + } + + + + if(jumps_need_update) update_jumps(); + + + + + + pending_widget_animates.forEach(widget => widget._animate()); + + pending_widget_animates = []; + + rearm = false; + + } while(0); + + + + requestAnimationFrameID = null; + + + + if(rearm) requestHMIAnimation(); + + } + + + + 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 + + function ws_onmessage(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); + + dispatch_value(index, value); + + i += bytesize; + + } else { + + throw new Error("Unknown index "+index); + + } + + }; + + + + // register for rendering on next frame, since there are updates + + } 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); + + + + var ws = null; + + + + function send_blob(data) { + + if(ws && data.length > 0) { + + ws.send(new Blob([hmi_hash_u8].concat(data))); + + }; + + }; + + + + const typedarray_types = { + + INT: (number) => new Int16Array([number]), + + BOOL: (truth) => new Int8Array([truth]), + + NODE: (truth) => new Int8Array([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; + + } + + } + + + + function reset_subscription_periods() { + + for(let index in subscriptions) + + subscriptions[index][1] = 0; + + } + + + + if(has_watchdog){ + + // 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); + + } + + }); + + } + + + + + + var page_fading = "off"; + + var page_fading_args = "off"; + + function fading_page_switch(...args){ + + if(page_fading == "in_progress") + + page_fading = "forced"; + + else + + page_fading = "pending"; + + page_fading_args = args; + + + + requestHMIAnimation(); + + + + } + + document.body.style.backgroundColor = "black"; + + + + // subscribe to per instance current page hmi variable + + // PLC must prefix page name with "!" for page switch to happen + + subscribers(current_page_var_index).add({ + + frequency: 1, + + indexes: [current_page_var_index], + + new_hmi_value: function(index, value, oldval) { + + if(value.startsWith("!")) + + fading_page_switch(value.slice(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, blank) { + + str.split('\n').map((line,i) => {elt.children[i].textContent = blank?"":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(); + + } + + }); + + + + // returns en_US, fr_FR or en_UK depending on selected language + + function get_current_lang_code(){ + + return cache[langcode_local_index]; + + } + + + + 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 = []; + + if(!ws) + + // dont' change subscriptions if not connected + + return; + + + + 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){ + + dispatch_value(index, value); + + + + if(persistent_indexes.has(index)){ + + let varname = persistent_indexes.get(index); + + document.cookie = varname+"="+value+"; max-age=3153600000"; + + } + + + + 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) { + + // Similarly to previous comment, taking decision to update based + + // on cache content is bad and can lead to inconsistency + + /*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"); + + var page_switch_in_progress = false; + + + + function toggleFullscreen() { + + let elem = document.documentElement; + + + + if (!document.fullscreenElement) { + + elem.requestFullscreen().catch(err => { + + console.log("Error attempting to enable full-screen mode: "+err.message+" ("+err.name+")"); + + }); + + } else { + + document.exitFullscreen(); + + } + + } + + + + // prevents context menu from appearing on right click and long touch + + document.body.addEventListener('contextmenu', e => { + + toggleFullscreen(); + + e.preventDefault(); + + }); + + + + var screensaver_timer = null; + + function reset_screensaver_timer() { + + if(screensaver_timer){ + + window.clearTimeout(screensaver_timer); + + } + + screensaver_timer = window.setTimeout(() => { + + switch_page("ScreenSaver"); + + screensaver_timer = null; + + }, screensaver_delay*1000); + + } + + if(screensaver_delay) + + document.body.addEventListener('pointerdown', reset_screensaver_timer); + + + + function detach_detachables() { + + + + for(let eltid in detachable_elements){ + + let [element,parent] = detachable_elements[eltid]; + + parent.removeChild(element); + + } + + }; + + + + function switch_page(page_name, page_index) { + + if(page_switch_in_progress){ + + /* page switch already going */ + + /* TODO LOG ERROR */ + + return false; + + } + + page_switch_in_progress = true; + + + + if(page_name == undefined) + + page_name = current_subscribed_page; + + else if(page_index == undefined){ + + [page_name, page_index] = page_name.split('@') + + } + + + + 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; + + else if(typeof(page_index) == "string") { + + let hmitree_node = hmitree_nodes[page_index]; + + if(hmitree_node !== undefined){ + + let [int_index, hmiclass] = hmitree_node; + + if(hmiclass == new_desc.page_class) + + page_index = int_index; + + else + + page_index = new_desc.page_index; + + } else { + + 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(); + + let [last_page_name, last_page_index] = jump_history[jump_history.length-1]; + + if(last_page_name != page_name || last_page_index != page_index){ + + jump_history.push([page_name, page_index]); + + if(jump_history.length > 42) + + jump_history.shift(); + + } + + + + apply_hmi_value(current_page_var_index, page_index == undefined + + ? page_name + + : page_name + "@" + hmitree_paths[page_index]); + + + + 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; + + }; + + + + /* From https://jsfiddle.net/ibowankenobi/1mmh7rs6/6/ */ + + function getAbsoluteCTM(element){ + + var height = svg_root.height.baseVal.value, + + width = svg_root.width.baseVal.value, + + viewBoxRect = svg_root.viewBox.baseVal, + + vHeight = viewBoxRect.height, + + vWidth = viewBoxRect.width; + + if(!vWidth || !vHeight){ + + return element.getCTM(); + + } + + var sH = height/vHeight, + + sW = width/vWidth, + + matrix = svg_root.createSVGMatrix(); + + matrix.a = sW; + + matrix.d = sH + + var realCTM = element.getCTM().multiply(matrix.inverse()); + + realCTM.e = realCTM.e/sW + viewBoxRect.x; + + realCTM.f = realCTM.f/sH + viewBoxRect.y; + + return realCTM; + + } + + + + function apply_reference_frames(){ + + const matches = svg_root.querySelectorAll("g[svghmi_x_offset]"); + + matches.forEach((group) => { + + let [x,y] = ["x", "y"].map((axis) => Number(group.getAttribute("svghmi_"+axis+"_offset"))); + + let ctm = getAbsoluteCTM(group); + + // zero translation part of CTM + + // to only apply rotation/skewing to offset vector + + ctm.e = 0; + + ctm.f = 0; + + let invctm = ctm.inverse(); + + let vect = new DOMPoint(x, y); + + let newvect = vect.matrixTransform(invctm); + + let transform = svg_root.createSVGTransform(); + + transform.setTranslate(newvect.x, newvect.y); + + group.transform.baseVal.appendItem(transform); + + ["x", "y"].forEach((axis) => group.removeAttribute("svghmi_"+axis+"_offset")); + + }); + + } + + + + // prepare SVG + + apply_reference_frames(); + + init_widgets(); + + detach_detachables(); + + + + // show main page + + switch_page(default_page); + + + + // initialize screensaver + + reset_screensaver_timer(); + + + + var reconnect_delay = 0; + + var periodic_reconnect_timer; + + + + // Once connection established + + function ws_onopen(evt) { + + // Work around memory leak with websocket on QtWebEngine + + // reconnect every hour to force deallocate websocket garbage + + if(window.navigator.userAgent.includes("QtWebEngine")){ + + if(periodic_reconnect_timer){ + + window.clearTimeout(periodic_reconnect_timer); + + } + + periodic_reconnect_timer = window.setTimeout(() => { + + ws.close(); + + periodic_reconnect_timer = null; + + }, 3600000); + + } + + + + // forget earlier subscriptions locally + + reset_subscription_periods(); + + + + // update PLC about subscriptions and current page + + switch_page(); + + + + // at first try reconnect immediately + + reconnect_delay = 1; + + }; + + + + function ws_onclose(evt) { + + console.log("Connection closed. code:"+evt.code+" reason:"+evt.reason+" wasClean:"+evt.wasClean+" Reload in "+reconnect_delay+"ms."); + + ws = null; + + // reconect + + // TODO : add visible notification while waiting for reload + + window.setTimeout(create_ws, reconnect_delay); + + reconnect_delay += 500; + + }; + + + + var ws_url = window.location.href.replace(/^http(s?:\/\/[^\/]*)\/.*$/, 'ws$1/ws') @@ -11053,1067 +12560,23 @@ - var ws = new WebSocket(ws_url); - - ws.binaryType = 'arraybuffer'; - - - - const dvgetters = { - - INT: (dv,offset) => [dv.getInt16(offset, true), 2], - - BOOL: (dv,offset) => [dv.getInt8(offset, true), 1], - - NODE: (dv,offset) => [dv.getInt8(offset, true), 1], - - REAL: (dv,offset) => [dv.getFloat32(offset, true), 4], - - STRING: (dv, offset) => { - - const size = dv.getInt8(offset); - - return [ - - String.fromCharCode.apply(null, new Uint8Array( - - dv.buffer, /* original buffer */ - - offset + 1, /* string starts after size*/ - - size /* size of string */ - - )), size + 1]; /* total increment */ - - } - - }; - - - - // Apply updates recieved through ws.onmessage to subscribed widgets - - function apply_updates() { - - updates.forEach((value, index) => { - - dispatch_value(index, value); - - }); - - updates.clear(); + function create_ws(){ + + ws = new WebSocket(ws_url); + + ws.binaryType = 'arraybuffer'; + + ws.onmessage = ws_onmessage; + + ws.onclose = ws_onclose; + + ws.onopen = ws_onopen; } - // Called on requestAnimationFrame, modifies DOM - - var requestAnimationFrameID = null; - - function animate() { - - let rearm = true; - - do{ - - if(page_fading == "pending" || page_fading == "forced"){ - - if(page_fading == "pending") - - svg_root.classList.add("fade-out-page"); - - page_fading = "in_progress"; - - if(page_fading_args.length) - - setTimeout(function(){ - - switch_page(...page_fading_args); - - },1); - - break; - - } - - - - // Do the page swith if pending - - if(page_switch_in_progress){ - - if(current_subscribed_page != current_visible_page){ - - switch_visible_page(current_subscribed_page); - - } - - - - page_switch_in_progress = false; - - - - if(page_fading == "in_progress"){ - - svg_root.classList.remove("fade-out-page"); - - page_fading = "off"; - - } - - } - - - - if(jumps_need_update) update_jumps(); - - - - - - pending_widget_animates.forEach(widget => widget._animate()); - - pending_widget_animates = []; - - rearm = false; - - } while(0); - - - - requestAnimationFrameID = null; - - - - if(rearm) requestHMIAnimation(); - - } - - - - 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); - - } - - }; - - - - apply_updates(); - - // register for rendering on next frame, since there are updates - - } 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; - - } - - } - - - - if(has_watchdog){ - - // 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); - - } - - }); - - } - - - - - - var page_fading = "off"; - - var page_fading_args = "off"; - - function fading_page_switch(...args){ - - if(page_fading == "in_progress") - - page_fading = "forced"; - - else - - page_fading = "pending"; - - page_fading_args = args; - - - - requestHMIAnimation(); - - - - } - - document.body.style.backgroundColor = "black"; - - - - // subscribe to per instance current page hmi variable - - // PLC must prefix page name with "!" for page switch to happen - - subscribers(current_page_var_index).add({ - - frequency: 1, - - indexes: [current_page_var_index], - - new_hmi_value: function(index, value, oldval) { - - if(value.startsWith("!")) - - fading_page_switch(value.slice(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, blank) { - - str.split('\n').map((line,i) => {elt.children[i].textContent = blank?"":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(); - - } - - }); - - - - // returns en_US, fr_FR or en_UK depending on selected language - - function get_current_lang_code(){ - - return cache[langcode_local_index]; - - } - - - - 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){ - - dispatch_value(index, value); - - - - if(persistent_indexes.has(index)){ - - let varname = persistent_indexes.get(index); - - document.cookie = varname+"="+value+"; max-age=3153600000"; - - } - - - - 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) { - - // Similarly to previous comment, taking decision to update based - - // on cache content is bad and can lead to inconsistency - - /*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"); - - var page_switch_in_progress = false; - - - - function toggleFullscreen() { - - let elem = document.documentElement; - - - - if (!document.fullscreenElement) { - - elem.requestFullscreen().catch(err => { - - console.log("Error attempting to enable full-screen mode: "+err.message+" ("+err.name+")"); - - }); - - } else { - - document.exitFullscreen(); - - } - - } - - - - function prepare_svg() { - - // prevents context menu from appearing on right click and long touch - - document.body.addEventListener('contextmenu', e => { - - toggleFullscreen(); - - e.preventDefault(); - - }); - - - - for(let eltid in detachable_elements){ - - let [element,parent] = detachable_elements[eltid]; - - parent.removeChild(element); - - } - - }; - - - - function switch_page(page_name, page_index) { - - if(page_switch_in_progress){ - - /* page switch already going */ - - /* TODO LOG ERROR */ - - return false; - - } - - page_switch_in_progress = true; - - - - if(page_name == undefined) - - page_name = current_subscribed_page; - - else if(page_index == undefined){ - - [page_name, page_index] = page_name.split('@') - - } - - - - 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; - - else if(typeof(page_index) == "string") { - - let hmitree_node = hmitree_nodes[page_index]; - - if(hmitree_node !== undefined){ - - let [int_index, hmiclass] = hmitree_node; - - if(hmiclass == new_desc.page_class) - - page_index = int_index; - - else - - page_index = new_desc.page_index; - - } else { - - 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(); - - - - apply_hmi_value(current_page_var_index, page_index == undefined - - ? page_name - - : page_name + "@" + hmitree_paths[page_index]); - - - - 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+"."); - - - - }; + create_ws() diff -r d1acf20e8e7c -r ed1ec3136c2b svghmi/svghmi.js --- a/svghmi/svghmi.js Sun Feb 19 08:37:27 2023 +0000 +++ b/svghmi/svghmi.js Mon Feb 27 13:47:36 2023 +0100 @@ -23,13 +23,6 @@ // Open WebSocket to relative "/ws" address var has_watchdog = window.location.hash == "#watchdog"; -var ws_url = - window.location.href.replace(/^http(s?:\/\/[^\/]*)\/.*$/, 'ws$1/ws') - + '?mode=' + (has_watchdog ? "watchdog" : "multiclient"); - -var ws = new WebSocket(ws_url); -ws.binaryType = 'arraybuffer'; - const dvgetters = { INT: (dv,offset) => [dv.getInt16(offset, true), 2], BOOL: (dv,offset) => [dv.getInt8(offset, true), 1], @@ -98,7 +91,7 @@ // 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) { +function ws_onmessage(evt) { let data = evt.data; let dv = new DataView(data); @@ -140,16 +133,18 @@ hmi_hash_u8 = new Uint8Array(hmi_hash); +var ws = null; + function send_blob(data) { - if(data.length > 0) { + if(ws && 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]), + BOOL: (truth) => new Int8Array([truth]), + NODE: (truth) => new Int8Array([truth]), REAL: (number) => new Float32Array([number]), STRING: (str) => { // beremiz default string max size is 128 @@ -199,6 +194,11 @@ } } +function reset_subscription_periods() { + for(let index in subscriptions) + subscriptions[index][1] = 0; +} + if(has_watchdog){ // artificially subscribe the watchdog widget to "/heartbeat" hmi variable // Since dispatch directly calls change_hmi_value, @@ -298,6 +298,10 @@ function update_subscriptions() { let delta = []; + if(!ws) + // dont' change subscriptions if not connected + return; + for(let index in subscriptions){ let widgets = subscribers(index); @@ -418,12 +422,30 @@ } } -function prepare_svg() { - // prevents context menu from appearing on right click and long touch - document.body.addEventListener('contextmenu', e => { - toggleFullscreen(); - e.preventDefault(); - }); +// prevents context menu from appearing on right click and long touch +document.body.addEventListener('contextmenu', e => { + toggleFullscreen(); + e.preventDefault(); +}); + +if(screensaver_delay){ + var screensaver_timer = null; + function reset_screensaver_timer() { + if(screensaver_timer){ + window.clearTimeout(screensaver_timer); + } + screensaver_timer = window.setTimeout(() => { + switch_page("ScreenSaver"); + screensaver_timer = null; + }, screensaver_delay*1000); + } + document.body.addEventListener('pointerdown', reset_screensaver_timer); + // initialize screensaver + reset_screensaver_timer(); +} + + +function detach_detachables() { for(let eltid in detachable_elements){ let [element,parent] = detachable_elements[eltid]; @@ -492,9 +514,12 @@ jumps_need_update = true; requestHMIAnimation(); - jump_history.push([page_name, page_index]); - if(jump_history.length > 42) - jump_history.shift(); + let [last_page_name, last_page_index] = jump_history[jump_history.length-1]; + if(last_page_name != page_name || last_page_index != page_index){ + jump_history.push([page_name, page_index]); + if(jump_history.length > 42) + jump_history.shift(); + } apply_hmi_value(current_page_var_index, page_index == undefined ? page_name @@ -572,24 +597,63 @@ }); } +// prepare SVG +apply_reference_frames(); +init_widgets(); +detach_detachables(); + +// show main page +switch_page(default_page); + +var reconnect_delay = 0; +var periodic_reconnect_timer; + // Once connection established -ws.onopen = function (evt) { - apply_reference_frames(); - init_widgets(); - send_reset(); - // show main page - prepare_svg(); - switch_page(default_page); -}; - -ws.onclose = function (evt) { +function ws_onopen(evt) { + // Work around memory leak with websocket on QtWebEngine + // reconnect every hour to force deallocate websocket garbage + if(window.navigator.userAgent.includes("QtWebEngine")){ + if(periodic_reconnect_timer){ + window.clearTimeout(periodic_reconnect_timer); + } + periodic_reconnect_timer = window.setTimeout(() => { + ws.close(); + periodic_reconnect_timer = null; + }, 3600000); + } + + // forget earlier subscriptions locally + reset_subscription_periods(); + + // update PLC about subscriptions and current page + switch_page(); + + // at first try reconnect immediately + reconnect_delay = 1; +}; + +function ws_onclose(evt) { + console.log("Connection closed. code:"+evt.code+" reason:"+evt.reason+" wasClean:"+evt.wasClean+" Reload in "+reconnect_delay+"ms."); + ws = null; + // reconect // 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+"."); - -}; + window.setTimeout(create_ws, reconnect_delay); + reconnect_delay += 500; +}; + +var ws_url = + window.location.href.replace(/^http(s?:\/\/[^\/]*)\/.*$/, 'ws$1/ws') + + '?mode=' + (has_watchdog ? "watchdog" : "multiclient"); + +function create_ws(){ + ws = new WebSocket(ws_url); + ws.binaryType = 'arraybuffer'; + ws.onmessage = ws_onmessage; + ws.onclose = ws_onclose; + ws.onopen = ws_onopen; +} + +create_ws() const xmlns = "http://www.w3.org/2000/svg"; var edit_callback; diff -r d1acf20e8e7c -r ed1ec3136c2b svghmi/svghmi.py --- a/svghmi/svghmi.py Sun Feb 19 08:37:27 2023 +0000 +++ b/svghmi/svghmi.py Mon Feb 27 13:47:36 2023 +0100 @@ -636,19 +636,31 @@ svghmi_cmds[thing] = ( "Popen(" + repr(shlex.split(given_command.format(**svghmi_options))) + - ")") if given_command else "pass # no command given" + ")") if given_command else "None # 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) +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# generated by beremiz/svghmi/svghmi.py + +browser_proc = None + def svghmi_{location}_watchdog_trigger(): - {svghmi_cmds[Watchdog]} + global browser_proc + restart_proc = {svghmi_cmds[Watchdog]} + waitpid_timeout(restart_proc, "SVGHMI watchdog triggered command") + stop_proc = {svghmi_cmds[Stop]} + waitpid_timeout(stop_proc, "SVGHMI stop command") + waitpid_timeout(browser_proc, "SVGHMI browser process") + browser_proc = {svghmi_cmds[Start]} max_svghmi_sessions = {maxConnections_total} def _runtime_{location}_svghmi_start(): - global svghmi_watchdog, svghmi_servers + global svghmi_watchdog, svghmi_servers, browser_proc srv = svghmi_servers.get("{interface}:{port}", None) if srv is not None: @@ -673,7 +685,7 @@ path_list.append("{path}") - {svghmi_cmds[Start]} + browser_proc = {svghmi_cmds[Start]} if {enable_watchdog}: if svghmi_watchdog is None: @@ -686,7 +698,7 @@ def _runtime_{location}_svghmi_stop(): - global svghmi_watchdog, svghmi_servers + global svghmi_watchdog, svghmi_servers, browser_proc if svghmi_watchdog is not None: svghmi_watchdog.cancel() @@ -702,7 +714,10 @@ svghmi_listener.stopListening() svghmi_servers.pop("{interface}:{port}") - {svghmi_cmds[Stop]} + stop_proc = {svghmi_cmds[Stop]} + waitpid_timeout(stop_proc, "SVGHMI stop command") + waitpid_timeout(browser_proc, "SVGHMI browser process") + browser_proc = None """.format(location=location_str, xhtml=target_fname, diff -r d1acf20e8e7c -r ed1ec3136c2b svghmi/svghmi_server.py --- a/svghmi/svghmi_server.py Sun Feb 19 08:37:27 2023 +0000 +++ b/svghmi/svghmi_server.py Mon Feb 27 13:47:36 2023 +0100 @@ -8,6 +8,7 @@ from __future__ import absolute_import import errno from threading import RLock, Timer +import os, time try: from runtime.spawn_subprocess import Popen @@ -23,6 +24,9 @@ from autobahn.websocket.protocol import WebSocketProtocol from autobahn.twisted.resource import WebSocketResource +from runtime.loglevels import LogLevelsDict +from runtime import GetPLCObjectSingleton + max_svghmi_sessions = None svghmi_watchdog = None @@ -219,6 +223,7 @@ _hmi_session = HMISession(self) registered = svghmi_session_manager.register(_hmi_session) self._hmi_session = _hmi_session + self._hmi_session.reset() def onClose(self, wasClean, code, reason): global svghmi_session_manager @@ -299,3 +304,21 @@ render_HEAD = render_GET +def waitpid_timeout(proc, helpstr="", timeout = 3): + if proc is None: + return + def waitpid_timeout_loop(pid=proc.pid, timeout = timeout): + try: + while os.waitpid(pid,os.WNOHANG) == (0,0): + time.sleep(1) + timeout = timeout - 1 + if not timeout: + GetPLCObjectSingleton().LogMessage( + LogLevelsDict["WARNING"], + "Timeout waiting for {} PID: {}".format(helpstr, str(pid))) + break + except OSError: + # workaround exception "OSError: [Errno 10] No child processes" + pass + Thread(target=waitpid_timeout_loop, name="Zombie hunter").start() + diff -r d1acf20e8e7c -r ed1ec3136c2b svghmi/widget_assign.ysl2 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/widget_assign.ysl2 Mon Feb 27 13:47:36 2023 +0100 @@ -0,0 +1,88 @@ +// widget_assign.ysl2 + +widget_desc("Assign") { + longdesc + || + + Arguments are either: + + - name=value: setting variable with literal value. + - name=other_name: copy variable content into another + + "active"+"inactive" labeled elements can be provided to show feedback when pressed + + Exemples: + + HMI:Assign:notify=1@notify=/PLCVAR + HMI:Assign:ack=2:notify=1@ack=.local_var@notify=/PLCVAR + + || + + shortdesc > Assign variables on click + +} + +widget_class("Assign") { +|| + frequency = 2; + + onmouseup(evt) { + svg_root.removeEventListener("pointerup", this.bound_onmouseup, true); + if(this.enable_state) { + this.activity_state = false + this.request_animate(); + this.assign(); + } + } + + onmousedown(){ + if(this.enable_state) { + svg_root.addEventListener("pointerup", this.bound_onmouseup, true); + this.activity_state = true; + this.request_animate(); + } + } + +|| +} + +widget_defs("Assign") { + optional_activable(); + + | init: function() { + | this.bound_onmouseup = this.onmouseup.bind(this); + | this.element.addEventListener("pointerdown", this.onmousedown.bind(this)); + | }, + + | assignments: {}, + | dispatch: function(value, oldval, varnum) { + const "widget", "."; + foreach "path" { + const "varid","generate-id()"; + const "varnum","position()-1"; + if "@assign" foreach "$widget/path[@assign]" if "$varid = generate-id()" { + | if(varnum == «$varnum») this.assignments["«@assign»"] = value; + } + } + | }, + | assign: function() { + const "paths","path"; + foreach "arg[contains(@value,'=')]"{ + const "name","substring-before(@value,'=')"; + const "value","substring-after(@value,'=')"; + const "index" foreach "$paths" if "@assign = $name" value "position()-1"; + const "isVarName", "regexp:test($value,'^[a-zA-Z_][a-zA-Z0-9_]+$')"; + choose { + when "$isVarName"{ + | const «$value» = this.assignments["«$value»"]; + | if(«$value» != undefined) + | this.apply_hmi_value(«$index», «$value»); + } + otherwise { + | this.apply_hmi_value(«$index», «$value»); + } + } + } + | }, +} + diff -r d1acf20e8e7c -r ed1ec3136c2b svghmi/widget_back.ysl2 --- a/svghmi/widget_back.ysl2 Sun Feb 19 08:37:27 2023 +0000 +++ b/svghmi/widget_back.ysl2 Mon Feb 27 13:47:36 2023 +0100 @@ -9,17 +9,20 @@ shortdesc > Jump to previous page } -// TODO: use es6 widget_class("Back") || on_click(evt) { if(jump_history.length > 1){ - jump_history.pop(); - let [page_name, index] = jump_history.pop(); + let page_name, index; + do { + jump_history.pop(); // forget current page + if(jump_history.length == 0) return; + [page_name, index] = jump_history[jump_history.length-1]; + } while(page_name == "ScreenSaver") // never go back to ScreenSaver switch_page(page_name, index); } } init() { - this.element.setAttribute("onclick", "hmi_widgets['"+this.element_id+"'].on_click(evt)"); + this.element.onclick = this.on_click.bind(this); } || diff -r d1acf20e8e7c -r ed1ec3136c2b svghmi/widget_jump.ysl2 --- a/svghmi/widget_jump.ysl2 Sun Feb 19 08:37:27 2023 +0000 +++ b/svghmi/widget_jump.ysl2 Mon Feb 27 13:47:36 2023 +0100 @@ -52,23 +52,28 @@ || activable = false; frequency = 2; + target_page_is_current_page = false; + button_beeing_pressed = false; - make_on_click() { - let that = this; - const name = this.args[0]; - return function(evt){ - /* TODO: in order to allow jumps to page selected through - for exemple a dropdown, support path pointing to local - variable whom value would be an HMI_TREE index and then - jump to a relative page not hard-coded in advance - */ - if(that.enable_state) { - const index = - (that.is_relative && that.indexes.length > 0) ? - that.indexes[0] + that.offset : undefined; - fading_page_switch(name, index); - that.notify(); - } + onmouseup(evt) { + svg_root.removeEventListener("pointerup", this.bound_onmouseup, true); + if(this.enable_state) { + const index = + (this.is_relative && this.indexes.length > 0) ? + this.indexes[0] + this.offset : undefined; + this.button_beeing_pressed = false; + this.activity_state = this.target_page_is_current_page || this.button_beeing_pressed; + fading_page_switch(this.args[0], index); + this.notify(); + } + } + + onmousedown(){ + if(this.enable_state) { + svg_root.addEventListener("pointerup", this.bound_onmouseup, true); + this.button_beeing_pressed = true; + this.activity_state = true; + this.request_animate(); } } @@ -77,7 +82,8 @@ if(this.activable) { const ref_index = this.indexes.length > 0 ? this.indexes[0] + this.offset : undefined; const ref_name = this.args[0]; - this.activity_state = ((ref_name == undefined || ref_name == page_name) && index == ref_index); + this.target_page_is_current_page = ((ref_name == undefined || ref_name == page_name) && index == ref_index); + this.activity_state = this.target_page_is_current_page || this.button_beeing_pressed; // Since called from animate, update activity directly if(this.enable_displayed_state && this.has_activity) { this.animate_activity(); @@ -98,7 +104,8 @@ const "jump_disability","$has_activity and $has_disability"; | init: function() { - | this.element.onclick = this.make_on_click(); + | this.bound_onmouseup = this.onmouseup.bind(this); + | this.element.addEventListener("pointerdown", this.onmousedown.bind(this)); if "$has_activity" { | this.activable = true; } diff -r d1acf20e8e7c -r ed1ec3136c2b targets/plc_debug.c --- a/targets/plc_debug.c Sun Feb 19 08:37:27 2023 +0000 +++ b/targets/plc_debug.c Mon Feb 27 13:47:36 2023 +0100 @@ -154,8 +154,6 @@ UnpackVar(dsc, &value_p, NULL, &size); - printf("Reminding %%d %%ld \n", retain_list_collect_cursor, size); - /* if buffer not full */ Remind(retain_offset, size, value_p); /* increment cursor according size*/ @@ -211,8 +209,6 @@ retain_list_collect_cursor++; } - printf("Retain size %%d \n", retain_size); - return retain_size; } diff -r d1acf20e8e7c -r ed1ec3136c2b tests/projects/svghmi/svghmi_0@svghmi/svghmi.svg --- a/tests/projects/svghmi/svghmi_0@svghmi/svghmi.svg Sun Feb 19 08:37:27 2023 +0000 +++ b/tests/projects/svghmi/svghmi_0@svghmi/svghmi.svg Mon Feb 27 13:47:36 2023 +0100 @@ -137,8 +137,8 @@ showgrid="false" units="px" inkscape:zoom="0.40092403" - inkscape:cx="323.58553" - inkscape:cy="-56.756946" + inkscape:cx="2333.0807" + inkscape:cy="1015.6842" inkscape:window-width="1600" inkscape:window-height="836" inkscape:window-x="0" @@ -6543,6 +6543,31 @@ style="text-align:center;text-anchor:middle;fill:#ff6600;stroke-width:0.99999994px">Home + + + Back + Home + + + + + + + + + + + + + Leave ScreenSaver + + + + + + + Home + + + + + + + + Settings + + + + + + + + Pump 0 + + + + + + + + Pump 1 + + + + + + + + Pump 2 + + + + + + + + Pump 3 + +