Merge, with surprizingly little conflicts
authorEdouard Tisserant <edouard.tisserant@gmail.com>
Fri, 23 Nov 2018 00:33:04 +0100
changeset 2429 15f18dc8b56a
parent 2428 e0f16317668e (diff)
parent 2427 9554952d36d7 (current diff)
child 2430 65ff9a309ff3
Merge, with surprizingly little conflicts
Beremiz_service.py
PLCGenerator.py
PSKManagement.py
ProjectController.py
connectors/PYRO/__init__.py
connectors/WAMP/__init__.py
dialogs/__init__.py
i18n/Beremiz_pt_PT.po
locale/pt_PT/LC_MESSAGES/Beremiz.mo
runtime/PLCObject.py
runtime/WampClient.py
runtime/Worker.py
--- a/BeremizIDE.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/BeremizIDE.py	Fri Nov 23 00:33:04 2018 +0100
@@ -25,6 +25,7 @@
 
 
 from __future__ import absolute_import
+from __future__ import print_function
 import os
 import sys
 import tempfile
--- a/Beremiz_service.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/Beremiz_service.py	Fri Nov 23 00:33:04 2018 +0100
@@ -31,14 +31,12 @@
 import getopt
 import threading
 from threading import Thread, Semaphore, Lock
-import traceback
 import __builtin__
-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 PlcStatus
+from runtime import LogMessageAndException
 import util.paths as paths
 
 
@@ -52,7 +50,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
@@ -61,7 +59,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
@@ -77,10 +75,10 @@
     sys.exit(2)
 
 # default values
-given_ip = None
+interface = ''
 port = 3000
 webport = 8009
-wampsecret = None
+PSKpath = None
 wampconf = None
 servicename = None
 autostart = False
@@ -99,8 +97,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()
@@ -120,7 +120,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()
@@ -142,11 +142,10 @@
     WorkingDir = os.getcwd()
     argv = [WorkingDir]
 
-if __name__ == '__main__':
-    __builtin__.__dict__['_'] = lambda x: x
-    # TODO: add a cmdline parameter if Trying Preloading Xenomai makes problem
-    TryPreloadXenomai()
-    version()
+__builtin__.__dict__['_'] = lambda x: x
+# TODO: add a cmdline parameter if Trying Preloading Xenomai makes problem
+TryPreloadXenomai()
+version()
 
 
 def Bpath(*args):
@@ -185,9 +184,8 @@
     def unicode_translation(message):
         return wx.GetTranslation(message).encode(default_locale)
 
-    if __name__ == '__main__':
-        __builtin__.__dict__['_'] = unicode_translation
-        # __builtin__.__dict__['_'] = wx.GetTranslation
+    __builtin__.__dict__['_'] = unicode_translation
+    # __builtin__.__dict__['_'] = wx.GetTranslation
 
 
 # Life is hard... have a candy.
@@ -255,12 +253,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)
@@ -283,15 +280,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
@@ -310,19 +306,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
@@ -349,19 +336,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
@@ -384,9 +368,9 @@
                 wx.CallAfter(wx.GetApp().ExitMainLoop)
 
             def UpdateIcon(self, plcstatus):
-                if plcstatus is PlcStatus.Started:
+                if plcstatus is "Started":
                     currenticon = self.MakeIcon(starticon)
-                elif plcstatus is PlcStatus.Stopped:
+                elif plcstatus is "Stopped":
                     currenticon = self.MakeIcon(stopicon)
                 else:
                     currenticon = self.MakeIcon(defaulticon)
@@ -405,89 +389,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():
@@ -535,31 +436,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)
 
