# This file is part of Beremiz
# Copyright (C) 2021: Edouard TISSERANT
# See COPYING file for copyrights details.
from __future__ import absolute_import
from lxml.etree import XSLTApplyError
from IDEFrame import EncodeFileSystemPath, DecodeFileSystemPath
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
from editors.ConfTreeNodeEditor import ConfTreeNodeEditor
from XSLTransform import XSLTransform
from svghmi.i18n import EtreeToMessages, SaveCatalog, ReadTranslations, MatchTranslations, TranslationToEtree, open_pofile
from svghmi.hmi_tree import HMI_TYPES, HMITreeNode, SPECIAL_NODES
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
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
hmi_types_instances = [v for v in varlist if v["derived"] in HMI_TYPES]
# take first HMI_NODE (placed as special node), make it root
for i,v in enumerate(hmi_types_instances):
path = v["IEC_path"].split(".")
if derived == "HMI_NODE":
hmi_tree_root = HMITreeNode(path, "", derived, v["type"], v["vartype"], v["C_path"])
hmi_types_instances.pop(i)
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_"):
if derived == "HMI_NODE":
# TODO : make problem if HMI_NODE used in CONFIG or RESOURCE
kwargs['hmiclass'] = 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),
if v["C_path"] == problematic_node:
failing_parent = last_FB["type"]
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),
self.FatalError("SVGHMI : " + message)
if on_hmitree_update is not None:
extern_variables_declarations = []
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
extern_variables_declarations += [
"#define heartbeat_index "+str(hmi_tree_hearbeat_index)
if hasattr(node, "iectype"):
sz = DebugTypesSize.get(node.iectype, 0)
"{&(" + node.cpath + "), " + node.iectype + {
str(buf_index) + ", 0, }"]
extern_variables_declarations += [
"extern __IEC_" + node.iectype + "_" +
"t" if node.vartype is "VAR" else "p"
# TODO : filter only requiered external declarations
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_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)
# Python based WebSocket HMITree Server
svghmiserverfile = open(paths.AbsNeighbourFile(__file__, "svghmi_server.py"), 'r')
svghmiservercode = svghmiserverfile.read()
runtimefile_path = os.path.join(buildpath, "runtime_00_svghmi.py")
runtimefile = open(runtimefile_path, 'w')
runtimefile.write(svghmiservercode)
# 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 SVGHMIEditorUpdater(ref):
def SVGHMIEditorUpdate():
return SVGHMIEditorUpdate
class HMITreeSelector(wx.TreeCtrl):
def __init__(self, parent):
wx.TreeCtrl.__init__(self, parent, style=(
on_hmitree_update = SVGHMIEditorUpdater(weakref.ref(self))
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, None) # TODO
self._recurseTree(c,tc_child)
display_name = '{} {}'.format(c.nodetype[4:], c.name)
tc_child = self.AppendItem(current_tc_root, display_name)
self.SetPyData(tc_child, None) # TODO
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, None)
if hmi_tree_root is not None:
self._recurseTree(hmi_tree_root, self.root)
class WidgetPicker(wx.TreeCtrl):
def __init__(self, parent, initialdir=None):
wx.TreeCtrl.__init__(self, parent, style=(
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
for f in sorted(os.listdir(current_dir)):
p = os.path.join(current_dir,f)
r = self._recurseTree(p, current_tc_root, dirlist + [f])
current_tc_root = res.pop()
elif os.path.splitext(f)[1].upper() == ".SVG":
current_tc_root = self.AppendItem(current_tc_root, d)
res.append(current_tc_root)
self.SetPyData(current_tc_root, None)
tc_child = self.AppendItem(current_tc_root, f)
self.SetPyData(tc_child, p)
def MakeTree(self, lib_dir = None):
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)
self._recurseTree(lib_dir, self.root, [])
_conf_key = "SVGHMIWidgetLib"
class WidgetLibBrowser(wx.Panel):
def __init__(self, parent, id=wx.ID_ANY, pos=wx.DefaultPosition,
wx.Panel.__init__(self, parent, id, pos, size)
self.Config = wx.ConfigBase.Get()
self.libdir = self.RecallLibDir()
sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=3, vgap=0)
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)) #, style=wx.SIMPLE_BORDER)
#self.preview.SetBackgroundColour(wx.WHITE)
sizer.AddWindow(self.libbutton, flag=wx.GROW)
sizer.AddWindow(self.widgetpicker, flag=wx.GROW)
sizer.AddWindow(self.preview, flag=wx.GROW)
self.Bind(wx.EVT_BUTTON, self.OnSelectLibDir, self.libbutton)
self.preview.Bind(wx.EVT_PAINT, self.OnPaint)
self.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnWidgetSelection, self.widgetpicker)
self.msg = _("Drag selected Widget from here to Inkscape")
conf = self.Config.Read(_conf_key)
return DecodeFileSystemPath(conf)
def RememberLibDir(self, path):
self.Config.Write(_conf_key,
EncodeFileSystemPath(path))
# Init preview panel paint device context
dc = wx.PaintDC(self.preview)
sz = self.preview.GetClientSize()
dc.DrawBitmap(self.bmp, (sz.width - w)/2, 5)
dc.SetFont(self.GetFont())
dc.DrawText(self.msg, 25,25)
def OnSelectLibDir(self, event):
defaultpath = self.RecallLibDir()
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)
def OnPaint(self, event):
Called when Preview panel needs to be redrawn
@param event: wx.PaintEvent
def GenThumbnail(self, svgpath, thumbpath):
inkpath = get_inkscape_path()
self.msg = _("Inkscape is not installed.")
# TODO: spawn a thread, to decouple thumbnail gen
status, result, _err_result = ProcessLogger(
'"' + inkpath + '" "' + svgpath + '" -e "' + thumbpath +
'" -D -h ' + str(_preview_height)).spin()
self.msg = _("Inkscape couldn't generate thumbnail.")
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:
dname = os.path.dirname(svgpath)
fname = os.path.basename(svgpath)
hasher = hashlib.new('md5')
with open(svgpath, 'rb') as afile:
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)
if not os.path.exists(thumbdir):
self.msg = _("Widget library must be writable")
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
def OnHMITreeNodeSelection(self, hmitree_node):
self.hmitree_node = hmitree_node
def ValidateWidget(self):
if self.selected_SVG is not None:
if self.hmitree_node is not None:
# - check SVG is valid for selected HMI tree item
class HMITreeView(wx.SplitterWindow):
def __init__(self, parent):
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)
class SVGHMIEditor(ConfTreeNodeEditor):
(_("HMI Tree"), "CreateHMITreeView")]
def CreateHMITreeView(self, parent):
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())
#self.HMITreeView = HMITreeView(self)
#return HMITreeSelector(parent)
return HMITreeView(parent)
XSD = """<?xml version="1.0" encoding="utf-8" ?>
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<xsd:element name="SVGHMI">
<xsd:attribute name="OnStart" type="xsd:string" use="optional"/>
<xsd:attribute name="OnStop" type="xsd:string" use="optional"/>
<xsd:attribute name="OnWatchdog" type="xsd:string" use="optional"/>
<xsd:attribute name="WatchdogInitial" type="xsd:integer" use="optional"/>
<xsd:attribute name="WatchdogInterval" type="xsd:integer" use="optional"/>
EditorType = SVGHMIEditor
"tooltip": _("Import SVG"),
"bitmap": "EditSVG", # should be something different
"tooltip": _("Edit HMI"),
"method": "_StartInkscape"
"bitmap": "OpenPOT", # should be something different
"tooltip": _("Open non translated message catalog (POT) to start new language"),
"bitmap": "EditPO", # should be something different
"tooltip": _("Edit existing message catalog (PO) for specific language"),
# - can drag'n'drop variabes to Inkscape
def _getSVGpath(self, project_path=None):
project_path = self.CTNPath()
return os.path.join(project_path, "svghmi.svg")
def _getPOTpath(self, project_path=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),
shutil.copyfile(self._getPOTpath(from_project_path),
# XXX TODO copy .PO files
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()
self.FatalError("SVGHMI: inkscape is not installed.")
svgpath = self._getSVGpath()
status, result, _err_result = ProcessLogger(self.GetCTRoot().logger,
'"' + inkpath + '" -S "' + svgpath + '"',
self.FatalError("SVGHMI: inkscape couldn't extract geometry from given SVG.")
for line in result.split():
strippedline = line.strip()
zip(InkscapeGeomColumns, line.strip().split(',')))
res.append(etree.Element("bbox", **attrs))
self.ProgressEnd("inkscape")
self.ProgressStart("hmitree", "getting HMI tree")
res = [hmi_tree_root.etree(add_hash=True)]
self.ProgressEnd("hmitree")
def GetTranslations(self, _context, msgs):
self.ProgressStart("i18n", "getting Translations")
messages = EtreeToMessages(msgs)
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)
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):
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()
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):
hmi_tree_root._hash(hasher)
with open(svgfile, 'rb') as afile:
digest = hasher.hexdigest()
if os.path.exists(hash_path):
with open(hash_path, 'rb') as digest_file:
last_digest = digest_file.read()
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),
("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)
# call xslt transform on Inkscape's SVG to generate XHTML
self.ProgressStart("xslt", "XSLT transform")
result = transform.transform(svgdom) # , profile_run=True)
except XSLTApplyError as e:
self.FatalError("SVGHMI " + view_name + ": " + e.message)
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")
# 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)
self.GetCTRoot().logger.write(" No changes - XSLT transformation skipped\n")
target_file = open(target_path, 'wb')
target_file.write("""<!DOCTYPE html>
<h1> No SVG file provided </h1>
res += ((target_fname, open(target_path, "rb")),)
for thing in ["Start", "Stop", "Watchdog"]:
given_command = self.GetParamsAttributes("SVGHMI.On"+thing)["value"]
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')
# TODO : multiple watchdog (one for each svghmi instance)
def svghmi_watchdog_trigger():
def _runtime_{location}_svghmi_start():
defaultType='application/xhtml+xml'))
svghmi_watchdog = Watchdog(
def _runtime_{location}_svghmi_stop():
if svghmi_watchdog is not None:
svghmi_root.delEntity('{view_name}')
""".format(location=location_str,
watchdog_initial = self.GetParamsAttributes("SVGHMI.WatchdogInitial")["value"],
watchdog_interval = self.GetParamsAttributes("SVGHMI.WatchdogInterval")["value"],
res += (("runtime_%s_svghmi.py" % location_str, open(runtimefile_path, "rb")),)
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())
self.GetCTRoot().logger.write_error(_("No such SVG file: %s\n") % svgpath)
def _StartInkscape(self):
svgfile = self._getSVGpath()
if not self.GetCTRoot().CheckProjectPathPerm():
dialog = wx.MessageDialog(self.GetCTRoot().AppFrame,
_("You don't have write permissions.\nOpen Inkscape anyway ?"),
wx.YES_NO | wx.ICON_QUESTION)
open_inkscape = dialog.ShowModal() == wx.ID_YES
if not os.path.isfile(svgfile):
def _StartPOEdit(self, POFile):
if not self.GetCTRoot().CheckProjectPathPerm():
dialog = wx.MessageDialog(self.GetCTRoot().AppFrame,
_("You don't have write permissions.\nOpen POEdit anyway ?"),
wx.YES_NO | wx.ICON_QUESTION)
open_poedit = dialog.ShowModal() == wx.ID_YES
""" 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)
self.GetCTRoot().logger.write_error(_("PO file misplaced: %s is not in %s\n") % (POFile,project_path))
self.GetCTRoot().logger.write_error(_("PO file does not exist: %s\n") % POFile)
""" Start POEdit with untouched empty catalog """
POFile = self._getPOTpath()
if os.path.isfile(POFile):
self._StartPOEdit(POFile)
self.GetCTRoot().logger.write_error(_("POT file does not exist, add translatable text (label starting with '_') in Inkscape first\n"))
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]