|
1 #!/usr/bin/env python |
|
2 # -*- coding: utf-8 -*- |
|
3 |
|
4 # This file is part of Beremiz |
|
5 # Copyright (C) 2021: Edouard TISSERANT |
|
6 # |
|
7 # See COPYING file for copyrights details. |
|
8 |
|
9 from __future__ import absolute_import |
|
10 import os |
|
11 import shutil |
|
12 import hashlib |
|
13 import shlex |
|
14 import time |
|
15 |
|
16 import wx |
|
17 |
|
18 from lxml import etree |
|
19 from lxml.etree import XSLTApplyError |
|
20 |
|
21 import util.paths as paths |
|
22 from POULibrary import POULibrary |
|
23 from docutil import open_svg, get_inkscape_path |
|
24 |
|
25 from util.ProcessLogger import ProcessLogger |
|
26 from runtime.typemapping import DebugTypesSize |
|
27 import targets |
|
28 from editors.ConfTreeNodeEditor import ConfTreeNodeEditor |
|
29 from XSLTransform import XSLTransform |
|
30 from svghmi.i18n import EtreeToMessages, SaveCatalog, ReadTranslations,\ |
|
31 MatchTranslations, TranslationToEtree, open_pofile,\ |
|
32 GetPoFiles |
|
33 from svghmi.hmi_tree import HMI_TYPES, HMITreeNode, SPECIAL_NODES |
|
34 from svghmi.ui import SVGHMI_UI |
|
35 from svghmi.fonts import GetFontTypeAndFamilyName, GetCSSFontFaceFromFontFile |
|
36 |
|
37 |
|
38 ScriptDirectory = paths.AbsDir(__file__) |
|
39 |
|
40 |
|
41 # module scope for HMITree root |
|
42 # so that CTN can use HMITree deduced in Library |
|
43 # note: this only works because library's Generate_C is |
|
44 # systematicaly invoked before CTN's CTNGenerate_C |
|
45 |
|
46 hmi_tree_root = None |
|
47 |
|
48 on_hmitree_update = None |
|
49 |
|
50 maxConnectionsTotal = 0 |
|
51 |
|
52 class SVGHMILibrary(POULibrary): |
|
53 def GetLibraryPath(self): |
|
54 return paths.AbsNeighbourFile(__file__, "pous.xml") |
|
55 |
|
56 def Generate_C(self, buildpath, varlist, IECCFLAGS): |
|
57 global hmi_tree_root, on_hmitree_update, maxConnectionsTotal |
|
58 |
|
59 already_found_watchdog = False |
|
60 found_SVGHMI_instance = False |
|
61 for CTNChild in self.GetCTR().IterChildren(): |
|
62 if isinstance(CTNChild, SVGHMI): |
|
63 found_SVGHMI_instance = True |
|
64 # collect maximum connection total for all svghmi nodes |
|
65 maxConnectionsTotal += CTNChild.GetParamsAttributes("SVGHMI.MaxConnections")["value"] |
|
66 |
|
67 # spot watchdog abuse |
|
68 if CTNChild.GetParamsAttributes("SVGHMI.EnableWatchdog")["value"]: |
|
69 if already_found_watchdog: |
|
70 self.FatalError("SVGHMI: Only one watchdog enabled HMI allowed") |
|
71 already_found_watchdog = True |
|
72 |
|
73 if not found_SVGHMI_instance: |
|
74 self.FatalError("SVGHMI : Library is selected but not used. Please either deselect it in project config or add a SVGHMI node to project.") |
|
75 |
|
76 |
|
77 """ |
|
78 PLC Instance Tree: |
|
79 prog0 |
|
80 +->v1 HMI_INT |
|
81 +->v2 HMI_INT |
|
82 +->fb0 (type mhoo) |
|
83 | +->va HMI_NODE |
|
84 | +->v3 HMI_INT |
|
85 | +->v4 HMI_INT |
|
86 | |
|
87 +->fb1 (type mhoo) |
|
88 | +->va HMI_NODE |
|
89 | +->v3 HMI_INT |
|
90 | +->v4 HMI_INT |
|
91 | |
|
92 +->fb2 |
|
93 +->v5 HMI_IN |
|
94 |
|
95 HMI tree: |
|
96 hmi0 |
|
97 +->v1 |
|
98 +->v2 |
|
99 +->fb0 class:va |
|
100 | +-> v3 |
|
101 | +-> v4 |
|
102 | |
|
103 +->fb1 class:va |
|
104 | +-> v3 |
|
105 | +-> v4 |
|
106 | |
|
107 +->v5 |
|
108 |
|
109 """ |
|
110 |
|
111 # Filter known HMI types |
|
112 hmi_types_instances = [v for v in varlist if v["derived"] in HMI_TYPES] |
|
113 |
|
114 hmi_tree_root = None |
|
115 |
|
116 # take first HMI_NODE (placed as special node), make it root |
|
117 for i,v in enumerate(hmi_types_instances): |
|
118 path = v["IEC_path"].split(".") |
|
119 derived = v["derived"] |
|
120 if derived == "HMI_NODE": |
|
121 hmi_tree_root = HMITreeNode(path, "", derived, v["type"], v["vartype"], v["C_path"]) |
|
122 hmi_types_instances.pop(i) |
|
123 break |
|
124 |
|
125 # deduce HMI tree from PLC HMI_* instances |
|
126 for v in hmi_types_instances: |
|
127 path = v["IEC_path"].split(".") |
|
128 # ignores variables starting with _TMP_ |
|
129 if path[-1].startswith("_TMP_"): |
|
130 continue |
|
131 derived = v["derived"] |
|
132 kwargs={} |
|
133 if derived == "HMI_NODE": |
|
134 # TODO : make problem if HMI_NODE used in CONFIG or RESOURCE |
|
135 name = path[-2] |
|
136 kwargs['hmiclass'] = path[-1] |
|
137 else: |
|
138 name = path[-1] |
|
139 new_node = HMITreeNode(path, name, derived, v["type"], v["vartype"], v["C_path"], **kwargs) |
|
140 placement_result = hmi_tree_root.place_node(new_node) |
|
141 if placement_result is not None: |
|
142 cause, problematic_node = placement_result |
|
143 if cause == "Non_Unique": |
|
144 message = _("HMI tree nodes paths are not unique.\nConflicting variable: {} {}").format( |
|
145 ".".join(problematic_node.path), |
|
146 ".".join(new_node.path)) |
|
147 |
|
148 last_FB = None |
|
149 for v in varlist: |
|
150 if v["vartype"] == "FB": |
|
151 last_FB = v |
|
152 if v["C_path"] == problematic_node: |
|
153 break |
|
154 if last_FB is not None: |
|
155 failing_parent = last_FB["type"] |
|
156 message += "\n" |
|
157 message += _("Solution: Add HMI_NODE at beginning of {}").format(failing_parent) |
|
158 |
|
159 elif cause in ["Late_HMI_NODE", "Duplicate_HMI_NODE"]: |
|
160 cause, problematic_node = placement_result |
|
161 message = _("There must be only one occurrence of HMI_NODE before any HMI_* variable in POU.\nConflicting variable: {} {}").format( |
|
162 ".".join(problematic_node.path), |
|
163 ".".join(new_node.path)) |
|
164 |
|
165 self.FatalError("SVGHMI : " + message) |
|
166 |
|
167 if on_hmitree_update is not None: |
|
168 on_hmitree_update(hmi_tree_root) |
|
169 |
|
170 variable_decl_array = [] |
|
171 extern_variables_declarations = [] |
|
172 buf_index = 0 |
|
173 item_count = 0 |
|
174 found_heartbeat = False |
|
175 |
|
176 hearbeat_IEC_path = ['CONFIG', 'HEARTBEAT'] |
|
177 |
|
178 for node in hmi_tree_root.traverse(): |
|
179 if not found_heartbeat and node.path == hearbeat_IEC_path: |
|
180 hmi_tree_hearbeat_index = item_count |
|
181 found_heartbeat = True |
|
182 extern_variables_declarations += [ |
|
183 "#define heartbeat_index "+str(hmi_tree_hearbeat_index) |
|
184 ] |
|
185 if hasattr(node, "iectype"): |
|
186 sz = DebugTypesSize.get(node.iectype, 0) |
|
187 variable_decl_array += [ |
|
188 "{&(" + node.cpath + "), " + node.iectype + { |
|
189 "EXT": "_P_ENUM", |
|
190 "IN": "_P_ENUM", |
|
191 "MEM": "_O_ENUM", |
|
192 "OUT": "_O_ENUM", |
|
193 "VAR": "_ENUM" |
|
194 }[node.vartype] + ", " + |
|
195 str(buf_index) + ", 0, }"] |
|
196 buf_index += sz |
|
197 item_count += 1 |
|
198 if len(node.path) == 1: |
|
199 extern_variables_declarations += [ |
|
200 "extern __IEC_" + node.iectype + "_" + |
|
201 "t" if node.vartype is "VAR" else "p" |
|
202 + node.cpath + ";"] |
|
203 |
|
204 assert(found_heartbeat) |
|
205 |
|
206 # TODO : filter only requiered external declarations |
|
207 for v in varlist: |
|
208 if v["C_path"].find('.') < 0: |
|
209 extern_variables_declarations += [ |
|
210 "extern %(type)s %(C_path)s;" % v] |
|
211 |
|
212 # TODO check if programs need to be declared separately |
|
213 # "programs_declarations": "\n".join(["extern %(type)s %(C_path)s;" % |
|
214 # p for p in self._ProgramList]), |
|
215 |
|
216 # C code to observe/access HMI tree variables |
|
217 svghmi_c_filepath = paths.AbsNeighbourFile(__file__, "svghmi.c") |
|
218 svghmi_c_file = open(svghmi_c_filepath, 'r') |
|
219 svghmi_c_code = svghmi_c_file.read() |
|
220 svghmi_c_file.close() |
|
221 svghmi_c_code = svghmi_c_code % { |
|
222 "variable_decl_array": ",\n".join(variable_decl_array), |
|
223 "extern_variables_declarations": "\n".join(extern_variables_declarations), |
|
224 "buffer_size": buf_index, |
|
225 "item_count": item_count, |
|
226 "var_access_code": targets.GetCode("var_access.c"), |
|
227 "PLC_ticktime": self.GetCTR().GetTicktime(), |
|
228 "hmi_hash_ints": ",".join(map(str,hmi_tree_root.hash())), |
|
229 "max_connections": maxConnectionsTotal |
|
230 } |
|
231 |
|
232 gen_svghmi_c_path = os.path.join(buildpath, "svghmi.c") |
|
233 gen_svghmi_c = open(gen_svghmi_c_path, 'w') |
|
234 gen_svghmi_c.write(svghmi_c_code) |
|
235 gen_svghmi_c.close() |
|
236 |
|
237 # Python based WebSocket HMITree Server |
|
238 svghmiserverfile = open(paths.AbsNeighbourFile(__file__, "svghmi_server.py"), 'r') |
|
239 svghmiservercode = svghmiserverfile.read() |
|
240 svghmiserverfile.close() |
|
241 |
|
242 runtimefile_path = os.path.join(buildpath, "runtime_00_svghmi.py") |
|
243 runtimefile = open(runtimefile_path, 'w') |
|
244 runtimefile.write(svghmiservercode) |
|
245 runtimefile.close() |
|
246 |
|
247 # Backup HMI Tree in XML form so that it can be loaded without building |
|
248 hmitree_backup_path = os.path.join(buildpath, "hmitree.xml") |
|
249 hmitree_backup_file = open(hmitree_backup_path, 'wb') |
|
250 hmitree_backup_file.write(etree.tostring(hmi_tree_root.etree())) |
|
251 hmitree_backup_file.close() |
|
252 |
|
253 return ((["svghmi"], [(gen_svghmi_c_path, IECCFLAGS)], True), "", |
|
254 ("runtime_00_svghmi.py", open(runtimefile_path, "rb"))) |
|
255 # ^ |
|
256 # note the double zero after "runtime_", |
|
257 # to ensure placement before other CTN generated code in execution order |
|
258 |
|
259 def GlobalInstances(self): |
|
260 """ Adds HMI tree root and hearbeat to PLC Configuration's globals """ |
|
261 return [(name, iec_type, "") for name, iec_type in SPECIAL_NODES] |
|
262 |
|
263 |
|
264 |
|
265 def Register_SVGHMI_UI_for_HMI_tree_updates(ref): |
|
266 global on_hmitree_update |
|
267 def HMITreeUpdate(_hmi_tree_root): |
|
268 obj = ref() |
|
269 if obj is not None: |
|
270 obj.HMITreeUpdate(_hmi_tree_root) |
|
271 |
|
272 on_hmitree_update = HMITreeUpdate |
|
273 |
|
274 |
|
275 class SVGHMIEditor(ConfTreeNodeEditor): |
|
276 CONFNODEEDITOR_TABS = [ |
|
277 (_("HMI Tree"), "CreateSVGHMI_UI")] |
|
278 |
|
279 def CreateSVGHMI_UI(self, parent): |
|
280 global hmi_tree_root |
|
281 |
|
282 if hmi_tree_root is None: |
|
283 buildpath = self.Controler.GetCTRoot()._getBuildPath() |
|
284 hmitree_backup_path = os.path.join(buildpath, "hmitree.xml") |
|
285 if os.path.exists(hmitree_backup_path): |
|
286 hmitree_backup_file = open(hmitree_backup_path, 'rb') |
|
287 hmi_tree_root = HMITreeNode.from_etree(etree.parse(hmitree_backup_file).getroot()) |
|
288 |
|
289 ret = SVGHMI_UI(parent, Register_SVGHMI_UI_for_HMI_tree_updates) |
|
290 |
|
291 on_hmitree_update(hmi_tree_root) |
|
292 |
|
293 return ret |
|
294 |
|
295 class SVGHMI(object): |
|
296 XSD = """<?xml version="1.0" encoding="utf-8" ?> |
|
297 <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"> |
|
298 <xsd:element name="SVGHMI"> |
|
299 <xsd:complexType> |
|
300 <xsd:attribute name="OnStart" type="xsd:string" use="optional" default="chromium {url}"/> |
|
301 <xsd:attribute name="OnStop" type="xsd:string" use="optional" default="echo 'please close chromium window at {url}'"/> |
|
302 <xsd:attribute name="OnWatchdog" type="xsd:string" use="optional" default="echo 'Watchdog for {name} !'"/> |
|
303 <xsd:attribute name="EnableWatchdog" type="xsd:boolean" use="optional" default="false"/> |
|
304 <xsd:attribute name="WatchdogInitial" use="optional" default="30"> |
|
305 <xsd:simpleType> |
|
306 <xsd:restriction base="xsd:integer"> |
|
307 <xsd:minInclusive value="2"/> |
|
308 <xsd:maxInclusive value="600"/> |
|
309 </xsd:restriction> |
|
310 </xsd:simpleType> |
|
311 </xsd:attribute> |
|
312 <xsd:attribute name="WatchdogInterval" use="optional" default="5"> |
|
313 <xsd:simpleType> |
|
314 <xsd:restriction base="xsd:integer"> |
|
315 <xsd:minInclusive value="2"/> |
|
316 <xsd:maxInclusive value="60"/> |
|
317 </xsd:restriction> |
|
318 </xsd:simpleType> |
|
319 </xsd:attribute> |
|
320 <xsd:attribute name="Port" type="xsd:integer" use="optional" default="8008"/> |
|
321 <xsd:attribute name="Interface" type="xsd:string" use="optional" default="localhost"/> |
|
322 <xsd:attribute name="Path" type="xsd:string" use="optional" default="{name}"/> |
|
323 <xsd:attribute name="MaxConnections" use="optional" default="16"> |
|
324 <xsd:simpleType> |
|
325 <xsd:restriction base="xsd:integer"> |
|
326 <xsd:minInclusive value="1"/> |
|
327 <xsd:maxInclusive value="1024"/> |
|
328 </xsd:restriction> |
|
329 </xsd:simpleType> |
|
330 </xsd:attribute> |
|
331 </xsd:complexType> |
|
332 </xsd:element> |
|
333 </xsd:schema> |
|
334 """ |
|
335 |
|
336 EditorType = SVGHMIEditor |
|
337 |
|
338 ConfNodeMethods = [ |
|
339 { |
|
340 "bitmap": "ImportSVG", |
|
341 "name": _("Import SVG"), |
|
342 "tooltip": _("Import SVG"), |
|
343 "method": "_ImportSVG" |
|
344 }, |
|
345 { |
|
346 "bitmap": "EditSVG", |
|
347 "name": _("Inkscape"), |
|
348 "tooltip": _("Edit HMI"), |
|
349 "method": "_StartInkscape" |
|
350 }, |
|
351 { |
|
352 "bitmap": "OpenPOT", |
|
353 "name": _("New lang"), |
|
354 "tooltip": _("Open non translated message catalog (POT) to start new language"), |
|
355 "method": "_OpenPOT" |
|
356 }, |
|
357 { |
|
358 "bitmap": "EditPO", |
|
359 "name": _("Edit lang"), |
|
360 "tooltip": _("Edit existing message catalog (PO) for specific language"), |
|
361 "method": "_EditPO" |
|
362 }, |
|
363 { |
|
364 "bitmap": "AddFont", |
|
365 "name": _("Add Font"), |
|
366 "tooltip": _("Add TTF, OTF or WOFF font to be embedded in HMI"), |
|
367 "method": "_AddFont" |
|
368 }, |
|
369 { |
|
370 "bitmap": "DelFont", |
|
371 "name": _("Delete Font"), |
|
372 "tooltip": _("Remove font previously added to HMI"), |
|
373 "method": "_DelFont" |
|
374 }, |
|
375 ] |
|
376 |
|
377 def _getSVGpath(self, project_path=None): |
|
378 if project_path is None: |
|
379 project_path = self.CTNPath() |
|
380 return os.path.join(project_path, "svghmi.svg") |
|
381 |
|
382 def _getPOTpath(self, project_path=None): |
|
383 if project_path is None: |
|
384 project_path = self.CTNPath() |
|
385 return os.path.join(project_path, "messages.pot") |
|
386 |
|
387 def OnCTNSave(self, from_project_path=None): |
|
388 if from_project_path is not None: |
|
389 shutil.copyfile(self._getSVGpath(from_project_path), |
|
390 self._getSVGpath()) |
|
391 shutil.copyfile(self._getPOTpath(from_project_path), |
|
392 self._getPOTpath()) |
|
393 # XXX TODO copy .PO files |
|
394 return True |
|
395 |
|
396 def GetSVGGeometry(self): |
|
397 self.ProgressStart("inkscape", "collecting SVG geometry (Inkscape)") |
|
398 # invoke inskscape -S, csv-parse output, produce elements |
|
399 InkscapeGeomColumns = ["Id", "x", "y", "w", "h"] |
|
400 |
|
401 inkpath = get_inkscape_path() |
|
402 |
|
403 if inkpath is None: |
|
404 self.FatalError("SVGHMI: inkscape is not installed.") |
|
405 |
|
406 svgpath = self._getSVGpath() |
|
407 status, result, _err_result = ProcessLogger(self.GetCTRoot().logger, |
|
408 '"' + inkpath + '" -S "' + svgpath + '"', |
|
409 no_stdout=True, |
|
410 no_stderr=True).spin() |
|
411 if status != 0: |
|
412 self.FatalError("SVGHMI: inkscape couldn't extract geometry from given SVG.") |
|
413 |
|
414 res = [] |
|
415 for line in result.split(): |
|
416 strippedline = line.strip() |
|
417 attrs = dict( |
|
418 zip(InkscapeGeomColumns, line.strip().split(','))) |
|
419 |
|
420 res.append(etree.Element("bbox", **attrs)) |
|
421 |
|
422 self.ProgressEnd("inkscape") |
|
423 return res |
|
424 |
|
425 def GetHMITree(self): |
|
426 global hmi_tree_root |
|
427 self.ProgressStart("hmitree", "getting HMI tree") |
|
428 res = [hmi_tree_root.etree(add_hash=True)] |
|
429 self.ProgressEnd("hmitree") |
|
430 return res |
|
431 |
|
432 def GetTranslations(self, _context, msgs): |
|
433 self.ProgressStart("i18n", "getting Translations") |
|
434 messages = EtreeToMessages(msgs) |
|
435 |
|
436 if len(messages) == 0: |
|
437 self.ProgressEnd("i18n") |
|
438 return |
|
439 |
|
440 SaveCatalog(self._getPOTpath(), messages) |
|
441 |
|
442 translations = ReadTranslations(self.CTNPath()) |
|
443 |
|
444 langs,translated_messages = MatchTranslations(translations, messages, |
|
445 errcallback=self.GetCTRoot().logger.write_warning) |
|
446 |
|
447 ret = TranslationToEtree(langs,translated_messages) |
|
448 |
|
449 self.ProgressEnd("i18n") |
|
450 |
|
451 return ret |
|
452 |
|
453 def GetFontsFiles(self): |
|
454 project_path = self.CTNPath() |
|
455 fontdir = os.path.join(project_path, "fonts") |
|
456 if os.path.isdir(fontdir): |
|
457 return [os.path.join(fontdir,f) for f in sorted(os.listdir(fontdir))] |
|
458 return [] |
|
459 |
|
460 def GetFonts(self, _context): |
|
461 css_parts = [] |
|
462 |
|
463 for fontfile in self.GetFontsFiles(): |
|
464 if os.path.isfile(fontfile): |
|
465 css_parts.append(GetCSSFontFaceFromFontFile(fontfile)) |
|
466 |
|
467 return "".join(css_parts) |
|
468 |
|
469 times_msgs = {} |
|
470 indent = 1 |
|
471 def ProgressStart(self, k, m): |
|
472 self.times_msgs[k] = (time.time(), m) |
|
473 self.GetCTRoot().logger.write(" "*self.indent + "Start %s...\n"%m) |
|
474 self.indent = self.indent + 1 |
|
475 |
|
476 def ProgressEnd(self, k): |
|
477 t = time.time() |
|
478 oldt, m = self.times_msgs[k] |
|
479 self.indent = self.indent - 1 |
|
480 self.GetCTRoot().logger.write(" "*self.indent + "... finished in %.3fs\n"%(t - oldt)) |
|
481 |
|
482 def get_SVGHMI_options(self): |
|
483 name = self.BaseParams.getName() |
|
484 port = self.GetParamsAttributes("SVGHMI.Port")["value"] |
|
485 interface = self.GetParamsAttributes("SVGHMI.Interface")["value"] |
|
486 path = self.GetParamsAttributes("SVGHMI.Path")["value"].format(name=name) |
|
487 if path and path[0]=='/': |
|
488 path = path[1:] |
|
489 enable_watchdog = self.GetParamsAttributes("SVGHMI.EnableWatchdog")["value"] |
|
490 url="http://"+interface+("" if port==80 else (":"+str(port)) |
|
491 ) + (("/"+path) if path else "" |
|
492 ) + ("#watchdog" if enable_watchdog else "") |
|
493 |
|
494 return dict( |
|
495 name=name, |
|
496 port=port, |
|
497 interface=interface, |
|
498 path=path, |
|
499 enable_watchdog=enable_watchdog, |
|
500 url=url) |
|
501 |
|
502 def CTNGenerate_C(self, buildpath, locations): |
|
503 global hmi_tree_root |
|
504 |
|
505 if hmi_tree_root is None: |
|
506 self.FatalError("SVGHMI : Library is not selected. Please select it in project config.") |
|
507 |
|
508 location_str = "_".join(map(str, self.GetCurrentLocation())) |
|
509 svghmi_options = self.get_SVGHMI_options() |
|
510 |
|
511 svgfile = self._getSVGpath() |
|
512 |
|
513 res = ([], "", False) |
|
514 |
|
515 target_fname = "svghmi_"+location_str+".xhtml" |
|
516 |
|
517 build_path = self._getBuildPath() |
|
518 target_path = os.path.join(build_path, target_fname) |
|
519 hash_path = os.path.join(build_path, "svghmi.md5") |
|
520 |
|
521 self.GetCTRoot().logger.write("SVGHMI:\n") |
|
522 |
|
523 if os.path.exists(svgfile): |
|
524 |
|
525 hasher = hashlib.md5() |
|
526 hmi_tree_root._hash(hasher) |
|
527 pofiles = GetPoFiles(self.CTNPath()) |
|
528 filestocheck = [svgfile] + \ |
|
529 (list(zip(*pofiles)[1]) if pofiles else []) + \ |
|
530 self.GetFontsFiles() |
|
531 |
|
532 for filetocheck in filestocheck: |
|
533 with open(filetocheck, 'rb') as afile: |
|
534 while True: |
|
535 buf = afile.read(65536) |
|
536 if len(buf) > 0: |
|
537 hasher.update(buf) |
|
538 else: |
|
539 break |
|
540 digest = hasher.hexdigest() |
|
541 |
|
542 if os.path.exists(hash_path): |
|
543 with open(hash_path, 'rb') as digest_file: |
|
544 last_digest = digest_file.read() |
|
545 else: |
|
546 last_digest = None |
|
547 |
|
548 if digest != last_digest: |
|
549 |
|
550 transform = XSLTransform(os.path.join(ScriptDirectory, "gen_index_xhtml.xslt"), |
|
551 [("GetSVGGeometry", lambda *_ignored:self.GetSVGGeometry()), |
|
552 ("GetHMITree", lambda *_ignored:self.GetHMITree()), |
|
553 ("GetTranslations", self.GetTranslations), |
|
554 ("GetFonts", self.GetFonts), |
|
555 ("ProgressStart", lambda _ign,k,m:self.ProgressStart(str(k),str(m))), |
|
556 ("ProgressEnd", lambda _ign,k:self.ProgressEnd(str(k)))]) |
|
557 |
|
558 self.ProgressStart("svg", "source SVG parsing") |
|
559 |
|
560 # load svg as a DOM with Etree |
|
561 svgdom = etree.parse(svgfile) |
|
562 |
|
563 self.ProgressEnd("svg") |
|
564 |
|
565 # call xslt transform on Inkscape's SVG to generate XHTML |
|
566 try: |
|
567 self.ProgressStart("xslt", "XSLT transform") |
|
568 result = transform.transform(svgdom) # , profile_run=True) |
|
569 self.ProgressEnd("xslt") |
|
570 except XSLTApplyError as e: |
|
571 self.FatalError("SVGHMI " + svghmi_options["name"] + ": " + e.message) |
|
572 finally: |
|
573 for entry in transform.get_error_log(): |
|
574 message = "SVGHMI: "+ entry.message + "\n" |
|
575 self.GetCTRoot().logger.write_warning(message) |
|
576 |
|
577 target_file = open(target_path, 'wb') |
|
578 result.write(target_file, encoding="utf-8") |
|
579 target_file.close() |
|
580 |
|
581 # print(str(result)) |
|
582 # print(transform.xslt.error_log) |
|
583 # print(etree.tostring(result.xslt_profile,pretty_print=True)) |
|
584 |
|
585 with open(hash_path, 'wb') as digest_file: |
|
586 digest_file.write(digest) |
|
587 else: |
|
588 self.GetCTRoot().logger.write(" No changes - XSLT transformation skipped\n") |
|
589 |
|
590 else: |
|
591 target_file = open(target_path, 'wb') |
|
592 target_file.write("""<!DOCTYPE html> |
|
593 <html xmlns="http://www.w3.org/1999/xhtml"> |
|
594 <head> |
|
595 <title>SVGHMI</title> |
|
596 </head> |
|
597 <body> |
|
598 <h1> No SVG file provided </h1> |
|
599 </body> |
|
600 </html> |
|
601 """) |
|
602 target_file.close() |
|
603 |
|
604 res += ((target_fname, open(target_path, "rb")),) |
|
605 |
|
606 svghmi_cmds = {} |
|
607 for thing in ["Start", "Stop", "Watchdog"]: |
|
608 given_command = self.GetParamsAttributes("SVGHMI.On"+thing)["value"] |
|
609 svghmi_cmds[thing] = ( |
|
610 "Popen(" + |
|
611 repr(shlex.split(given_command.format(**svghmi_options))) + |
|
612 ")") if given_command else "pass # no command given" |
|
613 |
|
614 runtimefile_path = os.path.join(buildpath, "runtime_%s_svghmi_.py" % location_str) |
|
615 runtimefile = open(runtimefile_path, 'w') |
|
616 runtimefile.write(""" |
|
617 # TODO : multiple watchdog (one for each svghmi instance) |
|
618 def svghmi_{location}_watchdog_trigger(): |
|
619 {svghmi_cmds[Watchdog]} |
|
620 |
|
621 max_svghmi_sessions = {maxConnections_total} |
|
622 |
|
623 def _runtime_{location}_svghmi_start(): |
|
624 global svghmi_watchdog, svghmi_servers |
|
625 |
|
626 srv = svghmi_servers.get("{interface}:{port}", None) |
|
627 if srv is not None: |
|
628 svghmi_root, svghmi_listener, path_list = srv |
|
629 if '{path}' in path_list: |
|
630 raise Exception("SVGHMI {name}: path {path} already used on {interface}:{port}") |
|
631 else: |
|
632 svghmi_root = Resource() |
|
633 factory = HMIWebSocketServerFactory() |
|
634 factory.setProtocolOptions(maxConnections={maxConnections}) |
|
635 |
|
636 svghmi_root.putChild("ws", WebSocketResource(factory)) |
|
637 |
|
638 svghmi_listener = reactor.listenTCP({port}, Site(svghmi_root), interface='{interface}') |
|
639 path_list = [] |
|
640 svghmi_servers["{interface}:{port}"] = (svghmi_root, svghmi_listener, path_list) |
|
641 |
|
642 svghmi_root.putChild( |
|
643 '{path}', |
|
644 NoCacheFile('{xhtml}', |
|
645 defaultType='application/xhtml+xml')) |
|
646 |
|
647 path_list.append("{path}") |
|
648 |
|
649 {svghmi_cmds[Start]} |
|
650 |
|
651 if {enable_watchdog}: |
|
652 if svghmi_watchdog is None: |
|
653 svghmi_watchdog = Watchdog( |
|
654 {watchdog_initial}, |
|
655 {watchdog_interval}, |
|
656 svghmi_{location}_watchdog_trigger) |
|
657 else: |
|
658 raise Exception("SVGHMI {name}: only one watchdog allowed") |
|
659 |
|
660 |
|
661 def _runtime_{location}_svghmi_stop(): |
|
662 global svghmi_watchdog, svghmi_servers |
|
663 |
|
664 if svghmi_watchdog is not None: |
|
665 svghmi_watchdog.cancel() |
|
666 svghmi_watchdog = None |
|
667 |
|
668 svghmi_root, svghmi_listener, path_list = svghmi_servers["{interface}:{port}"] |
|
669 svghmi_root.delEntity('{path}') |
|
670 |
|
671 path_list.remove('{path}') |
|
672 |
|
673 if len(path_list)==0: |
|
674 svghmi_root.delEntity("ws") |
|
675 svghmi_listener.stopListening() |
|
676 svghmi_servers.pop("{interface}:{port}") |
|
677 |
|
678 {svghmi_cmds[Stop]} |
|
679 |
|
680 """.format(location=location_str, |
|
681 xhtml=target_fname, |
|
682 svghmi_cmds=svghmi_cmds, |
|
683 watchdog_initial = self.GetParamsAttributes("SVGHMI.WatchdogInitial")["value"], |
|
684 watchdog_interval = self.GetParamsAttributes("SVGHMI.WatchdogInterval")["value"], |
|
685 maxConnections = self.GetParamsAttributes("SVGHMI.MaxConnections")["value"], |
|
686 maxConnections_total = maxConnectionsTotal, |
|
687 **svghmi_options |
|
688 )) |
|
689 |
|
690 runtimefile.close() |
|
691 |
|
692 res += (("runtime_%s_svghmi.py" % location_str, open(runtimefile_path, "rb")),) |
|
693 |
|
694 return res |
|
695 |
|
696 def _ImportSVG(self): |
|
697 dialog = wx.FileDialog(self.GetCTRoot().AppFrame, _("Choose a SVG file"), os.getcwd(), "", _("SVG files (*.svg)|*.svg|All files|*.*"), wx.OPEN) |
|
698 if dialog.ShowModal() == wx.ID_OK: |
|
699 svgpath = dialog.GetPath() |
|
700 if os.path.isfile(svgpath): |
|
701 shutil.copy(svgpath, self._getSVGpath()) |
|
702 else: |
|
703 self.GetCTRoot().logger.write_error(_("No such SVG file: %s\n") % svgpath) |
|
704 dialog.Destroy() |
|
705 |
|
706 def getDefaultSVG(self): |
|
707 return os.path.join(ScriptDirectory, "default.svg") |
|
708 |
|
709 def _StartInkscape(self): |
|
710 svgfile = self._getSVGpath() |
|
711 open_inkscape = True |
|
712 if not self.GetCTRoot().CheckProjectPathPerm(): |
|
713 dialog = wx.MessageDialog(self.GetCTRoot().AppFrame, |
|
714 _("You don't have write permissions.\nOpen Inkscape anyway ?"), |
|
715 _("Open Inkscape"), |
|
716 wx.YES_NO | wx.ICON_QUESTION) |
|
717 open_inkscape = dialog.ShowModal() == wx.ID_YES |
|
718 dialog.Destroy() |
|
719 if open_inkscape: |
|
720 if not os.path.isfile(svgfile): |
|
721 # make a copy of default svg from source |
|
722 default = self.getDefaultSVG() |
|
723 shutil.copyfile(default, svgfile) |
|
724 open_svg(svgfile) |
|
725 |
|
726 def _StartPOEdit(self, POFile): |
|
727 open_poedit = True |
|
728 if not self.GetCTRoot().CheckProjectPathPerm(): |
|
729 dialog = wx.MessageDialog(self.GetCTRoot().AppFrame, |
|
730 _("You don't have write permissions.\nOpen POEdit anyway ?"), |
|
731 _("Open POEdit"), |
|
732 wx.YES_NO | wx.ICON_QUESTION) |
|
733 open_poedit = dialog.ShowModal() == wx.ID_YES |
|
734 dialog.Destroy() |
|
735 if open_poedit: |
|
736 open_pofile(POFile) |
|
737 |
|
738 def _EditPO(self): |
|
739 """ Select a specific translation and edit it with POEdit """ |
|
740 project_path = self.CTNPath() |
|
741 dialog = wx.FileDialog(self.GetCTRoot().AppFrame, _("Choose a PO file"), project_path, "", _("PO files (*.po)|*.po"), wx.OPEN) |
|
742 if dialog.ShowModal() == wx.ID_OK: |
|
743 POFile = dialog.GetPath() |
|
744 if os.path.isfile(POFile): |
|
745 if os.path.relpath(POFile, project_path) == os.path.basename(POFile): |
|
746 self._StartPOEdit(POFile) |
|
747 else: |
|
748 self.GetCTRoot().logger.write_error(_("PO file misplaced: %s is not in %s\n") % (POFile,project_path)) |
|
749 else: |
|
750 self.GetCTRoot().logger.write_error(_("PO file does not exist: %s\n") % POFile) |
|
751 dialog.Destroy() |
|
752 |
|
753 def _OpenPOT(self): |
|
754 """ Start POEdit with untouched empty catalog """ |
|
755 POFile = self._getPOTpath() |
|
756 if os.path.isfile(POFile): |
|
757 self._StartPOEdit(POFile) |
|
758 else: |
|
759 self.GetCTRoot().logger.write_error(_("POT file does not exist, add translatable text (label starting with '_') in Inkscape first\n")) |
|
760 |
|
761 def _AddFont(self): |
|
762 dialog = wx.FileDialog( |
|
763 self.GetCTRoot().AppFrame, |
|
764 _("Choose a font"), |
|
765 os.path.expanduser("~"), |
|
766 "", |
|
767 _("Font files (*.ttf;*.otf;*.woff;*.woff2)|*.ttf;*.otf;*.woff;*.woff2"), wx.OPEN) |
|
768 |
|
769 if dialog.ShowModal() == wx.ID_OK: |
|
770 fontfile = dialog.GetPath() |
|
771 if os.path.isfile(fontfile): |
|
772 familyname, uniquename, formatname, mimetype = GetFontTypeAndFamilyName(fontfile) |
|
773 else: |
|
774 self.GetCTRoot().logger.write_error( |
|
775 _('Selected font %s is not a readable file\n')%fontfile) |
|
776 return |
|
777 if familyname is None or uniquename is None or formatname is None or mimetype is None: |
|
778 self.GetCTRoot().logger.write_error( |
|
779 _('Selected font file %s is invalid or incompatible\n')%fontfile) |
|
780 return |
|
781 |
|
782 project_path = self.CTNPath() |
|
783 |
|
784 fontfname = uniquename + "." + mimetype.split('/')[1] |
|
785 fontdir = os.path.join(project_path, "fonts") |
|
786 newfontfile = os.path.join(fontdir, fontfname) |
|
787 |
|
788 if not os.path.exists(fontdir): |
|
789 os.mkdir(fontdir) |
|
790 |
|
791 shutil.copyfile(fontfile, newfontfile) |
|
792 |
|
793 self.GetCTRoot().logger.write( |
|
794 _('Added font %s as %s\n')%(fontfile,newfontfile)) |
|
795 |
|
796 def _DelFont(self): |
|
797 project_path = self.CTNPath() |
|
798 fontdir = os.path.join(project_path, "fonts") |
|
799 dialog = wx.FileDialog( |
|
800 self.GetCTRoot().AppFrame, |
|
801 _("Choose a font to remove"), |
|
802 fontdir, |
|
803 "", |
|
804 _("Font files (*.ttf;*.otf;*.woff;*.woff2)|*.ttf;*.otf;*.woff;*.woff2"), wx.OPEN) |
|
805 if dialog.ShowModal() == wx.ID_OK: |
|
806 fontfile = dialog.GetPath() |
|
807 if os.path.isfile(fontfile): |
|
808 if os.path.relpath(fontfile, fontdir) == os.path.basename(fontfile): |
|
809 os.remove(fontfile) |
|
810 self.GetCTRoot().logger.write( |
|
811 _('Removed font %s\n')%fontfile) |
|
812 else: |
|
813 self.GetCTRoot().logger.write_error( |
|
814 _("Font to remove %s is not in %s\n") % (fontfile,fontdir)) |
|
815 else: |
|
816 self.GetCTRoot().logger.write_error( |
|
817 _("Font file does not exist: %s\n") % fontfile) |
|
818 |
|
819 ## In case one day we support more than one heartbeat |
|
820 # def CTNGlobalInstances(self): |
|
821 # view_name = self.BaseParams.getName() |
|
822 # return [(view_name + "_HEARTBEAT", "HMI_INT", "")] |
|
823 |
|
824 def GetIconName(self): |
|
825 return "SVGHMI" |