@@ -609,10 +491,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))
@@ -622,7 +519,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. "))
@@ -638,6 +535,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()
@@ -664,9 +566,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)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/PSKManagement.py	Fri Nov 23 00:33:04 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
+
+
--- a/ProjectController.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/ProjectController.py	Fri Nov 23 00:33:04 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"),
--- a/bacnet/runtime/device.c	Wed Oct 03 15:47:59 2018 +0300
+++ b/bacnet/runtime/device.c	Fri Nov 23 00:33:04 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:
--- a/bacnet/runtime/server.c	Wed Oct 03 15:47:59 2018 +0300
+++ b/bacnet/runtime/server.c	Fri Nov 23 00:33:04 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);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/connectors/PYRO/PSK_Adapter.py	Fri Nov 23 00:33:04 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'(?P<protocol>PYROLOCPSK)://(?P<hostname>[^\s:]+):?(?P<port>\d+)?/(?P<name>\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
--- a/connectors/PYRO/__init__.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/connectors/PYRO/__init__.py	Fri Nov 23 00:33:04 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"))
+    IDPSK = PyroCatcher(RemotePLCObjectProxy.GetPLCID)()
+    if IDPSK is None:
+        confnodesroot.logger.write_error(_("Cannot get PLC ID - connection failed.\n"))
         return None
 
+    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!")
     }
--- a/connectors/PYRO/dialog.py	Wed Oct 03 15:47:59 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)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/connectors/PYRO_dialog.py	Fri Nov 23 00:33:04 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 ''
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/connectors/SchemeEditor.py	Fri Nov 23 00:33:04 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}
+
--- a/connectors/WAMP/__init__.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/connectors/WAMP/__init__.py	Fri Nov 23 00:33:04 2018 +0100
@@ -70,10 +70,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():
@@ -152,6 +152,9 @@
                 self.__dict__[attrName] = member
             return member
 
+    # TODO : GetPLCID()
+    # TODO : PSK.UpdateID()
+
     # Try to get the proxy object
     try:
         return WampPLCObjectProxy()
--- a/connectors/WAMP/dialog.py	Wed Oct 03 15:47:59 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)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/connectors/WAMP_dialog.py	Fri Nov 23 00:33:04 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)
+
--- a/connectors/__init__.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/connectors/__init__.py	Fri Nov 23 00:33:04 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
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/controls/DiscoveryPanel.py	Fri Nov 23 00:33:04 2018 +0100
@@ -0,0 +1,233 @@
+#!/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 <andrej.skvortzov@gmail.com>
+#
+# 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
+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
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/controls/IDBrowser.py	Fri Nov 23 00:33:04 2018 +0100
@@ -0,0 +1,228 @@
+#!/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 GetAttrByRow(self, row, col, attr):
+        if col == 3:
+            attr.SetColour('blue')
+            attr.SetBold(True)
+            return True
+        return False
+
+
+    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 400,
+                                                  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 = 100),
+            args(_("Last URI"), COL_URI, width = 160 if big else 80),
+            args(_("Description"), COL_DESC, width = 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)
+
+        for a,k in ColumnsDesc:
+            self.dvc.AppendTextColumn(*a,**dict(k, flags = colflags))
+
+        # TODO : when select,
+        #  - update ID field of scheme editor
+        #  - enable use URI button
+
+        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
+            # use last known URI button
+            # TODO : disable use URI button until something selected
+            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)
+
--- a/controls/UriLocationEditor.py	Wed Oct 03 15:47:59 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
--- a/controls/__init__.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/controls/__init__.py	Fri Nov 23 00:33:04 2018 +0100
@@ -44,3 +44,4 @@
 from controls.LogViewer import LogViewer
 from controls.CustomStyledTextCtrl import CustomStyledTextCtrl
 from controls.CustomToolTip import CustomToolTip
+
--- a/dialogs/DiscoveryDialog.py	Wed Oct 03 15:47:59 2018 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,266 +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 <andrej.skvortzov@gmail.com>
-#
-# 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
-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
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dialogs/IDManager.py	Fri Nov 23 00:33:04 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()
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dialogs/IDMergeDialog.py	Fri Nov 23 00:33:04 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()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dialogs/UriEditor.py	Fri Nov 23 00:33:04 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()
+
--- a/dialogs/__init__.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/dialogs/__init__.py	Fri Nov 23 00:33:04 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
+
--- a/editors/ConfTreeNodeEditor.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/editors/ConfTreeNodeEditor.py	Fri Nov 23 00:33:04 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())
Binary file images/IDManager.png has changed
--- a/images/icons.svg	Wed Oct 03 15:47:59 2018 +0300
+++ b/images/icons.svg	Fri Nov 23 00:33:04 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">
   <metadata
@@ -31,8 +31,8 @@
     </rdf:RDF>
   </metadata>
   <sodipodi:namedview
