# HG changeset patch # User Edouard Tisserant # Date 1542971604 -3600 # Node ID 21164625b393cccac287adef8cceb163d8351c01 # Parent 2a70d52403006ad46045aca2b32dc5e1f4bc1b7a# Parent 9deec258ab1ae0c8ceff723d4028f9e9d0de42f0 Merged. Some changes that should already have been incuded during previous merge (mostly about PlcStatus) have been included this time. diff -r 9deec258ab1a -r 21164625b393 BeremizIDE.py --- a/BeremizIDE.py Fri Oct 12 13:24:47 2018 +0300 +++ b/BeremizIDE.py Fri Nov 23 12:13:24 2018 +0100 @@ -25,6 +25,7 @@ from __future__ import absolute_import +from __future__ import print_function import os import sys import tempfile diff -r 9deec258ab1a -r 21164625b393 Beremiz_service.py --- a/Beremiz_service.py Fri Oct 12 13:24:47 2018 +0300 +++ b/Beremiz_service.py Fri Nov 23 12:13:24 2018 +0100 @@ -31,15 +31,15 @@ import getopt import threading from threading import Thread, Semaphore, Lock -import traceback +import __builtin__ from builtins import str as text from past.builtins import execfile from six.moves import builtins -import Pyro -import Pyro.core as pyro - -from runtime import PLCObject, ServicePublisher, MainWorker + +import runtime +from runtime.PyroServer import Server from runtime.xenomai import TryPreloadXenomai +from runtime import LogMessageAndException from runtime import PlcStatus import util.paths as paths @@ -54,7 +54,7 @@ print(""" Usage of Beremiz PLC execution service :\n %s {[-n servicename] [-i IP] [-p port] [-x enabletaskbar] [-a autostart]|-h|--help} working_dir - -n zeroconf service name (default:disabled) + -n service name (default:None, zeroconf discovery disabled) -i IP address of interface to bind to (default:localhost) -p port number default:3000 -h print this help text and quit @@ -63,7 +63,7 @@ -t enable/disable Twisted web interface (0:disable 1:enable) (default:1) -w web server port or "off" to disable web server (default:8009) -c WAMP client config file (can be overriden by wampconf.json in project) - -s WAMP client secret, given as a file (can be overriden by wamp.secret in project) + -s PSK secret path (default:PSK disabled) -e python extension (absolute path .py) working_dir - directory where are stored PLC files @@ -79,10 +79,10 @@ sys.exit(2) # default values -given_ip = None +interface = '' port = 3000 webport = 8009 -wampsecret = None +PSKpath = None wampconf = None servicename = None autostart = False @@ -101,8 +101,10 @@ version() sys.exit() elif o == "-i": - if len(a.split(".")) == 4 or a == "localhost": - given_ip = a + if len(a.split(".")) == 4: + interface = a + elif a == "localhost": + interface = '127.0.0.1' else: usage() sys.exit() @@ -122,7 +124,7 @@ elif o == "-c": wampconf = None if a == "off" else a elif o == "-s": - wampsecret = None if a == "off" else a + PSKpath = None if a == "off" else a elif o == "-e": fnameanddirname = list(os.path.split(os.path.realpath(a))) fnameanddirname.reverse() @@ -144,11 +146,10 @@ WorkingDir = os.getcwd() argv = [WorkingDir] -if __name__ == '__main__': - builtins.__dict__['_'] = lambda x: x - # TODO: add a cmdline parameter if Trying Preloading Xenomai makes problem - TryPreloadXenomai() - version() +builtins.__dict__['_'] = lambda x: x +# TODO: add a cmdline parameter if Trying Preloading Xenomai makes problem +TryPreloadXenomai() +version() def Bpath(*args): @@ -187,9 +188,8 @@ def unicode_translation(message): return wx.GetTranslation(message).encode(default_locale) - if __name__ == '__main__': - builtins.__dict__['_'] = unicode_translation - # builtins.__dict__['_'] = wx.GetTranslation + builtins.__dict__['_'] = unicode_translation + # builtins.__dict__['_'] = wx.GetTranslation # Life is hard... have a candy. @@ -256,12 +256,11 @@ TBMENU_CHANGE_WD = wx.NewId() TBMENU_QUIT = wx.NewId() - def __init__(self, pyroserver, level): + def __init__(self, pyroserver): wx.TaskBarIcon.__init__(self) self.pyroserver = pyroserver # Set the image self.UpdateIcon(None) - self.level = level # bind some events self.Bind(wx.EVT_MENU, self.OnTaskBarStartPLC, id=self.TBMENU_START) @@ -284,15 +283,14 @@ menu = wx.Menu() menu.Append(self.TBMENU_START, _("Start PLC")) menu.Append(self.TBMENU_STOP, _("Stop PLC")) - if self.level == 1: - menu.AppendSeparator() - menu.Append(self.TBMENU_CHANGE_NAME, _("Change Name")) - menu.Append(self.TBMENU_CHANGE_INTERFACE, _("Change IP of interface to bind")) - menu.Append(self.TBMENU_CHANGE_PORT, _("Change Port Number")) - menu.Append(self.TBMENU_CHANGE_WD, _("Change working directory")) - menu.AppendSeparator() - menu.Append(self.TBMENU_LIVE_SHELL, _("Launch a live Python shell")) - menu.Append(self.TBMENU_WXINSPECTOR, _("Launch WX GUI inspector")) + menu.AppendSeparator() + menu.Append(self.TBMENU_CHANGE_NAME, _("Change Name")) + menu.Append(self.TBMENU_CHANGE_INTERFACE, _("Change IP of interface to bind")) + menu.Append(self.TBMENU_CHANGE_PORT, _("Change Port Number")) + menu.Append(self.TBMENU_CHANGE_WD, _("Change working directory")) + menu.AppendSeparator() + menu.Append(self.TBMENU_LIVE_SHELL, _("Launch a live Python shell")) + menu.Append(self.TBMENU_WXINSPECTOR, _("Launch WX GUI inspector")) menu.AppendSeparator() menu.Append(self.TBMENU_QUIT, _("Quit")) return menu @@ -311,19 +309,10 @@ return icon def OnTaskBarStartPLC(self, evt): - if self.pyroserver.plcobj is not None: - plcstatus = self.pyroserver.plcobj.GetPLCstatus()[0] - if plcstatus is PlcStatus.Stopped: - self.pyroserver.plcobj.StartPLC() - else: - print(_("PLC is empty or already started.")) + runtime.GetPLCObjectSingleton().StartPLC() def OnTaskBarStopPLC(self, evt): - if self.pyroserver.plcobj is not None: - if self.pyroserver.plcobj.GetPLCstatus()[0] == PlcStatus.Started: - Thread(target=self.pyroserver.plcobj.StopPLC).start() - else: - print(_("PLC is not started.")) + runtime.GetPLCObjectSingleton().StopPLC() def OnTaskBarChangeInterface(self, evt): ip_addr = self.pyroserver.ip_addr @@ -350,19 +339,16 @@ self.pyroserver.Restart() def OnTaskBarChangeName(self, evt): - servicename = self.pyroserver.servicename - servicename = '' if servicename is None else servicename - dlg = ParamsEntryDialog(None, _("Enter a name "), defaultValue=servicename) + _servicename = self.pyroserver.servicename + _servicename = '' if _servicename is None else _servicename + dlg = ParamsEntryDialog(None, _("Enter a name "), defaultValue=_servicename) dlg.SetTests([(lambda name: len(name) is not 0, _("Name must not be null!"))]) if dlg.ShowModal() == wx.ID_OK: self.pyroserver.servicename = dlg.GetValue() self.pyroserver.Restart() def _LiveShellLocals(self): - if self.pyroserver.plcobj is not None: - return {"locals": self.pyroserver.plcobj.python_runtime_vars} - else: - return {} + return {"locals": runtime.GetPLCObjectSingleton().python_runtime_vars} def OnTaskBarLiveShell(self, evt): from wx import py @@ -406,89 +392,6 @@ return res -class Server(object): - def __init__(self, servicename, ip_addr, port, - workdir, argv, - statuschange=None, evaluator=default_evaluator, - pyruntimevars=None): - self.continueloop = True - self.daemon = None - self.servicename = servicename - self.ip_addr = ip_addr - self.port = port - self.workdir = workdir - self.argv = argv - self.servicepublisher = None - self.statuschange = statuschange - self.evaluator = evaluator - self.pyruntimevars = pyruntimevars - self.plcobj = PLCObject(self) - - def _to_be_published(self): - return self.servicename is not None and \ - self.ip_addr is not None and \ - self.ip_addr != "localhost" and \ - self.ip_addr != "127.0.0.1" - - def PrintServerInfo(self): - print(_("Pyro port :"), self.port) - - # Beremiz IDE detects LOCAL:// runtime is ready by looking - # for self.workdir in the daemon's stdout. - print(_("Current working directory :"), self.workdir) - - if self._to_be_published(): - print(_("Publishing service on local network")) - - sys.stdout.flush() - - def PyroLoop(self, when_ready): - while self.continueloop: - Pyro.config.PYRO_MULTITHREADED = 0 - pyro.initServer() - self.daemon = pyro.Daemon(host=self.ip_addr, port=self.port) - - # pyro never frees memory after connection close if no timeout set - # taking too small timeout value may cause - # unwanted diconnection when IDE is kept busy for long periods - self.daemon.setTimeout(60) - - self.daemon.connect(self.plcobj, "PLCObject") - - if self._to_be_published(): - self.servicepublisher = ServicePublisher.ServicePublisher() - self.servicepublisher.RegisterService(self.servicename, self.ip_addr, self.port) - - when_ready() - self.daemon.requestLoop() - self.daemon.sock.close() - - def Restart(self): - self._stop() - - def Quit(self): - self.continueloop = False - if self.plcobj is not None: - self.plcobj.StopPLC() - self.plcobj.UnLoadPLC() - self._stop() - - def _stop(self): - if self.plcobj is not None: - self.plcobj.StopPLC() - if self.servicepublisher is not None: - self.servicepublisher.UnRegisterService() - self.servicepublisher = None - self.daemon.shutdown(True) - - def AutoLoad(self): - self.plcobj.AutoLoad() - if self.plcobj.GetPLCstatus()[0] == PlcStatus.Stopped: - if autostart: - self.plcobj.StartPLC() - self.plcobj.StatusChange() - - if enabletwisted: import warnings with warnings.catch_warnings(): @@ -536,31 +439,12 @@ wx.CallAfter(wx_evaluator, o) wx_eval_lock.acquire() return o.res - - pyroserver = Server(servicename, given_ip, port, - WorkingDir, argv, - statuschange, evaluator, pyruntimevars) - - taskbar_instance = BeremizTaskBarIcon(pyroserver, enablewx) else: - pyroserver = Server(servicename, given_ip, port, - WorkingDir, argv, - statuschange, pyruntimevars=pyruntimevars) - + evaluator = default_evaluator # Exception hooks -def LogMessageAndException(msg, exp=None): - if exp is None: - exp = sys.exc_info() - if pyroserver.plcobj is not None: - pyroserver.plcobj.LogMessage(0, msg + '\n'.join(traceback.format_exception(*exp))) - else: - print(msg) - traceback.print_exception(*exp) - - def LogException(*exp): LogMessageAndException("", exp) @@ -610,10 +494,25 @@ sys.path.append(extension_folder) execfile(os.path.join(extension_folder, extention_file), locals()) +# Service name is used as an ID for stunnel's PSK +# Some extension may set 'servicename' to a computed ID or Serial Number +# instead of using commandline '-n' +if servicename is not None and PSKpath is not None: + from runtime.Stunnel import ensurePSK + ensurePSK(servicename, PSKpath) + +runtime.CreatePLCObjectSingleton( + WorkingDir, argv, statuschange, evaluator, pyruntimevars) + +pyroserver = Server(servicename, interface, port) + +if havewx: + taskbar_instance = BeremizTaskBarIcon(pyroserver) + if havetwisted: if webport is not None: try: - website = NS.RegisterWebsite(webport) + website = NS.RegisterWebsite(interface, webport) pyruntimevars["website"] = website NS.SetServer(pyroserver) statuschange.append(NS.website_statuslistener_factory(website)) @@ -623,7 +522,7 @@ if havewamp: try: WC.SetServer(pyroserver) - WC.RegisterWampClient(wampconf, wampsecret) + WC.RegisterWampClient(wampconf, PSKpath) WC.RegisterWebSettings(NS) except Exception: LogMessageAndException(_("WAMP client startup failed. ")) @@ -639,6 +538,11 @@ pyroserver.PrintServerInfo() +# Beremiz IDE detects LOCAL:// runtime is ready by looking +# for self.workdir in the daemon's stdout. +sys.stdout.write(_("Current working directory :") + WorkingDir + "\n") +sys.stdout.flush() + if havetwisted or havewx: ui_thread_started = Lock() ui_thread_started.acquire() @@ -665,9 +569,15 @@ print("UI thread started successfully.") try: - MainWorker.runloop(pyroserver.AutoLoad) + runtime.MainWorker.runloop( + runtime.GetPLCObjectSingleton().AutoLoad, autostart) except KeyboardInterrupt: pass pyroserver.Quit() + +plcobj = runtime.GetPLCObjectSingleton() +plcobj.StopPLC() +plcobj.UnLoadPLC() + sys.exit(0) diff -r 9deec258ab1a -r 21164625b393 PSKManagement.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/PSKManagement.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,137 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# See COPYING file for copyrights details. + +from __future__ import absolute_import +import os +import time +import json +from zipfile import ZipFile + +# PSK Management Data model : +# [[ID,Desc, LastKnownURI, LastConnect]] +COL_ID, COL_URI, COL_DESC, COL_LAST = range(4) +REPLACE, REPLACE_ALL, KEEP, KEEP_ALL, CANCEL = range(5) + +def _pskpath(project_path): + return os.path.join(project_path, 'psk') + +def _mgtpath(project_path): + return os.path.join(_pskpath(project_path), 'management.json') + +def _default(ID): + return [ID, + '', # default description + None, # last known URI + None] # last connection date + +def _dataByID(data): + return {row[COL_ID]:row for row in data} + +def _LoadData(project_path): + """ load known keys metadata """ + if os.path.isdir(_pskpath(project_path)): + _path = _mgtpath(project_path) + if os.path.exists(_path): + return json.loads(open(_path).read()) + return [] + +def _filterData(psk_files, data_input): + input_by_ID = _dataByID(data_input) + output = [] + # go through all secret files available an build data + # out of data recoverd from json and list of secret. + # this implicitly filters IDs out of metadata who's + # secret is missing + for filename in psk_files: + if filename.endswith('.secret'): + ID = filename[:-7] # strip filename extension + output.append(input_by_ID.get(ID,_default(ID))) + return output + +def GetData(project_path): + loaded_data = _LoadData(project_path) + if loaded_data: + psk_files = os.listdir(_pskpath(project_path)) + return _filterData(psk_files, loaded_data) + return [] + +def DeleteID(project_path, ID): + secret_path = os.path.join(_pskpath(project_path), ID+'.secret') + os.remove(secret_path) + +def SaveData(project_path, data): + pskpath = _pskpath(project_path) + if not os.path.isdir(pskpath): + os.mkdir(pskpath) + with open(_mgtpath(project_path), 'w') as f: + f.write(json.dumps(data)) + + +def UpdateID(project_path, ID, secret, URI): + pskpath = _pskpath(project_path) + if not os.path.exists(pskpath): + os.mkdir(pskpath) + + secpath = os.path.join(pskpath, ID+'.secret') + with open(secpath, 'w') as f: + f.write(ID+":"+secret) + + # here we directly use _LoadData, avoiding filtering that could be long + data = _LoadData(project_path) + idata = _dataByID(data) + dataForID = idata.get(ID, _default(ID)) if data else _default(ID) + dataForID[COL_URI] = URI + # FIXME : could store time instead os a string and use DVC model's cmp + # then date display could be smarter, etc - sortable sting hack for now + dataForID[COL_LAST] = time.strftime('%y/%M/%d-%H:%M:%S') + SaveData(project_path, data) + +def ExportIDs(project_path, export_zip): + with ZipFile(export_zip, 'w') as zf: + path = _pskpath(project_path) + for nm in os.listdir(path): + if nm.endswith('.secret') or nm == 'management.json': + zf.write(os.path.join(path, nm), nm) + +def ImportIDs(project_path, import_zip, should_I_replace_callback): + zf = ZipFile(import_zip, 'r') + data = GetData(project_path) + + zip_loaded_data = json.loads(zf.open('management.json').read()) + name_list = zf.namelist() + zip_filtered_data = _filterData(name_list, zip_loaded_data) + + idata = _dataByID(data) + + keys_to_import = [] + result = None + + for imported_row in zip_filtered_data: + ID = imported_row[COL_ID] + existing_row = idata.get(ID, None) + if existing_row is None: + data.append(imported_row) + else: + # callback returns the selected list for merge or none if canceled + if result not in [REPLACE_ALL, KEEP_ALL]: + result = should_I_replace_callback(existing_row, imported_row) + + if result == CANCEL: + return + + if result in [REPLACE_ALL, REPLACE]: + # replace with imported + existing_row[:] = imported_row + # copy the key of selected + keys_to_import.append(ID) + + for ID in keys_to_import: + zf.extract(ID+".secret", _pskpath(project_path)) + + SaveData(project_path, data) + + return data + + diff -r 9deec258ab1a -r 21164625b393 ProjectController.py --- a/ProjectController.py Fri Oct 12 13:24:47 2018 +0300 +++ b/ProjectController.py Fri Nov 23 12:13:24 2018 +0100 @@ -55,7 +55,7 @@ from editors.ProjectNodeEditor import ProjectNodeEditor from editors.IECCodeViewer import IECCodeViewer from editors.DebugViewer import DebugViewer, REFRESH_PERIOD -from dialogs import DiscoveryDialog +from dialogs import UriEditor, IDManager from PLCControler import PLCControler from plcopen.structures import IEC_KEYWORDS from plcopen.types_enums import ComputeConfigurationResourceName, ITEM_CONFNODE @@ -268,7 +268,7 @@ self._setBuildPath(None) self.debug_break = False self.previous_plcstate = None - # copy ConfNodeMethods so that it can be later customized + # copy StatusMethods so that it can be later customized self.StatusMethods = [dic.copy() for dic in self.StatusMethods] def __del__(self): @@ -1260,6 +1260,11 @@ _IECCodeView = None + def _showIDManager(self): + dlg = IDManager(self.AppFrame, self) + dlg.ShowModal() + dlg.Destroy() + def _showIECcode(self): self._OpenView("IEC code") @@ -1759,7 +1764,7 @@ if uri == "": try: # Launch Service Discovery dialog - dialog = DiscoveryDialog(self.AppFrame) + dialog = UriEditor(self.AppFrame, self) answer = dialog.ShowModal() uri = dialog.GetURI() dialog.Destroy() @@ -1941,6 +1946,12 @@ "shown": False, }, { + "bitmap": "IDManager", + "name": _("ID Manager"), + "tooltip": _("Manage secure connection identities"), + "method": "_showIDManager", + }, + { "bitmap": "ShowIECcode", "name": _("Show code"), "tooltip": _("Show IEC code generated by PLCGenerator"), diff -r 9deec258ab1a -r 21164625b393 bacnet/runtime/device.c --- a/bacnet/runtime/device.c Fri Oct 12 13:24:47 2018 +0300 +++ b/bacnet/runtime/device.c Fri Nov 23 12:13:24 2018 +0100 @@ -398,7 +398,7 @@ PROP_OBJECT_TYPE, /* R R ( 79) */ PROP_SYSTEM_STATUS, /* R R (112) */ PROP_VENDOR_NAME, /* R R (121) */ - PROP_VENDOR_IDENTIFIER, /* W R (120) */ + PROP_VENDOR_IDENTIFIER, /* R R (120) */ PROP_MODEL_NAME, /* W R ( 70) */ PROP_FIRMWARE_REVISION, /* R R ( 44) */ PROP_APPLICATION_SOFTWARE_VERSION, /* R R ( 12) */ @@ -1366,16 +1366,16 @@ apdu_timeout_set((uint16_t) value.type.Unsigned_Int); } break; - case PROP_VENDOR_IDENTIFIER: - status = - WPValidateArgType(&value, BACNET_APPLICATION_TAG_UNSIGNED_INT, - &wp_data->error_class, &wp_data->error_code); - if (status) { - /* FIXME: bounds check? */ - Device_Set_Vendor_Identifier((uint16_t) value. - type.Unsigned_Int); - } - break; +// case PROP_VENDOR_IDENTIFIER: +// status = +// WPValidateArgType(&value, BACNET_APPLICATION_TAG_UNSIGNED_INT, +// &wp_data->error_class, &wp_data->error_code); +// if (status) { +// /* FIXME: bounds check? */ +// Device_Set_Vendor_Identifier((uint16_t) value. +// type.Unsigned_Int); +// } +// break; // case PROP_SYSTEM_STATUS: // status = // WPValidateArgType(&value, BACNET_APPLICATION_TAG_ENUMERATED, @@ -1453,6 +1453,7 @@ case PROP_OBJECT_TYPE: case PROP_SYSTEM_STATUS: case PROP_VENDOR_NAME: + case PROP_VENDOR_IDENTIFIER: case PROP_FIRMWARE_REVISION: case PROP_APPLICATION_SOFTWARE_VERSION: case PROP_LOCAL_TIME: diff -r 9deec258ab1a -r 21164625b393 bacnet/runtime/server.c --- a/bacnet/runtime/server.c Fri Oct 12 13:24:47 2018 +0300 +++ b/bacnet/runtime/server.c Fri Nov 23 12:13:24 2018 +0100 @@ -517,7 +517,7 @@ if (elapsed_seconds) { last_seconds = current_seconds; dcc_timer_seconds(elapsed_seconds); - bvlc_maintenance_timer(elapsed_seconds); + //bvlc_maintenance_timer(elapsed_seconds); // already called by dlenv_maintenance_timer() => do _not_ call here! dlenv_maintenance_timer(elapsed_seconds); elapsed_milliseconds = elapsed_seconds * 1000; tsm_timer_milliseconds(elapsed_milliseconds); diff -r 9deec258ab1a -r 21164625b393 connectors/PYRO/PSK_Adapter.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/PYRO/PSK_Adapter.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,85 @@ +from __future__ import absolute_import +from __future__ import print_function + +import socket +import re +import sslpsk +import ssl +import Pyro +from Pyro.core import PyroURI +from Pyro.protocol import _connect_socket,TCPConnection,PYROAdapter +from Pyro.errors import ConnectionDeniedError, ProtocolError +from Pyro.util import Log + +# +# The TLS-PSK adapter that handles SSL connections instead of regular sockets, +# but using Pre Shared Keys instead of Certificates +# +class PYROPSKAdapter(PYROAdapter): + # This is essentialy the same as in Pyro/protocol.py + # only raw_sock wrapping into sock through sslpsk.wrap_socket was added + # Pyro unfortunately doesn't allow cleaner customization + def bindToURI(self,URI): + with self.lock: # only 1 thread at a time can bind the URI + try: + self.URI=URI + + # This are the statements that differ from Pyro/protocol.py + raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _connect_socket(raw_sock, URI.address, URI.port, self.timeout) + sock = sslpsk.wrap_socket( + raw_sock, psk=Pyro.config.PYROPSK, server_side=False, + ciphers="PSK-AES256-CBC-SHA", # available in openssl 1.0.2 + ssl_version=ssl.PROTOCOL_TLSv1) + # all the rest is the same as in Pyro/protocol.py + + conn=TCPConnection(sock, sock.getpeername()) + # receive the authentication challenge string, and use that to build the actual identification string. + try: + authChallenge=self.recvAuthChallenge(conn) + except ProtocolError,x: + # check if we were denied + if hasattr(x,"partialMsg") and x.partialMsg[:len(self.denyMSG)]==self.denyMSG: + raise ConnectionDeniedError(Pyro.constants.deniedReasons[int(x.partialMsg[-1])]) + else: + raise + # reply with our ident token, generated from the ident passphrase and the challenge + msg = self._sendConnect(sock,self.newConnValidator.createAuthToken(self.ident, authChallenge, conn.addr, self.URI, None) ) + if msg==self.acceptMSG: + self.conn=conn + self.conn.connected=1 + Log.msg('PYROAdapter','connected to',str(URI)) + if URI.protocol=='PYROLOCPSK': + self.resolvePYROLOC_URI("PYROPSK") # updates self.URI + elif msg[:len(self.denyMSG)]==self.denyMSG: + try: + raise ConnectionDeniedError(Pyro.constants.deniedReasons[int(msg[-1])]) + except (KeyError,ValueError): + raise ConnectionDeniedError('invalid response') + except socket.error: + Log.msg('PYROAdapter','connection failed to URI',str(URI)) + raise ProtocolError('connection failed') + +_getProtocolAdapter = Pyro.protocol.getProtocolAdapter +def getProtocolAdapter(protocol): + if protocol in ('PYROPSK', 'PYROLOCPSK'): + return PYROPSKAdapter() + return _getProtocolAdapter(protocol) + +Pyro.protocol.getProtocolAdapter = getProtocolAdapter + +_processStringURI = Pyro.core.processStringURI +def processStringURI(URI): + x=re.match(r'(?PPYROLOCPSK)://(?P[^\s:]+):?(?P\d+)?/(?P\S*)',URI) + if x: + protocol=x.group('protocol') + hostname=x.group('hostname') + port=x.group('port') + if port: + port=int(port) + else: + port=0 + name=x.group('name') + return PyroURI(hostname,name,port,protocol) + return _processStringURI(URI) +Pyro.core.processStringURI = processStringURI diff -r 9deec258ab1a -r 21164625b393 connectors/PYRO/__init__.py --- a/connectors/PYRO/__init__.py Fri Oct 12 13:24:47 2018 +0300 +++ b/connectors/PYRO/__init__.py Fri Nov 23 12:13:24 2018 +0100 @@ -36,9 +36,10 @@ import Pyro.util from Pyro.errors import PyroError +import PSKManagement as PSK from runtime import PlcStatus -service_type = '_PYRO._tcp.local.' +zeroconf_service_type = '_PYRO._tcp.local.' # this module attribute contains a list of DNS-SD (Zeroconf) service types # supported by this connector confnode. # @@ -52,45 +53,29 @@ """ confnodesroot.logger.write(_("PYRO connecting to URI : %s\n") % uri) - servicetype, location = uri.split("://") - if servicetype == "PYROS": - schemename = "PYROLOCSSL" - # Protect against name->IP substitution in Pyro3 - Pyro.config.PYRO_DNS_URI = True - # Beware Pyro lib need str path, not unicode - # don't rely on PYRO_STORAGE ! see documentation - Pyro.config.PYROSSL_CERTDIR = os.path.abspath(str(confnodesroot.ProjectPath) + '/certs') - if not os.path.exists(Pyro.config.PYROSSL_CERTDIR): + scheme, location = uri.split("://") + if scheme == "PYROS": + import connectors.PYRO.PSK_Adapter + schemename = "PYROLOCPSK" + url, ID = location.split('#') #TODO fix exception when # not found + # load PSK from project + secpath = os.path.join(str(confnodesroot.ProjectPath), 'psk', ID+'.secret') + if not os.path.exists(secpath): confnodesroot.logger.write_error( - 'Error : the directory %s is missing for SSL certificates (certs_dir).' - 'Please fix it in your project.\n' % Pyro.config.PYROSSL_CERTDIR) + 'Error: Pre-Shared-Key Secret in %s is missing!\n' % secpath) return None - else: - confnodesroot.logger.write(_("PYRO using certificates in '%s' \n") - % (Pyro.config.PYROSSL_CERTDIR)) - Pyro.config.PYROSSL_CERT = "client.crt" - Pyro.config.PYROSSL_KEY = "client.key" - - # Ugly Monkey Patching - def _gettimeout(self): - return self.timeout - - def _settimeout(self, timeout): - self.timeout = timeout - from M2Crypto.SSL import Connection # pylint: disable=import-error - Connection.timeout = None - Connection.gettimeout = _gettimeout - Connection.settimeout = _settimeout - # M2Crypto.SSL.Checker.WrongHost: Peer certificate commonName does not - # match host, expected 127.0.0.1, got server - Connection.clientPostConnectionCheck = None + secret = open(secpath).read().partition(':')[2].rstrip('\n\r') + Pyro.config.PYROPSK = (secret, ID) + # strip ID from URL, so that pyro can understand it. + location = url else: schemename = "PYROLOC" - if location.find(service_type) != -1: + + if location.find(zeroconf_service_type) != -1: try: from zeroconf import Zeroconf r = Zeroconf() - i = r.get_service_info(service_type, location) + i = r.get_service_info(zeroconf_service_type, location) if i is None: raise Exception("'%s' not found" % location) ip = str(socket.inet_ntoa(i.address)) @@ -108,8 +93,8 @@ try: RemotePLCObjectProxy = Pyro.core.getAttrProxyForURI(schemename + "://" + location + "/PLCObject") except Exception: - confnodesroot.logger.write_error(_("Connection to '%s' failed.\n") % location) - confnodesroot.logger.write_error(traceback.format_exc()) + confnodesroot.logger.write_error(_("Connection to '%s' failed with exception '%s'\n") % (location, str(e))) + #confnodesroot.logger.write_error(traceback.format_exc()) return None def PyroCatcher(func, default=None): @@ -136,13 +121,17 @@ # Check connection is effective. # lambda is for getattr of GetPLCstatus to happen inside catcher - if PyroCatcher(RemotePLCObjectProxy.GetPLCstatus)() is None: - confnodesroot.logger.write_error(_("Cannot get PLC status - connection failed.\n")) - return None + IDPSK = PyroCatcher(RemotePLCObjectProxy.GetPLCID)() + if IDPSK is None: + confnodesroot.logger.write_warning(_("PLC did not provide identity and security infomation.\n")) + else: + ID,secret = IDPSK + PSK.UpdateID(confnodesroot.ProjectPath, ID, secret, uri) + _special_return_funcs = { "StartPLC": False, - "GetTraceVariables": ("Broken", None), + "GetTraceVariables": (PlcStatus.Broken, None), "GetPLCstatus": (PlcStatus.Broken, None), "RemoteExec": (-1, "RemoteExec script failed!") } diff -r 9deec258ab1a -r 21164625b393 connectors/PYRO/dialog.py --- a/connectors/PYRO/dialog.py Fri Oct 12 13:24:47 2018 +0300 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,62 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright (C) 2018: Smarteh -# -# See COPYING file for copyrights details. - - -from __future__ import absolute_import -from __future__ import print_function - -import wx -from zope.interface import implementer - -from controls.UriLocationEditor import IConnectorPanel - -URITypes = ["LOCAL", "PYRO", "PYROS"] - - -def PYRO_connector_dialog(confnodesroot): - [ID_IPTEXT, ID_PORTTEXT] = [wx.NewId() for _init_ctrls in range(2)] - - @implementer(IConnectorPanel) - class PYROConnectorPanel(wx.Panel): - def __init__(self, typeConnector, parrent, *args, **kwargs): - self.type = typeConnector - self.parrent = parrent - wx.Panel.__init__(self, parrent, *args, **kwargs) - self._init_ctrls() - self._init_sizers() - self.uri = None - - def _init_ctrls(self): - self.IpText = wx.TextCtrl(parent=self, id=ID_IPTEXT, size=wx.Size(200, -1)) - self.PortText = wx.TextCtrl(parent=self, id=ID_PORTTEXT, size=wx.Size(200, -1)) - - def _init_sizers(self): - self.mainSizer = wx.FlexGridSizer(cols=2, hgap=10, rows=5, vgap=10) - self.mainSizer.AddWindow(wx.StaticText(self, label=_("URI host:")), - flag=wx.ALIGN_CENTER_VERTICAL) - self.mainSizer.AddWindow(self.IpText, flag=wx.GROW) - - self.mainSizer.AddWindow(wx.StaticText(self, label=_("URI port:")), - flag=wx.ALIGN_CENTER_VERTICAL) - self.mainSizer.AddWindow(self.PortText, flag=wx.GROW) - self.SetSizer(self.mainSizer) - - def SetURI(self, uri): - self.uri = uri - uri_list = uri.strip().split(":") - length = len(uri_list) - if length == 3: - self.IpText.SetValue(uri_list[1].strip("/")) - self.PortText.SetValue(uri_list[2]) - elif length == 2: - self.IpText.SetValue(uri_list[1].strip("/")) - - def GetURI(self): - self.uri = self.type+"://"+self.IpText.GetValue()+":"+self.PortText.GetValue() - return self.uri - - return PYROConnectorPanel("PYRO", confnodesroot) diff -r 9deec258ab1a -r 21164625b393 connectors/PYRO_dialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/PYRO_dialog.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# See COPYING file for copyrights details. + +from __future__ import absolute_import + +from itertools import repeat, islice, chain +import wx + +from connectors.SchemeEditor import SchemeEditor + + +model = [('host',_("Host:")), + ('port',_("Port:"))] + +# (scheme, model, secure) +models = [("LOCAL", [], False), ("PYRO", model, False), ("PYROS", model, True)] + +Schemes = list(zip(*models)[0]) + +_PerSchemeConf = {sch : (mod,sec) for sch,mod,sec in models} + +class PYRO_dialog(SchemeEditor): + def __init__(self, scheme, *args, **kwargs): + + # ID selector is enabled only on PYROS (secure) + self.model, self.EnableIDSelector = _PerSchemeConf[scheme] + + SchemeEditor.__init__(self, scheme, *args, **kwargs) + + def SetLoc(self, loc): + hostport, ID = list(islice(chain(loc.split("#"), repeat("")),2)) + host, port = list(islice(chain(hostport.split(":"), repeat("")),2)) + self.SetFields(locals()) + + def GetLoc(self): + if self.model: + fields = self.GetFields() + template = "{host}" + if fields['port']: + template += ":{port}" + if fields['ID']: + template += "#{ID}" + + return template.format(**fields) + return '' + diff -r 9deec258ab1a -r 21164625b393 connectors/SchemeEditor.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/SchemeEditor.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# See COPYING file for copyrights details. + +from __future__ import absolute_import + +from itertools import repeat, izip_longest +from functools import partial +import wx + +from controls.IDBrowser import IDBrowser + +class SchemeEditor(wx.Panel): + def __init__(self, scheme, parent, *args, **kwargs): + self.txtctrls = {} + wx.Panel.__init__(self, parent, *args, **kwargs) + + self.fieldsizer = wx.FlexGridSizer(cols=2, hgap=10, vgap=10) + + if self.EnableIDSelector: + self.model = self.model + [("ID", _("ID:"))] + + for tag, label in self.model: + txtctrl = wx.TextCtrl(parent=self, size=wx.Size(200, -1)) + self.txtctrls[tag] = txtctrl + for win, flag in [ + (wx.StaticText(self, label=label), wx.ALIGN_CENTER_VERTICAL), + (txtctrl, wx.GROW)]: + self.fieldsizer.AddWindow(win, flag=flag) + + self.fieldsizer.AddSpacer(20) + + if self.EnableIDSelector: + self.mainsizer = wx.FlexGridSizer(cols=2, hgap=10, vgap=10) + self.mainsizer.AddSizer(self.fieldsizer) + self.idselector = IDBrowser( + self, parent.ctr, + # use a callafter, as editor can be deleted by calling SetURI + partial(wx.CallAfter, parent.SetURI), + self.txtctrls[tag].SetValue) + self.mainsizer.AddWindow(self.idselector) + self.SetSizer(self.mainsizer) + else: + self.SetSizer(self.fieldsizer) + + def SetFields(self, fields): + for tag, label in self.model: + self.txtctrls[tag].SetValue(fields[tag]) + + def GetFields(self): + return {tag: self.txtctrls[tag].GetValue() for tag,label in self.model} + diff -r 9deec258ab1a -r 21164625b393 connectors/WAMP/__init__.py --- a/connectors/WAMP/__init__.py Fri Oct 12 13:24:47 2018 +0300 +++ b/connectors/WAMP/__init__.py Fri Nov 23 12:13:24 2018 +0100 @@ -71,10 +71,10 @@ WAMP://127.0.0.1:12345/path#realm#ID WAMPS://127.0.0.1:12345/path#realm#ID """ - servicetype, location = uri.split("://") + scheme, location = uri.split("://") urlpath, realm, ID = location.split('#') urlprefix = {"WAMP": "ws", - "WAMPS": "wss"}[servicetype] + "WAMPS": "wss"}[scheme] url = urlprefix+"://"+urlpath def RegisterWampClient(): @@ -153,6 +153,9 @@ self.__dict__[attrName] = member return member + # TODO : GetPLCID() + # TODO : PSK.UpdateID() + # Try to get the proxy object try: return WampPLCObjectProxy() diff -r 9deec258ab1a -r 21164625b393 connectors/WAMP/dialog.py --- a/connectors/WAMP/dialog.py Fri Oct 12 13:24:47 2018 +0300 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,91 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright (C) 2018: Smarteh -# -# See COPYING file for copyrights details. - - -from __future__ import absolute_import -from __future__ import print_function - -import wx -from zope.interface import implementer - -from controls.UriLocationEditor import IConnectorPanel - -URITypes = ["WAMP", "WAMPS"] - - -def WAMP_connector_dialog(confnodesroot): - [ID_IPTEXT, ID_PORTTEXT, ID_REALMTEXT, ID_WAMPIDTEXT, ID_SECURECHECKBOX] = [wx.NewId() for _init_ctrls in range(5)] - - @implementer(IConnectorPanel) - class WAMPConnectorPanel(wx.Panel): - def __init__(self, typeConnector, parrent, *args, **kwargs): - self.type = typeConnector - self.parrent = parrent - wx.Panel.__init__(self, parrent, *args, **kwargs) - self._init_ctrls() - self._init_sizers() - self.uri = None - - def _init_ctrls(self): - self.IpText = wx.TextCtrl(parent=self, id=ID_IPTEXT, size=wx.Size(200, -1)) - self.PortText = wx.TextCtrl(parent=self, id=ID_PORTTEXT, size=wx.Size(200, -1)) - self.RealmText = wx.TextCtrl(parent=self, id=ID_REALMTEXT, size=wx.Size(200, -1)) - self.WAMPIDText = wx.TextCtrl(parent=self, id=ID_WAMPIDTEXT, size=wx.Size(200, -1)) - self.SecureCheckbox = wx.CheckBox(self, ID_SECURECHECKBOX, _("Is connection secure?")) - - def _init_sizers(self): - self.mainSizer = wx.FlexGridSizer(cols=2, hgap=10, rows=5, vgap=10) - self.mainSizer.AddWindow(wx.StaticText(self, label=_("URI host:")), - flag=wx.ALIGN_CENTER_VERTICAL) - self.mainSizer.AddWindow(self.IpText, flag=wx.GROW) - - self.mainSizer.AddWindow(wx.StaticText(self, label=_("URI port:")), - flag=wx.ALIGN_CENTER_VERTICAL) - self.mainSizer.AddWindow(self.PortText, flag=wx.GROW) - - self.mainSizer.AddWindow(wx.StaticText(self, label=_("Realm:")), - flag=wx.ALIGN_CENTER_VERTICAL) - self.mainSizer.AddWindow(self.RealmText, flag=wx.GROW) - - self.mainSizer.AddWindow(wx.StaticText(self, label=_("WAMP ID:")), - flag=wx.ALIGN_CENTER_VERTICAL) - self.mainSizer.AddWindow(self.WAMPIDText, flag=wx.GROW) - - self.mainSizer.AddWindow(wx.StaticText(self, label=""), flag=wx.ALIGN_CENTER_VERTICAL) - self.mainSizer.AddWindow(self.SecureCheckbox, flag=wx.GROW) - - self.SetSizer(self.mainSizer) - - def SetURI(self, uri): - self.uri = uri - uri_list = uri.strip().split(":") - length = len(uri_list) - - if length > 0: - if uri_list[0] == URITypes[1]: - self.SecureCheckbox.SetValue(True) - - if length > 2: - self.IpText.SetValue(uri_list[1].strip("/")) - wampSett = uri_list[2].split("#") - length2 = len(wampSett) - if length2 > 0: - self.PortText.SetValue(wampSett[0]) - if length2 > 1: - self.RealmText.SetValue(wampSett[1]) - if length2 > 2: - self.WAMPIDText.SetValue(wampSett[2]) - - def GetURI(self): - if self.IpText.Validate(): - typeForURI = self.type + "S" if self.SecureCheckbox.GetValue() else self.type - self.uri = typeForURI + "://" + self.IpText.GetValue() + ":" + self.PortText.GetValue() + "#" + self.RealmText.GetValue() + "#" + self.WAMPIDText.GetValue() - return self.uri - else: - return "" - - return WAMPConnectorPanel("WAMP", confnodesroot) diff -r 9deec258ab1a -r 21164625b393 connectors/WAMP_dialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/WAMP_dialog.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# See COPYING file for copyrights details. + +from __future__ import absolute_import + +from itertools import repeat, islice, chain +import wx + +from connectors.SchemeEditor import SchemeEditor + +Schemes = ["WAMP", "WAMPS"] + +model = [('host',_("Host:")), + ('port',_("Port:")), + ('realm',_("Realm:"))] + +class WAMP_dialog(SchemeEditor): + def __init__(self, *args, **kwargs): + self.model = model + self.EnableIDSelector = True + SchemeEditor.__init__(self, *args, **kwargs) + + def SetLoc(self, loc): + hostport, realm, ID = list(islice(chain(loc.split("#"), repeat("")),3)) + host, port = list(islice(chain(hostport.split(":"), repeat("")),2)) + self.SetFields(locals()) + + def GetLoc(self): + fields = self.GetFields() + + #TODO : input validation test + + template = "{host}" + \ + (":{port}" if fields['port'] else '') +\ + "#{realm}#{ID}" + + return template.format(**fields) + diff -r 9deec258ab1a -r 21164625b393 connectors/__init__.py --- a/connectors/__init__.py Fri Oct 12 13:24:47 2018 +0300 +++ b/connectors/__init__.py Fri Nov 23 12:13:24 2018 +0100 @@ -30,32 +30,33 @@ from os import listdir, path import util.paths as paths -_base_path = paths.AbsDir(__file__) +connectors_packages = ["PYRO","WAMP"] def _GetLocalConnectorClassFactory(name): return lambda: getattr(__import__(name, globals(), locals()), name + "_connector_factory") -def _GetLocalConnectorClassDialog(name): - return lambda: getattr(__import__(name + '.dialog', globals(), locals(), fromlist=['dialog']), name + "_connector_dialog") +connectors = {name: _GetLocalConnectorClassFactory(name) + for name in connectors_packages} +_dialogs_imported = False +per_URI_connectors = None +schemes = None -def _GetLocalConnectorURITypes(name): - return lambda: getattr(__import__(name + '.dialog', globals(), locals(), fromlist=['dialog']), "URITypes", None) +# lazy import of connectors dialogs, only if used +def _Import_Dialogs(): + global per_URI_connectors, schemes, _dialogs_imported + if not _dialogs_imported: + _dialogs_imported = True + per_URI_connectors = {} + schemes = [] + for con_name in connectors_packages: + module = __import__(con_name + '_dialog', globals(), locals()) - -connectors = {name: - _GetLocalConnectorClassFactory(name) - for name in listdir(_base_path) - if (path.isdir(path.join(_base_path, name)) and - not name.startswith("__"))} - -connectors_dialog = {name: - {"function": _GetLocalConnectorClassDialog(name), "URITypes": _GetLocalConnectorURITypes(name)} - for name in listdir(_base_path) - if (path.isdir(path.join(_base_path, name)) and - not name.startswith("__"))} + for scheme in module.Schemes: + per_URI_connectors[scheme] = getattr(module, con_name + '_dialog') + schemes += [scheme] def ConnectorFactory(uri, confnodesroot): @@ -63,41 +64,31 @@ Return a connector corresponding to the URI or None if cannot connect to URI """ - servicetype = uri.split("://")[0].upper() - if servicetype == "LOCAL": + scheme = uri.split("://")[0].upper() + if scheme == "LOCAL": # Local is special case # pyro connection to local runtime # started on demand, listening on random port - servicetype = "PYRO" + scheme = "PYRO" runtime_port = confnodesroot.AppFrame.StartLocalRuntime( taskbaricon=True) uri = "PYROLOC://127.0.0.1:" + str(runtime_port) - elif servicetype in connectors: + elif scheme in connectors: pass - elif servicetype[-1] == 'S' and servicetype[:-1] in connectors: - servicetype = servicetype[:-1] + elif scheme[-1] == 'S' and scheme[:-1] in connectors: + scheme = scheme[:-1] else: return None # import module according to uri type - connectorclass = connectors[servicetype]() + connectorclass = connectors[scheme]() return connectorclass(uri, confnodesroot) -def ConnectorDialog(conn_type, confnodesroot): - if conn_type not in connectors_dialog: - return None +def EditorClassFromScheme(scheme): + _Import_Dialogs() + return per_URI_connectors.get(scheme, None) - connectorclass = connectors_dialog[conn_type]["function"]() - return connectorclass(confnodesroot) - - -def GetConnectorFromURI(uri): - typeOfConnector = None - for conn_type in connectors_dialog: - connectorTypes = connectors_dialog[conn_type]["URITypes"]() - if connectorTypes and uri in connectorTypes: - typeOfConnector = conn_type - break - - return typeOfConnector +def ConnectorSchemes(): + _Import_Dialogs() + return schemes diff -r 9deec258ab1a -r 21164625b393 controls/DiscoveryPanel.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/controls/DiscoveryPanel.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,235 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz, a Integrated Development Environment for +# programming IEC 61131-3 automates supporting plcopen standard and CanFestival. +# +# Copyright (C) 2007: Edouard TISSERANT and Laurent BESSARD +# Copyright (C) 2017: Andrey Skvortsov +# +# See COPYING file for copyrights details. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + +from __future__ import absolute_import +import socket +from six.moves import xrange +import wx +import wx.lib.mixins.listctrl as listmix +from zeroconf import ServiceBrowser, Zeroconf + + +service_type = '_PYRO._tcp.local.' + + +class AutoWidthListCtrl(wx.ListCtrl, listmix.ListCtrlAutoWidthMixin): + def __init__(self, parent, id, name, pos=wx.DefaultPosition, + size=wx.DefaultSize, style=0): + wx.ListCtrl.__init__(self, parent, id, pos, size, style, name=name) + listmix.ListCtrlAutoWidthMixin.__init__(self) + +class DiscoveryPanel(wx.Panel, listmix.ColumnSorterMixin): + + def _init_coll_MainSizer_Items(self, parent): + parent.AddWindow(self.staticText1, 0, border=20, flag=wx.TOP | wx.LEFT | wx.RIGHT | wx.GROW) + parent.AddWindow(self.ServicesList, 0, border=20, flag=wx.LEFT | wx.RIGHT | wx.GROW) + parent.AddSizer(self.ButtonGridSizer, 0, border=20, flag=wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.GROW) + + def _init_coll_MainSizer_Growables(self, parent): + parent.AddGrowableCol(0) + parent.AddGrowableRow(1) + + def _init_coll_ButtonGridSizer_Items(self, parent): + parent.AddWindow(self.RefreshButton, 0, border=0, flag=0) + parent.AddWindow(self.ByIPCheck, 0, border=0, flag=0) + + def _init_coll_ButtonGridSizer_Growables(self, parent): + parent.AddGrowableCol(0) + parent.AddGrowableRow(0) + + def _init_sizers(self): + self.MainSizer = wx.FlexGridSizer(cols=1, hgap=0, rows=3, vgap=10) + self.ButtonGridSizer = wx.FlexGridSizer(cols=2, hgap=5, rows=1, vgap=0) + + self._init_coll_MainSizer_Items(self.MainSizer) + self._init_coll_MainSizer_Growables(self.MainSizer) + self._init_coll_ButtonGridSizer_Items(self.ButtonGridSizer) + self._init_coll_ButtonGridSizer_Growables(self.ButtonGridSizer) + + self.SetSizer(self.MainSizer) + + def _init_list_ctrl(self): + # Set up list control + listID = wx.NewId() + self.ServicesList = AutoWidthListCtrl( + id=listID, + name='ServicesList', parent=self, pos=wx.Point(0, 0), size=wx.Size(0, 0), + style=wx.LC_REPORT | wx.LC_EDIT_LABELS | wx.LC_SORT_ASCENDING | wx.LC_SINGLE_SEL) + self.ServicesList.InsertColumn(0, _('NAME')) + self.ServicesList.InsertColumn(1, _('TYPE')) + self.ServicesList.InsertColumn(2, _('IP')) + self.ServicesList.InsertColumn(3, _('PORT')) + self.ServicesList.SetColumnWidth(0, 150) + self.ServicesList.SetColumnWidth(1, 150) + self.ServicesList.SetColumnWidth(2, 150) + self.ServicesList.SetColumnWidth(3, 150) + self.ServicesList.SetInitialSize(wx.Size(-1, 300)) + self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnItemSelected, id=listID) + self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.OnItemActivated, id=listID) + + def _init_ctrls(self, prnt): + self.staticText1 = wx.StaticText( + label=_('Services available:'), name='staticText1', parent=self, + pos=wx.Point(0, 0), size=wx.DefaultSize, style=0) + + refreshID = wx.NewId() + self.RefreshButton = wx.Button( + id=refreshID, + label=_('Refresh'), name='RefreshButton', parent=self, + pos=wx.Point(0, 0), size=wx.DefaultSize, style=0) + self.Bind(wx.EVT_BUTTON, self.OnRefreshButton, id=refreshID) + + self.ByIPCheck = wx.CheckBox(self, label=_("Use IP instead of Service Name")) + + self._init_sizers() + self.Fit() + + def __init__(self, parent): + wx.Panel.__init__(self, parent) + + self._init_list_ctrl() + listmix.ColumnSorterMixin.__init__(self, 4) + + self._init_ctrls(parent) + + self.itemDataMap = {} + self.nextItemId = 0 + + self.URI = None + self.Browser = None + + self.ZeroConfInstance = Zeroconf() + self.RefreshList() + self.LatestSelection = None + + def __del__(self): + if self.Browser is not None: + self.Browser.cancel() + self.ZeroConfInstance.close() + + def RefreshList(self): + if self.Browser is not None: + self.Browser.cancel() + self.Browser = ServiceBrowser(self.ZeroConfInstance, service_type, self) + + def OnRefreshButton(self, event): + self.ServicesList.DeleteAllItems() + self.RefreshList() + + # Used by the ColumnSorterMixin, see wx/lib/mixins/listctrl.py + def GetListCtrl(self): + return self.ServicesList + + def getColumnText(self, index, col): + item = self.ServicesList.GetItem(index, col) + return item.GetText() + + def OnItemSelected(self, event): + self.SetURI(event.m_itemIndex) + event.Skip() + + def OnItemActivated(self, event): + self.SetURI(event.m_itemIndex) + self.EndModal(wx.ID_OK) + event.Skip() + +# def SetURI(self, idx): +# connect_type = self.getColumnText(idx, 1) +# connect_address = self.getColumnText(idx, 2) +# connect_port = self.getColumnText(idx, 3) +# +# self.URI = "%s://%s:%s"%(connect_type, connect_address, connect_port) + + def SetURI(self, idx): + self.LatestSelection = idx + svcname = self.getColumnText(idx, 0) + connect_type = self.getColumnText(idx, 1) + self.URI = "%s://%s" % (connect_type, svcname + '.' + service_type) + + def GetURI(self): + if self.LatestSelection is not None: + if self.ByIPCheck.IsChecked(): + self.URI = "%s://%s:%s" % tuple( + map(lambda col:self.getColumnText(self.LatestSelection, col), + (1, 2, 3))) + + return self.URI + return None + + def remove_service(self, zeroconf, _type, name): + wx.CallAfter(self._removeService, name) + + def _removeService(self, name): + ''' + called when a service with the desired type goes offline. + ''' + + # loop through the list items looking for the service that went offline + for idx in xrange(self.ServicesList.GetItemCount()): + # this is the unique identifier assigned to the item + item_id = self.ServicesList.GetItemData(idx) + + # this is the full typename that was received by addService + item_name = self.itemDataMap[item_id][4] + + if item_name == name: + self.ServicesList.DeleteItem(idx) + break + + def add_service(self, zeroconf, _type, name): + wx.CallAfter(self._addService, _type, name) + + def _addService(self, _type, name): + ''' + called when a service with the desired type is discovered. + ''' + info = self.ZeroConfInstance.get_service_info(_type, name) + svcname = name.split(".")[0] + typename = _type.split(".")[0][1:] + ip = str(socket.inet_ntoa(info.address)) + port = info.port + + num_items = self.ServicesList.GetItemCount() + + # display the new data in the list + new_item = self.ServicesList.InsertStringItem(num_items, svcname) + self.ServicesList.SetStringItem(new_item, 1, "%s" % typename) + self.ServicesList.SetStringItem(new_item, 2, "%s" % ip) + self.ServicesList.SetStringItem(new_item, 3, "%s" % port) + + # record the new data for the ColumnSorterMixin + # we assign every list item a unique id (that won't change when items + # are added or removed) + self.ServicesList.SetItemData(new_item, self.nextItemId) + + # the value of each column has to be stored in the itemDataMap + # so that ColumnSorterMixin knows how to sort the column. + + # "name" is included at the end so that self.removeService + # can access it. + self.itemDataMap[self.nextItemId] = [svcname, typename, ip, port, name] + + self.nextItemId += 1 diff -r 9deec258ab1a -r 21164625b393 controls/IDBrowser.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/controls/IDBrowser.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,219 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# See COPYING file for copyrights details. + +from __future__ import absolute_import +import os +import wx +import wx.dataview as dv +import PSKManagement as PSK +from PSKManagement import * +from dialogs.IDMergeDialog import IDMergeDialog + +class IDBrowserModel(dv.PyDataViewIndexListModel): + def __init__(self, project_path, columncount): + self.project_path = project_path + self.columncount = columncount + self.data = PSK.GetData(project_path) + dv.PyDataViewIndexListModel.__init__(self, len(self.data)) + + def _saveData(self): + PSK.SaveData(self.project_path, self.data) + + def GetColumnType(self, col): + return "string" + + def GetValueByRow(self, row, col): + return self.data[row][col] + + def SetValueByRow(self, value, row, col): + self.data[row][col] = value + self._saveData() + + def GetColumnCount(self): + return len(self.data[0]) if self.data else self.columncount + + def GetCount(self): + return len(self.data) + + def Compare(self, item1, item2, col, ascending): + if not ascending: # swap sort order? + item2, item1 = item1, item2 + row1 = self.GetRow(item1) + row2 = self.GetRow(item2) + if col == 0: + return cmp(int(self.data[row1][col]), int(self.data[row2][col])) + else: + return cmp(self.data[row1][col], self.data[row2][col]) + + def DeleteRows(self, rows): + rows = list(rows) + rows.sort(reverse=True) + + for row in rows: + PSK.DeleteID(self.project_path, self.data[row][COL_ID]) + del self.data[row] + self.RowDeleted(row) + self._saveData() + + def AddRow(self, value): + self.data.append(value) + self.RowAppended() + self._saveData() + + def Import(self, filepath, sircb): + data = PSK.ImportIDs(self.project_path, filepath, sircb) + if data is not None: + self.data = data + self.Reset(len(self.data)) + + def Export(self, filepath): + PSK.ExportIDs(self.project_path, filepath) + +colflags = dv.DATAVIEW_COL_RESIZABLE|dv.DATAVIEW_COL_SORTABLE + +class IDBrowser(wx.Panel): + def __init__(self, parent, ctr, SelectURICallBack=None, SelectIDCallBack=None, **kwargs): + big = self.isManager = SelectURICallBack is None and SelectIDCallBack is None + wx.Panel.__init__(self, parent, -1, size=(800 if big else 450, + 600 if big else 200)) + + self.SelectURICallBack = SelectURICallBack + self.SelectIDCallBack = SelectIDCallBack + + dvStyle = wx.BORDER_THEME | dv.DV_ROW_LINES + if self.isManager : + # no multiple selection in selector mode + dvStyle |= dv.DV_MULTIPLE + self.dvc = dv.DataViewCtrl(self, style = dvStyle) + + args = lambda *a,**k:(a,k) + + ColumnsDesc = [ + args(_("ID"), COL_ID, width = 70), + args(_("Last URI"), COL_URI, width = 300 if big else 80), + args(_("Description"), COL_DESC, width = 300 if big else 200, + mode = dv.DATAVIEW_CELL_EDITABLE + if self.isManager + else dv.DATAVIEW_CELL_INERT), + args(_("Last connection"), COL_LAST, width = 120), + ] + + self.model = IDBrowserModel(ctr.ProjectPath, len(ColumnsDesc)) + self.dvc.AssociateModel(self.model) + + col_list = [] + for a,k in ColumnsDesc: + col_list.append( + self.dvc.AppendTextColumn(*a,**dict(k, flags = colflags))) + col_list[COL_LAST].SetSortOrder(False) + + # TODO : sort by last bvisit by default + + self.Sizer = wx.BoxSizer(wx.VERTICAL) + self.Sizer.Add(self.dvc, 1, wx.EXPAND) + + btnbox = wx.BoxSizer(wx.HORIZONTAL) + if self.isManager : + + # deletion of secret and metadata + deleteButton = wx.Button(self, label=_("Delete ID")) + self.Bind(wx.EVT_BUTTON, self.OnDeleteButton, deleteButton) + btnbox.Add(deleteButton, 0, wx.LEFT|wx.RIGHT, 5) + + # export all + exportButton = wx.Button(self, label=_("Export all")) + self.Bind(wx.EVT_BUTTON, self.OnExportButton, exportButton) + btnbox.Add(exportButton, 0, wx.LEFT|wx.RIGHT, 5) + + # import with a merge -> duplicates are asked for + importButton = wx.Button(self, label=_("Import")) + self.Bind(wx.EVT_BUTTON, self.OnImportButton, importButton) + btnbox.Add(importButton, 0, wx.LEFT|wx.RIGHT, 5) + + else : + # selector mode + self.useURIButton = wx.Button(self, label=_("Use last URI")) + self.Bind(wx.EVT_BUTTON, self.OnUseURIButton, self.useURIButton) + self.useURIButton.Disable() + btnbox.Add(self.useURIButton, 0, wx.LEFT|wx.RIGHT, 5) + + self.Sizer.Add(btnbox, 0, wx.TOP|wx.BOTTOM, 5) + self.Bind(dv.EVT_DATAVIEW_SELECTION_CHANGED, self.OnSelectionChanged, self.dvc) + + + def OnDeleteButton(self, evt): + items = self.dvc.GetSelections() + rows = [self.model.GetRow(item) for item in items] + + # Ask if user really wants to delete + if wx.MessageBox(_('Are you sure to delete selected IDs?'), + _('Delete IDs'), + wx.YES_NO | wx.CENTRE | wx.NO_DEFAULT) != wx.YES: + return + + self.model.DeleteRows(rows) + + def OnSelectionChanged(self, evt): + if not self.isManager : + items = self.dvc.GetSelections() + somethingSelected = len(items) > 0 + self.useURIButton.Enable(somethingSelected) + if somethingSelected: + row = self.model.GetRow(items[0]) + ID = self.model.GetValueByRow(row, COL_ID) + self.SelectIDCallBack(ID) + + + def OnUseURIButton(self, evt): + row = self.model.GetRow(self.dvc.GetSelections()[0]) + URI = self.model.GetValueByRow(row, COL_URI) + if URI: + self.SelectURICallBack(URI) + + def OnExportButton(self, evt): + dialog = wx.FileDialog(self, _("Choose a file"), + wildcard = _("PSK ZIP files (*.zip)|*.zip"), + style = wx.SAVE | wx.OVERWRITE_PROMPT) + if dialog.ShowModal() == wx.ID_OK: + self.model.Export(dialog.GetPath()) + + def ShouldIReplaceCallback(self,existing,replacement): + ID,URI,DESC,LAST = existing + _ID,_URI,_DESC,_LAST = replacement + dlg = IDMergeDialog(self, + _("Import IDs"), + (_("Replace information for ID {ID} ?") + "\n\n" + + _("Existing:") + "\n " + + _("Description:") + " {DESC}\n " + + _("Last known URI:") + " {URI}\n " + + _("Last connection:") + " {LAST}\n\n" + + _("Replacement:") + "\n " + + _("Description:") + " {_DESC}\n " + + _("Last known URI:") + " {_URI}\n " + + _("Last connection:") + " {_LAST}\n").format(**locals()), + _("Do the same for following IDs"), + [_("Replace"), _("Keep"),_("Cancel")]) + + answer = dlg.ShowModal() # return value ignored as we have "Ok" only anyhow + if answer == wx.ID_CANCEL: + return CANCEL + + if dlg.OptionChecked(): + if answer == wx.ID_YES: + return REPLACE_ALL + return KEEP_ALL + else: + if answer == wx.ID_YES: + return REPLACE + return KEEP + + def OnImportButton(self, evt): + dialog = wx.FileDialog(self, _("Choose a file"), + wildcard = _("PSK ZIP files (*.zip)|*.zip"), + style = wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) + if dialog.ShowModal() == wx.ID_OK: + self.model.Import(dialog.GetPath(), + self.ShouldIReplaceCallback) + diff -r 9deec258ab1a -r 21164625b393 controls/UriLocationEditor.py --- a/controls/UriLocationEditor.py Fri Oct 12 13:24:47 2018 +0300 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,115 +0,0 @@ -from __future__ import absolute_import - -import wx -from zope.interface import Interface, Attribute -from zope.interface.verify import verifyObject -from connectors import connectors_dialog, ConnectorDialog, GetConnectorFromURI - - -[ID_URIWIZARDDIALOG, ID_URITYPECHOICE] = [wx.NewId() for _init_ctrls in range(2)] - - -class IConnectorPanel(Interface): - """This is interface for panel of seperate connector type""" - uri = Attribute("""uri of connections""") - type = Attribute("""type of connector""") - - def SetURI(uri): # pylint: disable=no-self-argument - """methode for set uri""" - - def GetURI(): # pylint: disable=no-self-argument - """metohde for get uri""" - - -class UriLocationEditor(wx.Dialog): - def _init_ctrls(self, parent): - self.UriTypeChoice = wx.Choice(parent=self, id=ID_URIWIZARDDIALOG, choices=self.URITYPES) - self.UriTypeChoice.SetSelection(0) - self.Bind(wx.EVT_CHOICE, self.OnTypeChoice, self.UriTypeChoice) - self.PanelSizer = wx.BoxSizer(wx.HORIZONTAL) - self.ButtonSizer = self.CreateButtonSizer(wx.OK | wx.CANCEL) - - def _init_sizers(self): - self.mainSizer = wx.BoxSizer(wx.VERTICAL) - typeSizer = wx.BoxSizer(wx.HORIZONTAL) - typeSizer.Add(wx.StaticText(self, wx.ID_ANY, _("URI type:")), border=5, flag=wx.ALIGN_CENTER_VERTICAL | wx.ALL) - typeSizer.Add(self.UriTypeChoice, border=5, flag=wx.ALL) - self.mainSizer.Add(typeSizer) - - self.mainSizer.Add(self.PanelSizer, border=5, flag=wx.ALL) - self.mainSizer.Add(self.ButtonSizer, border=5, flag=wx.BOTTOM | wx.ALIGN_CENTER_HORIZONTAL) - self.SetSizer(self.mainSizer) - self.Layout() - self.Fit() - - def __init__(self, parent, uri): - wx.Dialog.__init__(self, id=ID_URIWIZARDDIALOG, - name='UriLocationEditor', parent=parent, - title='Uri location') - self.URITYPES = [_("- Select URI type -")] - for connector_type, connector_function in connectors_dialog.iteritems(): - try: - connector_function['function']() - self.URITYPES.append(connector_type) - except Exception: - pass - - self.selected = None - self.parrent = parent - self.logger = self.parrent.CTR.logger - self._init_ctrls(parent) - self._init_sizers() - self.SetURI(uri) - self.CenterOnParent() - - def OnTypeChoice(self, event): - self._removePanelType() - index = event.GetSelection() - if index > 0: - self.selected = event.GetString() - self.panelType = self._getConnectorDialog(self.selected) - if self.panelType: - self.PanelSizer.Add(self.panelType) - self.mainSizer.Layout() - self.Fit() - self.panelType.Refresh() - - def SetURI(self, uri): - self._removePanelType() - uri_list = uri.strip().split(":") - if uri_list: - uri_type = uri_list[0].upper() - type = GetConnectorFromURI(uri_type) - if type: - self.selected = type - self.UriTypeChoice.SetStringSelection(self.selected) - self.panelType = self._getConnectorDialog(self.selected) - if self.panelType: - self.panelType.SetURI(uri) - self.PanelSizer.Add(self.panelType) - self.PanelSizer.Layout() - self.mainSizer.Layout() - self.Fit() - self.panelType.Refresh() - - def GetURI(self): - if not self.selected or not self.panelType: - return "" - else: - return self.panelType.GetURI() - - def _removePanelType(self): - for i in range(self.PanelSizer.GetItemCount()): - item = self.PanelSizer.GetItem(i) - item.DeleteWindows() - self.PanelSizer.Remove(i) - self.Fit() - self.PanelSizer.Layout() - - def _getConnectorDialog(self, connectorType): - connector = ConnectorDialog(connectorType, self) - if connector and IConnectorPanel.providedBy(connector): - if verifyObject(IConnectorPanel, connector): - return connector - else: - return None diff -r 9deec258ab1a -r 21164625b393 controls/__init__.py --- a/controls/__init__.py Fri Oct 12 13:24:47 2018 +0300 +++ b/controls/__init__.py Fri Nov 23 12:13:24 2018 +0100 @@ -44,3 +44,4 @@ from controls.LogViewer import LogViewer from controls.CustomStyledTextCtrl import CustomStyledTextCtrl from controls.CustomToolTip import CustomToolTip + diff -r 9deec258ab1a -r 21164625b393 dialogs/DiscoveryDialog.py --- a/dialogs/DiscoveryDialog.py Fri Oct 12 13:24:47 2018 +0300 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,268 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# This file is part of Beremiz, a Integrated Development Environment for -# programming IEC 61131-3 automates supporting plcopen standard and CanFestival. -# -# Copyright (C) 2007: Edouard TISSERANT and Laurent BESSARD -# Copyright (C) 2017: Andrey Skvortsov -# -# See COPYING file for copyrights details. -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - - -from __future__ import absolute_import -import socket -from six.moves import xrange -import wx -import wx.lib.mixins.listctrl as listmix -from zeroconf import ServiceBrowser, Zeroconf - - -service_type = '_PYRO._tcp.local.' - - -class AutoWidthListCtrl(wx.ListCtrl, listmix.ListCtrlAutoWidthMixin): - def __init__(self, parent, id, name, pos=wx.DefaultPosition, - size=wx.DefaultSize, style=0): - wx.ListCtrl.__init__(self, parent, id, pos, size, style, name=name) - listmix.ListCtrlAutoWidthMixin.__init__(self) - - -[ - ID_DISCOVERYDIALOG, ID_DISCOVERYDIALOGSTATICTEXT1, - ID_DISCOVERYDIALOGSERVICESLIST, ID_DISCOVERYDIALOGREFRESHBUTTON, - ID_DISCOVERYDIALOGLOCALBUTTON, ID_DISCOVERYDIALOGIPBUTTON, -] = [wx.NewId() for _init_ctrls in range(6)] - - -class DiscoveryDialog(wx.Dialog, listmix.ColumnSorterMixin): - - def _init_coll_MainSizer_Items(self, parent): - parent.AddWindow(self.staticText1, 0, border=20, flag=wx.TOP | wx.LEFT | wx.RIGHT | wx.GROW) - parent.AddWindow(self.ServicesList, 0, border=20, flag=wx.LEFT | wx.RIGHT | wx.GROW) - parent.AddSizer(self.ButtonGridSizer, 0, border=20, flag=wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.GROW) - - def _init_coll_MainSizer_Growables(self, parent): - parent.AddGrowableCol(0) - parent.AddGrowableRow(1) - - def _init_coll_ButtonGridSizer_Items(self, parent): - parent.AddWindow(self.RefreshButton, 0, border=0, flag=0) - parent.AddWindow(self.LocalButton, 0, border=0, flag=0) - parent.AddWindow(self.IpButton, 0, border=0, flag=0) - parent.AddSizer(self.ButtonSizer, 0, border=0, flag=0) - - def _init_coll_ButtonGridSizer_Growables(self, parent): - parent.AddGrowableCol(0) - parent.AddGrowableCol(1) - parent.AddGrowableRow(0) - - def _init_sizers(self): - self.MainSizer = wx.FlexGridSizer(cols=1, hgap=0, rows=3, vgap=10) - self.ButtonGridSizer = wx.FlexGridSizer(cols=4, hgap=5, rows=1, vgap=0) - - self._init_coll_MainSizer_Items(self.MainSizer) - self._init_coll_MainSizer_Growables(self.MainSizer) - self._init_coll_ButtonGridSizer_Items(self.ButtonGridSizer) - self._init_coll_ButtonGridSizer_Growables(self.ButtonGridSizer) - - self.SetSizer(self.MainSizer) - - def _init_list_ctrl(self): - # Set up list control - self.ServicesList = AutoWidthListCtrl( - id=ID_DISCOVERYDIALOGSERVICESLIST, - name='ServicesList', parent=self, pos=wx.Point(0, 0), size=wx.Size(0, 0), - style=wx.LC_REPORT | wx.LC_EDIT_LABELS | wx.LC_SORT_ASCENDING | wx.LC_SINGLE_SEL) - self.ServicesList.InsertColumn(0, _('NAME')) - self.ServicesList.InsertColumn(1, _('TYPE')) - self.ServicesList.InsertColumn(2, _('IP')) - self.ServicesList.InsertColumn(3, _('PORT')) - self.ServicesList.SetColumnWidth(0, 150) - self.ServicesList.SetColumnWidth(1, 150) - self.ServicesList.SetColumnWidth(2, 150) - self.ServicesList.SetColumnWidth(3, 150) - self.ServicesList.SetInitialSize(wx.Size(-1, 300)) - self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnItemSelected, id=ID_DISCOVERYDIALOGSERVICESLIST) - self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.OnItemActivated, id=ID_DISCOVERYDIALOGSERVICESLIST) - - def _init_ctrls(self, prnt): - self.staticText1 = wx.StaticText( - id=ID_DISCOVERYDIALOGSTATICTEXT1, - label=_('Services available:'), name='staticText1', parent=self, - pos=wx.Point(0, 0), size=wx.DefaultSize, style=0) - - self.RefreshButton = wx.Button( - id=ID_DISCOVERYDIALOGREFRESHBUTTON, - label=_('Refresh'), name='RefreshButton', parent=self, - pos=wx.Point(0, 0), size=wx.DefaultSize, style=0) - self.Bind(wx.EVT_BUTTON, self.OnRefreshButton, id=ID_DISCOVERYDIALOGREFRESHBUTTON) - - self.LocalButton = wx.Button( - id=ID_DISCOVERYDIALOGLOCALBUTTON, - label=_('Local'), name='LocalButton', parent=self, - pos=wx.Point(0, 0), size=wx.DefaultSize, style=0) - self.Bind(wx.EVT_BUTTON, self.OnLocalButton, id=ID_DISCOVERYDIALOGLOCALBUTTON) - - self.IpButton = wx.Button( - id=ID_DISCOVERYDIALOGIPBUTTON, - label=_('Add IP'), name='IpButton', parent=self, - pos=wx.Point(0, 0), size=wx.DefaultSize, style=0) - self.Bind(wx.EVT_BUTTON, self.OnIpButton, id=ID_DISCOVERYDIALOGIPBUTTON) - - self.ButtonSizer = self.CreateButtonSizer(wx.OK | wx.CANCEL | wx.CENTER) - - self._init_sizers() - self.Fit() - - def __init__(self, parent): - wx.Dialog.__init__( - self, id=ID_DISCOVERYDIALOG, - name='DiscoveryDialog', parent=parent, - style=wx.DEFAULT_DIALOG_STYLE, - title=_('Service Discovery')) - - self._init_list_ctrl() - listmix.ColumnSorterMixin.__init__(self, 4) - - self._init_ctrls(parent) - - self.itemDataMap = {} - self.nextItemId = 0 - - self.URI = None - self.Browser = None - - self.ZeroConfInstance = Zeroconf() - self.RefreshList() - self.LatestSelection = None - - def __del__(self): - if self.Browser is not None: - self.Browser.cancel() - self.ZeroConfInstance.close() - - def RefreshList(self): - if self.Browser is not None: - self.Browser.cancel() - self.Browser = ServiceBrowser(self.ZeroConfInstance, service_type, self) - - def OnRefreshButton(self, event): - self.ServicesList.DeleteAllItems() - self.RefreshList() - - def OnLocalButton(self, event): - self.URI = "LOCAL://" - self.EndModal(wx.ID_OK) - event.Skip() - - def OnIpButton(self, event): - def GetColText(col): - return self.getColumnText(self.LatestSelection, col) - - if self.LatestSelection is not None: - self.URI = "%s://%s:%s" % tuple(map(GetColText, (1, 2, 3))) - self.EndModal(wx.ID_OK) - event.Skip() - - # Used by the ColumnSorterMixin, see wx/lib/mixins/listctrl.py - def GetListCtrl(self): - return self.ServicesList - - def getColumnText(self, index, col): - item = self.ServicesList.GetItem(index, col) - return item.GetText() - - def OnItemSelected(self, event): - self.SetURI(event.m_itemIndex) - event.Skip() - - def OnItemActivated(self, event): - self.SetURI(event.m_itemIndex) - self.EndModal(wx.ID_OK) - event.Skip() - -# def SetURI(self, idx): -# connect_type = self.getColumnText(idx, 1) -# connect_address = self.getColumnText(idx, 2) -# connect_port = self.getColumnText(idx, 3) -# -# self.URI = "%s://%s:%s"%(connect_type, connect_address, connect_port) - - def SetURI(self, idx): - self.LatestSelection = idx - svcname = self.getColumnText(idx, 0) - connect_type = self.getColumnText(idx, 1) - self.URI = "%s://%s" % (connect_type, svcname + '.' + service_type) - - def GetURI(self): - return self.URI - - def remove_service(self, zeroconf, _type, name): - wx.CallAfter(self._removeService, name) - - def _removeService(self, name): - ''' - called when a service with the desired type goes offline. - ''' - - # loop through the list items looking for the service that went offline - for idx in xrange(self.ServicesList.GetItemCount()): - # this is the unique identifier assigned to the item - item_id = self.ServicesList.GetItemData(idx) - - # this is the full typename that was received by addService - item_name = self.itemDataMap[item_id][4] - - if item_name == name: - self.ServicesList.DeleteItem(idx) - break - - def add_service(self, zeroconf, _type, name): - wx.CallAfter(self._addService, _type, name) - - def _addService(self, _type, name): - ''' - called when a service with the desired type is discovered. - ''' - info = self.ZeroConfInstance.get_service_info(_type, name) - svcname = name.split(".")[0] - typename = _type.split(".")[0][1:] - ip = str(socket.inet_ntoa(info.address)) - port = info.port - - num_items = self.ServicesList.GetItemCount() - - # display the new data in the list - new_item = self.ServicesList.InsertStringItem(num_items, svcname) - self.ServicesList.SetStringItem(new_item, 1, "%s" % typename) - self.ServicesList.SetStringItem(new_item, 2, "%s" % ip) - self.ServicesList.SetStringItem(new_item, 3, "%s" % port) - - # record the new data for the ColumnSorterMixin - # we assign every list item a unique id (that won't change when items - # are added or removed) - self.ServicesList.SetItemData(new_item, self.nextItemId) - - # the value of each column has to be stored in the itemDataMap - # so that ColumnSorterMixin knows how to sort the column. - - # "name" is included at the end so that self.removeService - # can access it. - self.itemDataMap[self.nextItemId] = [svcname, typename, ip, port, name] - - self.nextItemId += 1 diff -r 9deec258ab1a -r 21164625b393 dialogs/IDManager.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/dialogs/IDManager.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,27 @@ +from __future__ import absolute_import + +import wx +from connectors import ConnectorSchemes, EditorClassFromScheme +from controls.DiscoveryPanel import DiscoveryPanel +from controls.IDBrowser import IDBrowser + +class IDManager(wx.Dialog): + def __init__(self, parent, ctr): + self.ctr = ctr + wx.Dialog.__init__(self, + name='IDManager', parent=parent, + title=_('URI Editor'), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + size=(800,600)) + # start IDBrowser in manager mode + self.browser = IDBrowser(self, ctr) + self.Bind(wx.EVT_CHAR_HOOK, self.OnEscapeKey) + + def OnEscapeKey(self, event): + keycode = event.GetKeyCode() + if keycode == wx.WXK_ESCAPE: + self.EndModal(wx.ID_CANCEL) + else: + event.Skip() + + diff -r 9deec258ab1a -r 21164625b393 dialogs/IDMergeDialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/dialogs/IDMergeDialog.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# See COPYING file for copyrights details. + +from __future__ import absolute_import +import wx + +# class RichMessageDialog is still not available in wxPython 3.0.2 +class IDMergeDialog(wx.Dialog): + def __init__(self, parent, title, question, optiontext, button_texts): + wx.Dialog.__init__(self, parent, title=title) + + main_sizer = wx.BoxSizer(wx.VERTICAL) + + message = wx.StaticText(self, label=question) + main_sizer.AddWindow(message, border=20, + flag = wx.ALIGN_CENTER_HORIZONTAL | wx.TOP | wx.LEFT | wx.RIGHT) + + self.check = wx.CheckBox(self, label=optiontext) + main_sizer.AddWindow(self.check, border=20, + flag=wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_HORIZONTAL) + + buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) + for label,wxID in zip(button_texts, [wx.ID_YES, wx.ID_NO, wx.ID_CANCEL]): + Button = wx.Button(self, label=label) + def OnButtonFactory(_wxID): + return lambda event: self.EndModal(_wxID) + self.Bind(wx.EVT_BUTTON, OnButtonFactory(wxID), Button) + buttons_sizer.AddWindow(Button) + + main_sizer.AddSizer(buttons_sizer, border=20, + flag=wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.ALIGN_RIGHT) + + self.SetSizer(main_sizer) + self.Fit() + + self.Bind(wx.EVT_CHAR_HOOK, self.OnEscapeKey) + + def OnEscapeKey(self, event): + keycode = event.GetKeyCode() + if keycode == wx.WXK_ESCAPE: + self.EndModal(wx.ID_CANCEL) + else: + event.Skip() + + def OptionChecked(self): + return self.check.GetValue() diff -r 9deec258ab1a -r 21164625b393 dialogs/UriEditor.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/dialogs/UriEditor.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,92 @@ +from __future__ import absolute_import + +import wx +from connectors import ConnectorSchemes, EditorClassFromScheme +from controls.DiscoveryPanel import DiscoveryPanel + +class UriEditor(wx.Dialog): + def _init_ctrls(self, parent): + self.UriTypeChoice = wx.Choice(parent=self, choices=self.choices) + self.UriTypeChoice.SetSelection(0) + self.Bind(wx.EVT_CHOICE, self.OnTypeChoice, self.UriTypeChoice) + self.editor_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.ButtonSizer = self.CreateButtonSizer(wx.OK | wx.CANCEL) + + def _init_sizers(self): + self.mainSizer = wx.BoxSizer(wx.VERTICAL) + typeSizer = wx.BoxSizer(wx.HORIZONTAL) + typeSizer.Add(wx.StaticText(self, wx.ID_ANY, _("Scheme :")), border=5, + flag=wx.ALIGN_CENTER_VERTICAL | wx.ALL) + typeSizer.Add(self.UriTypeChoice, border=5, flag=wx.ALL) + self.mainSizer.Add(typeSizer) + + self.mainSizer.Add(self.editor_sizer, border=5, flag=wx.ALL) + self.mainSizer.Add(self.ButtonSizer, border=5, + flag=wx.BOTTOM | wx.ALIGN_CENTER_HORIZONTAL) + self.SetSizer(self.mainSizer) + self.Layout() + self.Fit() + + def __init__(self, parent, ctr, uri=''): + self.ctr = ctr + wx.Dialog.__init__(self, + name='UriEditor', parent=parent, + title=_('URI Editor')) + self.choices = [_("- Search local network -")] + ConnectorSchemes() + self._init_ctrls(parent) + self._init_sizers() + self.scheme = None + self.scheme_editor = None + self.SetURI(uri) + self.CenterOnParent() + + def OnTypeChoice(self, event): + index = event.GetSelection() + self._replaceSchemeEditor(event.GetString() if index > 0 else None) + + def SetURI(self, uri): + try: + scheme, loc = uri.strip().split("://",1) + scheme = scheme.upper() + except: + scheme = None + + if scheme in ConnectorSchemes(): + self.UriTypeChoice.SetStringSelection(scheme) + else: + self.UriTypeChoice.SetSelection(0) + + self._replaceSchemeEditor(scheme) + + if scheme is not None: + self.scheme_editor.SetLoc(loc) + + + def GetURI(self): + if self.scheme is None: + return self.scheme_editor.GetURI() + else: + return self.scheme+"://"+self.scheme_editor.GetLoc() + + def _replaceSchemeEditor(self, scheme): + self.scheme = scheme + + if self.scheme_editor is not None: + self.editor_sizer.Detach(self.scheme_editor) + self.scheme_editor.Destroy() + self.scheme_editor = None + + if scheme is not None : + EditorClass = EditorClassFromScheme(scheme) + self.scheme_editor = EditorClass(scheme,self) + else : + # None is for searching local network + self.scheme_editor = DiscoveryPanel(self) + + self.editor_sizer.Add(self.scheme_editor) + self.scheme_editor.Refresh() + + self.editor_sizer.Layout() + self.mainSizer.Layout() + self.Fit() + diff -r 9deec258ab1a -r 21164625b393 dialogs/__init__.py --- a/dialogs/__init__.py Fri Oct 12 13:24:47 2018 +0300 +++ b/dialogs/__init__.py Fri Nov 23 12:13:24 2018 +0100 @@ -49,4 +49,6 @@ from dialogs.PouActionDialog import PouActionDialog from dialogs.FindInPouDialog import FindInPouDialog from dialogs.BrowseValuesLibraryDialog import BrowseValuesLibraryDialog -from dialogs.DiscoveryDialog import DiscoveryDialog +from dialogs.UriEditor import UriEditor +from dialogs.IDManager import IDManager + diff -r 9deec258ab1a -r 21164625b393 editors/ConfTreeNodeEditor.py --- a/editors/ConfTreeNodeEditor.py Fri Oct 12 13:24:47 2018 +0300 +++ b/editors/ConfTreeNodeEditor.py Fri Nov 23 12:13:24 2018 +0100 @@ -33,8 +33,8 @@ from IDEFrame import TITLE, FILEMENU, PROJECTTREE, PAGETITLES -from controls import TextCtrlAutoComplete, UriLocationEditor -from dialogs import BrowseValuesLibraryDialog +from controls import TextCtrlAutoComplete +from dialogs import BrowseValuesLibraryDialog, UriEditor from util.BitmapLibrary import GetBitmap if wx.Platform == '__WXMSW__': @@ -345,7 +345,7 @@ # Get connector uri uri = CTR_BeremizRoot.getURI_location().strip() - dialog = UriLocationEditor.UriLocationEditor(CTR_AppFrame, uri) + dialog = UriEditor(CTR_AppFrame, CTR, uri) if dialog.ShowModal() == wx.ID_OK: CTR_BeremizRoot.setURI_location(dialog.GetURI()) diff -r 9deec258ab1a -r 21164625b393 images/IDManager.png Binary file images/IDManager.png has changed diff -r 9deec258ab1a -r 21164625b393 images/icons.svg --- a/images/icons.svg Fri Oct 12 13:24:47 2018 +0300 +++ b/images/icons.svg Fri Nov 23 12:13:24 2018 +0100 @@ -15,7 +15,7 @@ height="1052.3622" id="svg2" sodipodi:version="0.32" - inkscape:version="0.92.3 (2405546, 2018-03-11)" + inkscape:version="0.91 r13725" sodipodi:docname="icons.svg" inkscape:output_extension="org.inkscape.output.svg.inkscape"> %% editIECrawcode editWXGLADE editPYTHONcode EditCfile Transfer Connect Disconnect Debug %% + style="font-size:12.76095104px;line-height:1.25">%% editIECrawcode editWXGLADE editPYTHONcode EditCfile Transfer Connect Disconnect Debug IDManager %% %% fullscreen %% + + + + + + diff -r 9deec258ab1a -r 21164625b393 modbus/modbus.py --- a/modbus/modbus.py Fri Oct 12 13:24:47 2018 +0300 +++ b/modbus/modbus.py Fri Nov 23 12:13:24 2018 +0100 @@ -789,7 +789,7 @@ LDFLAGS = [] LDFLAGS.append(" \"-L" + ModbusPath + "\"") - LDFLAGS.append(os.path.join(ModbusPath, "libmb.a")) + LDFLAGS.append(" \"" + os.path.join(ModbusPath, "libmb.a") + "\"") LDFLAGS.append(" \"-Wl,-rpath," + ModbusPath + "\"") # LDFLAGS.append("\"" + os.path.join(ModbusPath, "mb_slave_and_master.o") + "\"") # LDFLAGS.append("\"" + os.path.join(ModbusPath, "mb_slave.o") + "\"") diff -r 9deec258ab1a -r 21164625b393 py_ext/plc_python.c --- a/py_ext/plc_python.c Fri Oct 12 13:24:47 2018 +0300 +++ b/py_ext/plc_python.c Fri Nov 23 12:13:24 2018 +0100 @@ -89,6 +89,10 @@ */ void __PythonEvalFB(int poll, PYTHON_EVAL* data__) { + if(!__GET_VAR(data__->TRIG)){ + /* ACK is False when TRIG is false, except a pulse when receiving result */ + __SET_VAR(data__->, ACK,, 0); + } /* detect rising edge on TRIG to trigger evaluation */ if(((__GET_VAR(data__->TRIG) && !__GET_VAR(data__->TRIGM1)) || /* polling is equivalent to trig on value rather than on rising edge*/ @@ -109,7 +113,7 @@ if(__GET_VAR(data__->STATE) == PYTHON_FB_ANSWERED){ /* Copy buffer content into result*/ __SET_VAR(data__->, RESULT,, __GET_VAR(data__->BUFFER)); - /* signal result presece to PLC*/ + /* signal result presence to PLC*/ __SET_VAR(data__->, ACK,, 1); /* Mark as free */ __SET_VAR(data__->, STATE,, PYTHON_FB_FREE); diff -r 9deec258ab1a -r 21164625b393 runtime/NevowServer.py --- a/runtime/NevowServer.py Fri Oct 12 13:24:47 2018 +0300 +++ b/runtime/NevowServer.py Fri Nov 23 12:13:24 2018 +0100 @@ -30,6 +30,7 @@ from zope.interface import implements from nevow import appserver, inevow, tags, loaders, athena, url, rend from nevow.page import renderer +from nevow.static import File from formless import annotate from formless import webform from formless import configurable @@ -77,10 +78,12 @@ class MainPage(athena.LiveElement): jsClass = u"WebInterface.PLC" docFactory = loaders.stan( - tags.div(render=tags.directive('liveElement'))[ - tags.div(id='content')[ - tags.div(render=tags.directive('PLCElement'))] - ]) + tags.invisible[ + tags.div(render=tags.directive('liveElement'))[ + tags.div(id='content')[ + tags.div(render=tags.directive('PLCElement'))] + ], + tags.a(href='settings')['Settings']]) def __init__(self, *a, **kw): athena.LiveElement.__init__(self, *a, **kw) @@ -135,11 +138,34 @@ def __init__(self): configurable.Configurable.__init__(self, None) self.bindingsNames = [] + self.infostringcount = 0 def getBindingNames(self, ctx): return self.bindingsNames - def addExtension(self, name, desc, fields, btnlabel, callback): + def addInfoString(self, label, value, name=None): + if isinstance(value, str): + def default(*k): + return value + else: + def default(*k): + return value() + + if name is None: + name = "_infostring_" + str(self.infostringcount) + self.infostringcount = self.infostringcount + 1 + + def _bind(ctx): + return annotate.Property( + name, + annotate.String( + label=label, + default=default, + immutable=True)) + setattr(self, 'bind_' + name, _bind) + self.bindingsNames.append(name) + + def addSettings(self, name, desc, fields, btnlabel, callback): def _bind(ctx): return annotate.MethodBinding( 'action_' + name, @@ -192,25 +218,27 @@ # This makes webform_css url answer some default CSS child_webform_css = webform.defaultCSS + child_webinterface_css = File(paths.AbsNeighbourFile(__file__, 'webinterface.css'), 'text/css') implements(ISettings) - docFactory = loaders.stan([ - tags.html[ - tags.head[ - tags.title[_("Beremiz Runtime Settings")], - tags.link(rel='stylesheet', - type='text/css', - href=url.here.child("webform_css")) - ], - tags.body[ - tags.h1["Runtime settings:"], - webform.renderForms('staticSettings'), - tags.h2["Extensions settings:"], - webform.renderForms('dynamicSettings'), - ] - ] - ]) + docFactory = loaders.stan([tags.html[ + tags.head[ + tags.title[_("Beremiz Runtime Settings")], + tags.link(rel='stylesheet', + type='text/css', + href=url.here.child("webform_css")), + tags.link(rel='stylesheet', + type='text/css', + href=url.here.child("webinterface_css")) + ], + tags.body[ + tags.a(href='/')['Back'], + tags.h1["Runtime settings:"], + webform.renderForms('staticSettings'), + tags.h1["Extensions settings:"], + webform.renderForms('dynamicSettings'), + ]]]) def configurable_staticSettings(self, ctx): return configurable.TypedInterfaceConfigurable(self) @@ -305,11 +333,11 @@ # print "We will be called back when the client disconnects" -def RegisterWebsite(port): +def RegisterWebsite(iface, port): website = WebInterface() site = appserver.NevowSite(website) - reactor.listenTCP(port, site) + reactor.listenTCP(port, site, interface=iface) print(_('HTTP interface port :'), port) return website diff -r 9deec258ab1a -r 21164625b393 runtime/PLCObject.py --- a/runtime/PLCObject.py Fri Oct 12 13:24:47 2018 +0300 +++ b/runtime/PLCObject.py Fri Nov 23 12:13:24 2018 +0100 @@ -23,7 +23,7 @@ from __future__ import absolute_import -from threading import Thread, Lock, Semaphore, Event, Condition +from threading import Thread, Lock, Semaphore, Event import ctypes import os import sys @@ -37,7 +37,9 @@ from runtime.typemapping import TypeTranslator from runtime.loglevels import LogLevelsDefault, LogLevelsCount +from runtime.Stunnel import getPSKID from runtime import PlcStatus +from runtime import MainWorker if os.name in ("nt", "ce"): dlopen = _ctypes.LoadLibrary @@ -64,133 +66,25 @@ sys.stdout.flush() -class job(object): - """ - job to be executed by a worker - """ - def __init__(self, call, *args, **kwargs): - self.job = (call, args, kwargs) - self.result = None - self.success = False - self.exc_info = None - - def do(self): - """ - do the job by executing the call, and deal with exceptions - """ - try: - call, args, kwargs = self.job - self.result = call(*args, **kwargs) - self.success = True - except Exception: - self.success = False - self.exc_info = sys.exc_info() - - -class worker(object): - """ - serialize main thread load/unload of PLC shared objects - """ - def __init__(self): - # Only one job at a time - self._finish = False - self._threadID = None - self.mutex = Lock() - self.todo = Condition(self.mutex) - self.done = Condition(self.mutex) - self.free = Condition(self.mutex) - self.job = None - - def runloop(self, *args, **kwargs): - """ - meant to be called by worker thread (blocking) - """ - self._threadID = _thread.get_ident() - if args or kwargs: - job(*args, **kwargs).do() - # result is ignored - self.mutex.acquire() - while not self._finish: - self.todo.wait() - if self.job is not None: - self.job.do() - self.done.notify() - else: - self.free.notify() - self.mutex.release() - - def call(self, *args, **kwargs): - """ - creates a job, execute it in worker thread, and deliver result. - if job execution raise exception, re-raise same exception - meant to be called by non-worker threads, but this is accepted. - blocking until job done - """ - - _job = job(*args, **kwargs) - - if self._threadID == _thread.get_ident() or self._threadID is None: - # if caller is worker thread execute immediately - _job.do() - else: - # otherwise notify and wait for completion - self.mutex.acquire() - - while self.job is not None: - self.free.wait() - - self.job = _job - self.todo.notify() - self.done.wait() - _job = self.job - self.job = None - self.mutex.release() - - if _job.success: - return _job.result - else: - exc_type = _job.exc_info[0] - exc_value = _job.exc_info[1] - exc_traceback = _job.exc_info[2] - six.reraise(exc_type, exc_value, exc_traceback) - - def quit(self): - """ - unblocks main thread, and terminate execution of runloop() - """ - # mark queue - self._finish = True - self.mutex.acquire() - self.job = None - self.todo.notify() - self.mutex.release() - - -MainWorker = worker() - - def RunInMain(func): def func_wrapper(*args, **kwargs): return MainWorker.call(func, *args, **kwargs) return func_wrapper -class PLCObject(pyro.ObjBase): - def __init__(self, server): - pyro.ObjBase.__init__(self) - self.evaluator = server.evaluator - self.argv = [server.workdir] + server.argv # force argv[0] to be "path" to exec... - self.workingdir = server.workdir +class PLCObject(object): + def __init__(self, WorkingDir, argv, statuschange, evaluator, pyruntimevars): + self.workingdir = WorkingDir + # FIXME : is argv of any use nowadays ? + self.argv = [WorkingDir] + argv # force argv[0] to be "path" to exec... + self.statuschange = statuschange + self.evaluator = evaluator + self.pyruntimevars = pyruntimevars self.PLCStatus = PlcStatus.Empty self.PLClibraryHandle = None self.PLClibraryLock = Lock() - self.DummyIteratorLock = None # Creates fake C funcs proxies self._InitPLCStubCalls() - self.daemon = server.daemon - self.statuschange = server.statuschange - self.hmi_frame = None - self.pyruntimevars = server.pyruntimevars self._loading_error = None self.python_runtime_vars = None self.TraceThread = None @@ -198,7 +92,7 @@ self.Traces = [] # First task of worker -> no @RunInMain - def AutoLoad(self): + def AutoLoad(self, autostart): # Get the last transfered PLC try: self.CurrentPLCFilename = open( @@ -206,10 +100,15 @@ "r").read().strip() + lib_ext if self.LoadPLC(): self.PLCStatus = PlcStatus.Stopped + if autostart: + self.StartPLC() + return except Exception: self.PLCStatus = PlcStatus.Empty self.CurrentPLCFilename = None + self.StatusChange() + def StatusChange(self): if self.statuschange is not None: for callee in self.statuschange: @@ -545,6 +444,10 @@ return self.PLCStatus, map(self.GetLogCount, xrange(LogLevelsCount)) @RunInMain + def GetPLCID(self): + return getPSKID() + + @RunInMain def NewPLC(self, md5sum, data, extrafiles): if self.PLCStatus in [PlcStatus.Stopped, PlcStatus.Empty, PlcStatus.Broken]: NewFileName = md5sum + lib_ext diff -r 9deec258ab1a -r 21164625b393 runtime/PyroServer.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/runtime/PyroServer.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,78 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz runtime. + +# Copyright (C) 2007: Edouard TISSERANT and Laurent BESSARD +# Copyright (C) 2017: Andrey Skvortsov +# Copyright (C) 2018: Edouard TISSERANT + +# See COPYING file for copyrights details. + +from __future__ import absolute_import +from __future__ import print_function +import sys + +import Pyro +import Pyro.core as pyro +import runtime +from runtime.ServicePublisher import ServicePublisher + + +class Server(object): + def __init__(self, servicename, ip_addr, port): + self.continueloop = True + self.daemon = None + self.servicename = servicename + self.ip_addr = ip_addr + self.port = port + self.servicepublisher = None + + def _to_be_published(self): + return self.servicename is not None and \ + self.ip_addr not in ["", "localhost", "127.0.0.1"] + + def PrintServerInfo(self): + print(_("Pyro port :"), self.port) + + if self._to_be_published(): + print(_("Publishing service on local network")) + + sys.stdout.flush() + + def PyroLoop(self, when_ready): + while self.continueloop: + Pyro.config.PYRO_MULTITHREADED = 0 + pyro.initServer() + self.daemon = pyro.Daemon(host=self.ip_addr, port=self.port) + + # pyro never frees memory after connection close if no timeout set + # taking too small timeout value may cause + # unwanted diconnection when IDE is kept busy for long periods + self.daemon.setTimeout(60) + + pyro_obj = Pyro.core.ObjBase() + pyro_obj.delegateTo(runtime.GetPLCObjectSingleton()) + + self.daemon.connect(pyro_obj, "PLCObject") + + if self._to_be_published(): + self.servicepublisher = ServicePublisher() + self.servicepublisher.RegisterService(self.servicename, self.ip_addr, self.port) + + when_ready() + self.daemon.requestLoop() + self.daemon.sock.close() + + def Restart(self): + self._stop() + + def Quit(self): + self.continueloop = False + self._stop() + + def _stop(self): + if self.servicepublisher is not None: + self.servicepublisher.UnRegisterService() + self.servicepublisher = None + self.daemon.shutdown(True) diff -r 9deec258ab1a -r 21164625b393 runtime/ServicePublisher.py --- a/runtime/ServicePublisher.py Fri Oct 12 13:24:47 2018 +0300 +++ b/runtime/ServicePublisher.py Fri Nov 23 12:13:24 2018 +0100 @@ -56,11 +56,15 @@ self.name = name self.port = port - self.server = zeroconf.Zeroconf() - print("MDNS brodcasting on :" + ip) + if ip == "0.0.0.0": + print("MDNS brodcasted on all interfaces") + interfaces=zeroconf.InterfaceChoice.All + ip = self.gethostaddr() + else: + interfaces=[ip] - if ip == "0.0.0.0": - ip = self.gethostaddr() + self.server = zeroconf.Zeroconf(interfaces=interfaces) + print("MDNS brodcasted service address :" + ip) self.ip_32b = socket.inet_aton(ip) diff -r 9deec258ab1a -r 21164625b393 runtime/Stunnel.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/runtime/Stunnel.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,41 @@ +import os +from binascii import b2a_hqx +try: + from runtime.spawn_subprocess import call +except ImportError: + from subprocess import call + +restart_stunnel_cmdline = ["/etc/init.d/S50stunnel","restart"] + +_PSKpath = None + +def PSKgen(ID, PSKpath): + + # b2a_hqx output len is 4/3 input len + secret = os.urandom(192) # int(256/1.3333) + secretstring = b2a_hqx(secret) + + PSKstring = ID+":"+secretstring + with open(PSKpath, 'w') as f: + f.write(PSKstring) + call(restart_stunnel_cmdline) + +def ensurePSK(ID, PSKpath): + global _PSKpath + _PSKpath = PSKpath + # check if already there + if not os.path.exists(PSKpath): + # create if needed + PSKgen(ID, PSKpath) + +def getPSKID(): + if _PSKpath is not None : + if not os.path.exists(_PSKpath): + confnodesroot.logger.write_error( + 'Error: Pre-Shared-Key Secret in %s is missing!\n' % _PSKpath) + return None + ID,_sep,PSK = open(_PSKpath).read().partition(':') + PSK = PSK.rstrip('\n\r') + return (ID,PSK) + return None + diff -r 9deec258ab1a -r 21164625b393 runtime/WampClient.py --- a/runtime/WampClient.py Fri Oct 12 13:24:47 2018 +0300 +++ b/runtime/WampClient.py Fri Nov 23 12:13:24 2018 +0100 @@ -33,13 +33,13 @@ from autobahn.twisted.websocket import WampWebSocketClientFactory, connectWS from autobahn.wamp import types, auth from autobahn.wamp.serializer import MsgPackSerializer -from twisted.internet.defer import inlineCallbacks from twisted.internet.protocol import ReconnectingClientFactory from twisted.python.components import registerAdapter from formless import annotate, webform import formless from nevow import tags, url, static +from runtime import GetPLCObjectSingleton mandatoryConfigItems = ["ID", "active", "realm", "url"] @@ -88,7 +88,7 @@ def GetCallee(name): """ Get Callee or Subscriber corresponding to '.' spearated object path """ names = name.split('.') - obj = _PySrv.plcobj + obj = GetPLCObjectSingleton() while names: obj = getattr(obj, names.pop(0)) return obj @@ -116,7 +116,6 @@ raise Exception( "don't know how to handle authmethod {}".format(challenge.method)) - @inlineCallbacks def onJoin(self, details): global _WampSession _WampSession = self @@ -129,13 +128,13 @@ registerOptions = None print(_("TypeError register option: {}".format(e))) - yield self.register(GetCallee(name), u'.'.join((ID, name)), registerOptions) + self.register(GetCallee(name), u'.'.join((ID, name)), registerOptions) for name in SubscribedEvents: - yield self.subscribe(GetCallee(name), text(name)) + self.subscribe(GetCallee(name), unicode(name)) for func in DoOnJoin: - yield func(self) + func(self) print(_('WAMP session joined (%s) by:' % time.ctime()), ID) @@ -146,6 +145,10 @@ _transportFactory = None print(_('WAMP session left')) + def publishWithOwnID(self, eventID, value): + ID = self.config.extra["ID"] + self.publish(unicode(ID+'.'+eventID), value) + class ReconnectingWampWebSocketClientFactory(WampWebSocketClientFactory, ReconnectingClientFactory): @@ -343,6 +346,16 @@ _PySrv = pysrv +def PublishEvent(eventID, value): + if getWampStatus() == "Attached": + _WampSession.publish(unicode(eventID), value) + + +def PublishEventWithOwnID(eventID, value): + if getWampStatus() == "Attached": + _WampSession.publishWithOwnID(unicode(eventID), value) + + # WEB CONFIGURATION INTERFACE WAMP_SECRET_URL = "secret" webExposedConfigItems = ['active', 'url', 'ID'] @@ -428,7 +441,8 @@ def RegisterWebSettings(NS): - NS.ConfigurableSettings.addExtension( + + NS.ConfigurableSettings.addSettings( "wamp", _("Wamp Settings"), webFormInterface, diff -r 9deec258ab1a -r 21164625b393 runtime/Worker.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/runtime/Worker.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,120 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz runtime. +# +# Copyright (C) 2018: Edouard TISSERANT +# +# See COPYING.Runtime file for copyrights details. + +from __future__ import absolute_import +import sys +import thread +from threading import Lock, Condition + + +class job(object): + """ + job to be executed by a worker + """ + def __init__(self, call, *args, **kwargs): + self.job = (call, args, kwargs) + self.result = None + self.success = False + self.exc_info = None + + def do(self): + """ + do the job by executing the call, and deal with exceptions + """ + try: + call, args, kwargs = self.job + self.result = call(*args, **kwargs) + self.success = True + except Exception: + self.success = False + self.exc_info = sys.exc_info() + + +class worker(object): + """ + serialize main thread load/unload of PLC shared objects + """ + def __init__(self): + # Only one job at a time + self._finish = False + self._threadID = None + self.mutex = Lock() + self.todo = Condition(self.mutex) + self.done = Condition(self.mutex) + self.free = Condition(self.mutex) + self.job = None + + def runloop(self, *args, **kwargs): + """ + meant to be called by worker thread (blocking) + """ + self._threadID = thread.get_ident() + if args or kwargs: + _job = job(*args, **kwargs) + _job.do() + if _job.success: + # result is ignored + pass + else: + raise _job.exc_info[0], _job.exc_info[1], _job.exc_info[2] + self.mutex.acquire() + while not self._finish: + self.todo.wait() + if self.job is not None: + self.job.do() + self.done.notify() + else: + self.free.notify() + self.mutex.release() + + def call(self, *args, **kwargs): + """ + creates a job, execute it in worker thread, and deliver result. + if job execution raise exception, re-raise same exception + meant to be called by non-worker threads, but this is accepted. + blocking until job done + """ + + _job = job(*args, **kwargs) + + if self._threadID == thread.get_ident(): + # if caller is worker thread execute immediately + _job.do() + else: + # otherwise notify and wait for completion + self.mutex.acquire() + + while self.job is not None: + self.free.wait() + + self.job = _job + self.todo.notify() + self.done.wait() + _job = self.job + self.job = None + self.mutex.release() + + if _job.success: + return _job.result + else: + exc_type = _job.exc_info[0] + exc_value = _job.exc_info[1] + exc_traceback = _job.exc_info[2] + six.reraise(exc_type, exc_value, exc_traceback) + + def quit(self): + """ + unblocks main thread, and terminate execution of runloop() + """ + # mark queue + self._finish = True + self.mutex.acquire() + self.job = None + self.todo.notify() + self.mutex.release() diff -r 9deec258ab1a -r 21164625b393 runtime/__init__.py --- a/runtime/__init__.py Fri Oct 12 13:24:47 2018 +0300 +++ b/runtime/__init__.py Fri Nov 23 12:13:24 2018 +0100 @@ -1,28 +1,32 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# This file is part of Beremiz runtime. -# -# Copyright (C) 2007: Edouard TISSERANT and Laurent BESSARD -# -# See COPYING.Runtime file for copyrights details. -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. +from __future__ import absolute_import +from __future__ import print_function +import traceback +import sys -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. +from runtime.Worker import worker +MainWorker = worker() -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +_PLCObjectSingleton = None -from __future__ import absolute_import -import os -from runtime.PLCObject import PLCObject, PLCprint, MainWorker -import runtime.ServicePublisher +def GetPLCObjectSingleton(): + assert _PLCObjectSingleton is not None + return _PLCObjectSingleton + + +def LogMessageAndException(msg, exp=None): + if exp is None: + exp = sys.exc_info() + if _PLCObjectSingleton is not None: + _PLCObjectSingleton.LogMessage(0, msg + '\n'.join(traceback.format_exception(*exp))) + print(msg) + traceback.print_exception(*exp) + + +def CreatePLCObjectSingleton(*args, **kwargs): + global _PLCObjectSingleton + from runtime.PLCObject import PLCObject # noqa # pylint: disable=wrong-import-position + _PLCObjectSingleton = PLCObject(*args, **kwargs) diff -r 9deec258ab1a -r 21164625b393 runtime/spawn_subprocess.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/runtime/spawn_subprocess.py Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,132 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# subset of subprocess built-in module using posix_spawn rather than fork. + +import posix_spawn +import os +import signal + +PIPE = "42" + +class Popen(object): + def __init__(self, args,stdin=None, stdout=None): + self.returncode = None + self.stdout = None + self.stdin = None + # TODO: stderr + file_actions = posix_spawn.FileActions() + if stdout is not None: + # child's stdout, child 2 parent pipe + c2pread, c2pwrite = os.pipe() + # attach child's stdout to writing en of c2p pipe + file_actions.add_dup2(c2pwrite, 1) + # close other end + file_actions.add_close(c2pread) + if stdin is not None: + # child's stdin, parent to child pipe + p2cread, p2cwrite = os.pipe() + # attach child's stdin to reading en of p2c pipe + file_actions.add_dup2(p2cread, 0) + # close other end + file_actions.add_close(p2cwrite) + self.pid = posix_spawn.posix_spawnp(args[0], args, file_actions=file_actions) + if stdout is not None: + self.stdout = os.fdopen(c2pread) + os.close(c2pwrite) + if stdin is not None: + self.stdin = os.fdopen(p2cwrite, 'w') + os.close(p2cread) + + def _wait(self): + if self.returncode is None: + self.returncode = os.waitpid(self.pid,0)[1] + + def communicate(self): + if self.stdin is not None: + self.stdin.close() + self.stdin = None + if self.stdout is not None: + stdoutdata = self.stdout.read() + else: + stdoutdata = "" + + # TODO + stderrdata = "" + + self._wait() + if self.stdout is not None: + self.stdout.close() + self.stdout = None + + return (stdoutdata, stderrdata) + + def wait(self): + if self.stdin is not None: + self.stdin.close() + self.stdin = None + self._wait() + if self.stdout is not None: + self.stdout.close() + self.stdout = None + return self.returncode + + def poll(self): + if self.returncode is None: + pid, ret = os.waitpid(self.pid, os.WNOHANG) + if (pid,ret) != (0,0): + self.returncode = ret + + if self.stdin is not None: + self.stdin.close() + self.stdin = None + if self.stdout is not None: + self.stdout.close() + self.stdout = None + + return self.returncode + + def kill(self): + os.kill(self.pid, signal.SIGKILL) + + if self.stdin is not None: + self.stdin.close() + self.stdin = None + if self.stdout is not None: + self.stdout.close() + self.stdout = None + + + +def call(*args): + cmd = [] + if isinstance(args[0], str): + if len(args)==1: + # oversimplified splitting of arguments, + # TODO: care about use of simple and double quotes + cmd = args[0].split() + else: + cmd = args + elif isinstance(args[0], list) and len(args)==1: + cmd = args[0] + else: + raise Exception("Wrong arguments passed to subprocess.call") + pid = posix_spawn.posix_spawnp(cmd[0], cmd) + return os.waitpid(pid,0) + +if __name__ == '__main__': + # unit test + + p = Popen(["tr", "abc", "def"], stdin=PIPE, stdout=PIPE) + p.stdin.write("blah") + p.stdin.close() + print p.stdout.read() + p.wait() + + p = Popen(["tr", "abc", "def"], stdin=PIPE, stdout=PIPE) + p.stdin.write("blah") + print p.communicate() + + call("echo blah0") + call(["echo", "blah1"]) + call("echo", "blah2") diff -r 9deec258ab1a -r 21164625b393 runtime/webinterface.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/runtime/webinterface.css Fri Nov 23 12:13:24 2018 +0100 @@ -0,0 +1,6 @@ + +.freeform-label { + float: left; + width: 30%; +} + diff -r 9deec258ab1a -r 21164625b393 runtime_files.list --- a/runtime_files.list Fri Oct 12 13:24:47 2018 +0300 +++ b/runtime_files.list Fri Nov 23 12:13:24 2018 +0100 @@ -10,6 +10,7 @@ runtime/PLCObject.py runtime/NevowServer.py runtime/webinterface.js +runtime/webinterface.css runtime/__init__.py runtime/ServicePublisher.py runtime/typemapping.py