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