-     inkscape:window-height="1136"
-     inkscape:window-width="1920"
+     inkscape:window-height="874"
+     inkscape:window-width="1600"
      inkscape:pageshadow="2"
      inkscape:pageopacity="0"
      guidetolerance="10.0"
@@ -43,11 +43,11 @@
      pagecolor="#ffffff"
      id="base"
      showgrid="false"
-     inkscape:zoom="2.5873931"
-     inkscape:cx="986.14665"
-     inkscape:cy="698.07857"
+     inkscape:zoom="16"
+     inkscape:cx="754.13513"
+     inkscape:cy="907.03479"
      inkscape:window-x="0"
-     inkscape:window-y="27"
+     inkscape:window-y="24"
      inkscape:current-layer="svg2"
      showguides="true"
      inkscape:guide-bbox="true"
@@ -89942,12 +89942,13 @@
      y="121.52582"
      id="text16266"
      xml:space="preserve"
-     style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:0%;font-family:'Bitstream Vera Sans';-inkscape-font-specification:'Bitstream Vera Sans';text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"><tspan
+     style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:0%;font-family:'Bitstream Vera Sans';-inkscape-font-specification:'Bitstream Vera Sans';text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
+     sodipodi:linespacing="0%"><tspan
        sodipodi:role="line"
        id="tspan16268"
        x="73.295929"
        y="121.52582"
-       style="font-size:12.76095104px;line-height:1.25">%% editIECrawcode editWXGLADE editPYTHONcode EditCfile Transfer Connect Disconnect Debug %%</tspan></text>
+       style="font-size:12.76095104px;line-height:1.25">%% editIECrawcode editWXGLADE editPYTHONcode EditCfile Transfer Connect Disconnect Debug IDManager %%</tspan></text>
   <rect
      width="24"
      height="24"
@@ -93315,4 +93316,37 @@
        id="tspan16195-3-6"
        sodipodi:role="line"
        style="font-size:12.76000023px;line-height:1.25">%% fullscreen %%</tspan></text>
+  <rect
+     width="24"
+     height="24"
+     x="730"
+     y="131.36218"
+     id="IDManager"
+     style="display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:0;fill-rule:evenodd;stroke:none;stroke-width:1;marker:none;enable-background:accumulate"
+     inkscape:label="#rect16270" />
+  <g
+     id="g19473"
+     transform="matrix(1.3198788,0,0,1.3198788,-237.35005,-42.92225)"
+     style="color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#3d993d;fill-opacity:1;fill-rule:nonzero;stroke:#3d993d;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10.43299961;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;filter-blend-mode:normal;filter-gaussianBlur-deviation:0;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate">
+    <rect
+       y="140.41789"
+       x="736.44702"
+       height="8.1489973"
+       width="11.106004"
+       id="rect18652"
+       style="fill:#3d993d;fill-opacity:1;fill-rule:nonzero;stroke:#3d993d;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10.43299961;stroke-dasharray:none;stroke-opacity:1;color:#000000;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;stroke-dashoffset:0;marker:none;filter-blend-mode:normal;filter-gaussianBlur-deviation:0;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
+    <path
+       style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#3d993d;fill-opacity:1;fill-rule:nonzero;stroke:#3d993d;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10.43299961;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;marker:none;filter-blend-mode:normal;filter-gaussianBlur-deviation:0"
+       d="m 741.99023,134.18555 c -1.03169,0.0327 -2.08009,0.38311 -2.90625,1.11328 -0.82615,0.73017 -1.38416,1.85745 -1.41796,3.20898 l -0.002,0.0117 0,2.03516 2,0 0,-1.99805 c 0.0216,-0.86174 0.3173,-1.38252 0.74414,-1.75976 0.42684,-0.37725 1.03088,-0.59378 1.64649,-0.61329 0.61561,-0.0195 1.21879,0.1637 1.62304,0.47461 0.40425,0.31092 0.64912,0.70425 0.65821,1.37305 l 0,2.66211 2,0 0,-2.68359 0,-0.006 c -0.0168,-1.23751 -0.60543,-2.28823 -1.43946,-2.92969 -0.83402,-0.64146 -1.87455,-0.92136 -2.90625,-0.88867 z"
+       id="path18654"
+       inkscape:connector-curvature="0" />
+  </g>
+  <rect
+     inkscape:label="#rect16270"
+     style="display:inline;overflow:visible;visibility:visible;fill:#000000;fill-opacity:0;fill-rule:evenodd;stroke:#6e6e6e;stroke-width:0.1;marker:none;enable-background:accumulate;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
+     id="rect19460"
+     y="131.36218"
+     x="730"
+     height="24"
+     width="24" />
 </svg>
--- a/modbus/modbus.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/modbus/modbus.py	Fri Nov 23 00:33:04 2018 +0100
@@ -788,7 +788,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") + "\"")
--- a/py_ext/plc_python.c	Wed Oct 03 15:47:59 2018 +0300
+++ b/py_ext/plc_python.c	Fri Nov 23 00:33:04 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);
--- a/runtime/NevowServer.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/runtime/NevowServer.py	Fri Nov 23 00:33:04 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
 
--- a/runtime/PLCObject.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/runtime/PLCObject.py	Fri Nov 23 00:33:04 2018 +0100
@@ -23,20 +23,19 @@
 
 
 from __future__ import absolute_import
-import thread
-from threading import Thread, Lock, Semaphore, Event, Condition
+from threading import Thread, Lock, Semaphore, Event
 import ctypes
 import os
 import sys
 import traceback
 from time import time
 import _ctypes  # pylint: disable=wrong-import-order
-import Pyro.core as pyro
-import six
 
 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
@@ -63,133 +62,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
@@ -197,18 +88,23 @@
         self.Traces = []
 
     # First task of worker -> no @RunInMain
-    def AutoLoad(self):
+    def AutoLoad(self, autostart):
         # Get the last transfered PLC
         try:
             self.CurrentPLCFilename = open(
                 self._GetMD5FileName(),
                 "r").read().strip() + lib_ext
             if self.LoadPLC():
-                self.PLCStatus = PlcStatus.Stopped
+                self.PLCStatus = "Stopped"
+                if autostart:
+                    self.StartPLC()
+                    return
         except Exception:
-            self.PLCStatus = PlcStatus.Empty
+            self.PLCStatus = "Empty"
             self.CurrentPLCFilename = None
 
+        self.StatusChange()
+
     def StatusChange(self):
         if self.statuschange is not None:
             for callee in self.statuschange:
@@ -544,6 +440,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
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/runtime/PyroServer.py	Fri Nov 23 00:33:04 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)
--- a/runtime/ServicePublisher.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/runtime/ServicePublisher.py	Fri Nov 23 00:33:04 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)
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/runtime/Stunnel.py	Fri Nov 23 00:33:04 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
+    
--- a/runtime/WampClient.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/runtime/WampClient.py	Fri Nov 23 00:33:04 2018 +0100
@@ -32,13 +32,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"]
 
@@ -87,7 +87,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
@@ -115,7 +115,6 @@
             raise Exception(
                 "don't know how to handle authmethod {}".format(challenge.method))
 
-    @inlineCallbacks
     def onJoin(self, details):
         global _WampSession
         _WampSession = self
@@ -128,13 +127,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), unicode(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)
 
@@ -145,6 +144,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):
 
@@ -342,6 +345,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']
@@ -427,7 +440,8 @@
 
 
 def RegisterWebSettings(NS):
-    NS.ConfigurableSettings.addExtension(
+
+    NS.ConfigurableSettings.addSettings(
         "wamp",
         _("Wamp Settings"),
         webFormInterface,
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/runtime/Worker.py	Fri Nov 23 00:33:04 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()
--- a/runtime/__init__.py	Wed Oct 03 15:47:59 2018 +0300
+++ b/runtime/__init__.py	Fri Nov 23 00:33:04 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)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/runtime/spawn_subprocess.py	Fri Nov 23 00:33:04 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")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/runtime/webinterface.css	Fri Nov 23 00:33:04 2018 +0100
@@ -0,0 +1,6 @@
+
+.freeform-label {
+    float: left;
+    width: 30%;
+}
+
--- a/runtime_files.list	Wed Oct 03 15:47:59 2018 +0300
+++ b/runtime_files.list	Fri Nov 23 00:33:04 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