--- a/Beremiz_service.py Fri Jun 15 09:48:05 2018 +0200
+++ b/Beremiz_service.py Fri Jul 20 11:05:17 2018 +0200
@@ -45,17 +45,17 @@
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)
- -i - IP address of interface to bind to (default:localhost)
- -p - port number default:3000
- -h - print this help text and quit
- -a - autostart PLC (0:disable 1:enable) (default:0)
- -x - enable/disable wxTaskbarIcon (0:disable 1:enable) (default:1)
- -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 default config file (default:wampconf.json)
- -s - WAMP client secret, given as a file
- -e - python extension (absolute path .py)
+ -n zeroconf service name (default:disabled) + -i IP address of interface to bind to (default:localhost) + -p port number default:3000 + -h print this help text and quit + -a autostart PLC (0:disable 1:enable) (default:0) + -x enable/disable wxTaskbarIcon (0:disable 1:enable) (default:1) + -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) + -e python extension (absolute path .py) working_dir - directory where are stored PLC files
@@ -582,29 +582,23 @@
installThreadExcepthook()
import runtime.NevowServer as NS # pylint: disable=ungrouped-imports
- print(_("Nevow/Athena import failed :"), e)
+ LogMessageAndException(_("Nevow/Athena import failed :")) NS.WorkingDir = WorkingDir
- # Find pre-existing project WAMP config file
- _wampconf = os.path.join(WorkingDir, "wampconf.json")
- # If project's WAMP config file exits, override default (-c)
- if os.path.exists(_wampconf):
- if wampconf is not None:
- import runtime.WampClient as WC # pylint: disable=ungrouped-imports
- print(_("WAMP import failed :"), e)
+ import runtime.WampClient as WC # pylint: disable=ungrouped-imports + WC.WorkingDir = WorkingDir + LogMessageAndException(_("WAMP import failed :")) for extention_file, extension_folder in extensions:
@@ -616,22 +610,16 @@
website = NS.RegisterWebsite(webport)
pyruntimevars["website"] = website
+ NS.SetServer(pyroserver) statuschange.append(NS.website_statuslistener_factory(website))
LogMessageAndException(_("Nevow Web service failed. "))
- if wampconf is not None:
- _wampconf = WC.LoadWampClientConf(wampconf)
- if _wampconf["url"]: # TODO : test more ?
- WC.RegisterWampClient(wampconf, wampsecret)
- pyruntimevars["wampsession"] = WC.GetSession
- WC.SetServer(pyroserver)
- raise Exception(_("WAMP config is incomplete."))
- raise Exception(_("WAMP config is missing."))
+ WC.SetServer(pyroserver) + WC.RegisterWampClient(wampconf, wampsecret) + WC.RegisterWebSettings(NS) LogMessageAndException(_("WAMP client startup failed. "))
--- a/ProjectController.py Fri Jun 15 09:48:05 2018 +0200
+++ b/ProjectController.py Fri Jul 20 11:05:17 2018 +0200
@@ -1352,6 +1352,31 @@
if self.AppFrame is not None:
self.AppFrame.LogViewer.SetLogCounters(log_count)
+ "Started": {"_Stop": True, + "Stopped": {"_Run": True, + "Empty": {"_Transfer": True, + "Broken": {"_Connect": False, def UpdateMethodsFromPLCStatus(self):
@@ -1364,21 +1389,11 @@
self._SetConnector(None, False)
if self.previous_plcstate != status:
- "Started": [("_Run", False),
- "Stopped": [("_Run", True),
- "Empty": [("_Run", False),
- "Disconnected": [("_Run", False),
- ("_Disconnect", False)],
+ allmethods = self.DefaultMethods.copy() + self.MethodsFromStatus.get(status, {})) + for method, active in allmethods.items(): + self.ShowMethod(method,active) self.previous_plcstate = status
if self.AppFrame is not None:
@@ -1713,10 +1728,6 @@
self.logger.write_error(_("Connection failed to %s!\n") % uri)
- self.ShowMethod("_Connect", False)
- self.ShowMethod("_Disconnect", True)
- self.ShowMethod("_Transfer", True)
self.CompareLocalAndRemotePLC()
# Init with actual PLC status and print it
--- a/runtime/NevowServer.py Fri Jun 15 09:48:05 2018 +0200
+++ b/runtime/NevowServer.py Fri Jul 20 11:05:17 2018 +0200
@@ -26,10 +26,19 @@
from __future__ import absolute_import
from __future__ import print_function
-from nevow import appserver, inevow, tags, loaders, athena
+from zope.interface import implements +from nevow import appserver, inevow, tags, loaders, athena, url, rend from nevow.page import renderer
+from formless import annotate +from formless import webform +from formless import configurable from twisted.internet import reactor
import util.paths as paths
+from runtime.loglevels import LogLevels, LogLevelsDict +PAGE_TITLE = 'Beremiz Runtime Web Interface' xhtml_header = '''<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
@@ -37,6 +46,7 @@
class PLCHMI(athena.LiveElement):
@@ -49,7 +59,6 @@
def HMIinitialisation(self):
self.HMIinitialised(None)
class DefaultPLCStartedHMI(PLCHMI):
docFactory = loaders.stan(
tags.div(render=tags.directive('liveElement'))[
@@ -119,19 +128,113 @@
for child in self.liveFragmentChildren[:]:
+class ConfigurableBindings(configurable.Configurable): + configurable.Configurable.__init__(self, None) + self.bindingsNames = [] + def getBindingNames(self, ctx): + return self.bindingsNames + def addExtension(self, name, desc, fields, btnlabel, callback): + return annotate.MethodBinding( + annotate.Method(arguments=[ + annotate.Argument(*field) + setattr(self, 'bind_'+name, _bind) + setattr(self, 'action_'+name, callback) + self.bindingsNames.append(name) +ConfigurableSettings = ConfigurableBindings() +class ISettings(annotate.TypedInterface): + platform = annotate.String(label = _("Platform"), + default = platform.system() + " " + platform.release(), + ctx = annotate.Context(), + level = annotate.Choice(LogLevels, + label=_("Log message level")), + message = annotate.String(label=_("Message text"))): + sendLogMessage = annotate.autocallable(sendLogMessage, + label=_("Send a message to the log"), +class SettingsPage(rend.Page): + # This makes webform_css url answer some default CSS + child_webform_css = webform.defaultCSS + docFactory = loaders.stan([tags.html[ + tags.title[_("Beremiz Runtime Settings")], + tags.link(rel='stylesheet', + href=url.here.child("webform_css")) + tags.h1["Runtime settings:"], + webform.renderForms('staticSettings'), + tags.h2["Extensions settings:"], + webform.renderForms('dynamicSettings'), + def configurable_staticSettings(self, ctx): + return configurable.TypedInterfaceConfigurable(self) + def configurable_dynamicSettings(self, ctx): + return ConfigurableSettings + def sendLogMessage(self, level, message, **kwargs): + level = LogLevelsDict[level] + if _PySrv.plcobj is not None: + _PySrv.plcobj.LogMessage(level, "Web form log message: " + message ) + def locateChild(self, ctx, segments): + if segments[0] in customSettingsURLs : + return customSettingsURLs[segments[0]](ctx, segments) + return super(SettingsPage, self).locateChild(ctx, segments) class WebInterface(athena.LivePage):
docFactory = loaders.stan([tags.raw(xhtml_header),
tags.html(xmlns="http://www.w3.org/1999/xhtml")[
- tags.head(render=tags.directive('liveglue')),
+ tags.head(render=tags.directive('liveglue'))[ + tags.title[PAGE_TITLE], + tags.link(rel='stylesheet', + href=url.here.child("webform_css")) - tags.div(render=tags.directive("MainPage"))
+ tags.div(render=tags.directive("MainPage")), + def child_settings(self, context): def __init__(self, plcState=False, *a, **kw):
super(WebInterface, self).__init__(*a, **kw)
self.jsModules.mapping[u'WebInterface'] = paths.AbsNeighbourFile(__file__, 'webinterface.js')
@@ -184,6 +287,7 @@
# print "We will be called back when the client disconnects"
def RegisterWebsite(port):
site = appserver.NevowSite(website)
@@ -209,3 +313,9 @@
def website_statuslistener_factory(site):
return statuslistener(site).listen
--- a/runtime/WampClient.py Fri Jun 15 09:48:05 2018 +0200
+++ b/runtime/WampClient.py Fri Jul 20 11:05:17 2018 +0200
@@ -26,31 +26,53 @@
from __future__ import print_function
from autobahn.twisted import wamp
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 +from nevow import tags, url, static +mandatoryConfigItems = ["ID", "active", "realm", "url"] +_transportFactory = None +# Find pre-existing project WAMP config file
- "SetTraceVariablesList",
+ ("SetTraceVariablesList", {}), + ("GetTraceVariables", {}), +# de-activated dumb wamp config + "url": "ws://127.0.0.1:8888" # Those two lists are meant to be filled by customized runtime
@@ -60,6 +82,7 @@
""" things to do on join (callables) """
""" Get Callee or Subscriber corresponding to '.' spearated object path """
@@ -73,16 +96,19 @@
class WampSession(wamp.ApplicationSession):
if "secret" in self.config.extra:
- user = self.config.extra["ID"].encode('utf8')
+ user = self.config.extra["ID"] self.join(u"Automation", [u"wampcra"], user)
def onChallenge(self, challenge):
if challenge.method == u"wampcra":
- secret = self.config.extra["secret"].encode('utf8')
- signature = auth.compute_wcs(secret, challenge.extra['challenge'].encode('utf8'))
- return signature.decode("ascii")
+ if "secret" in self.config.extra: + secret = self.config.extra["secret"].encode('utf8') + signature = auth.compute_wcs(secret, challenge.extra['challenge'].encode('utf8')) + return signature.decode("ascii") + raise Exception("no secret given for authentication") raise Exception("don't know how to handle authmethod {}".format(challenge.method))
@@ -91,10 +117,15 @@
ID = self.config.extra["ID"]
- print('WAMP session joined by :', ID)
- for name in ExposedCalls:
- regoption = types.RegisterOptions(u'exact', u'last')
- yield self.register(GetCallee(name), u'.'.join((ID, name)), regoption)
+ for name, kwargs in ExposedCalls: + registerOptions = types.RegisterOptions(**kwargs) + print(_("TypeError register option: {}".format(e))) + yield self.register(GetCallee(name), u'.'.join((ID, name)), registerOptions) for name in SubscribedEvents:
yield self.subscribe(GetCallee(name), unicode(name))
@@ -102,81 +133,283 @@
+ print(_('WAMP session joined (%s) by:' % time.ctime()), ID) def onLeave(self, details):
+ global _WampSession, _transportFactory + super(WampSession, self).onLeave(details) + _transportFactory = None print(_('WAMP session left'))
class ReconnectingWampWebSocketClientFactory(WampWebSocketClientFactory, ReconnectingClientFactory):
+ def __init__(self, config, *args, **kwargs): + global _transportFactory + WampWebSocketClientFactory.__init__(self, *args, **kwargs) + protocolOptions = config.extra.get('protocolOptions', None) + self.setProtocolOptions(**protocolOptions) + _transportFactory = self + print(_("Custom protocol options failed :"), e) + _transportFactory = None + def buildProtocol(self, addr): + return ReconnectingClientFactory.buildProtocol(self, addr) def clientConnectionFailed(self, connector, reason):
- print(_("WAMP Client connection failed (%s) .. retrying .." % time.ctime()))
- ReconnectingClientFactory.clientConnectionFailed(self, connector, reason)
+ if self.continueTrying: + print(_("WAMP Client connection failed (%s) .. retrying ..") % time.ctime()) + super(ReconnectingWampWebSocketClientFactory, self).clientConnectionFailed(connector, reason) def clientConnectionLost(self, connector, reason):
- print(_("WAMP Client connection lost (%s) .. retrying .." % time.ctime()))
- ReconnectingClientFactory.clientConnectionFailed(self, connector, reason)
+ if self.continueTrying: + print(_("WAMP Client connection lost (%s) .. retrying ..") % time.ctime()) + super(ReconnectingWampWebSocketClientFactory, self).clientConnectionFailed(connector, reason) -def LoadWampClientConf(wampconf):
- WSClientConf = json.load(open(wampconf))
- print(_("WAMP load error: "), ve)
+def CheckConfiguration(WampClientConf): + url = WampClientConf["url"] + if not IsCorrectUri(url): + raise annotate.ValidateError( + {"url":"Invalid URL: {}".format(url)}, + _("WAMP configuration error:")) + if os.path.exists(_WampConf): + WampClientConf = json.load(open(_WampConf)) + WampClientConf = defaultWampConfig.copy() + for itemName in mandatoryConfigItems: + if WampClientConf.get(itemName, None) is None : + raise Exception(_("WAMP configuration error : missing '{}' parameter.").format(itemName)) + CheckConfiguration(WampClientConf) + lastKnownConfig = WampClientConf.copy() +def SetWampSecret(wampSecret): + with open(os.path.realpath(_WampSecret), 'w') as f: +def SetConfiguration(WampClientConf): + CheckConfiguration(WampClientConf) + lastKnownConfig = WampClientConf.copy() + with open(os.path.realpath(_WampConf), 'w') as f: + json.dump(WampClientConf, f, sort_keys=True, indent=4) + if 'active' in WampClientConf and WampClientConf['active']: + if _transportFactory and _WampSession: + StopReconnectWampClient() + StartReconnectWampClient() + StopReconnectWampClient() def LoadWampSecret(secretfname):
- WSClientWampSecret = open(secretfname, 'rb').read()
- return WSClientWampSecret
- print(_("Wamp secret load error:"), ve)
+ WSClientWampSecret = open(secretfname, 'rb').read() + if len(WSClientWampSecret) == 0 : + raise Exception(_("WAMP secret empty")) + return WSClientWampSecret + return re.match(r'wss?://[^\s?:#-]+(:[0-9]+)?(/[^\s]*)?$', uri) is not None -def RegisterWampClient(wampconf, secretfname):
+def RegisterWampClient(wampconf=None, wampsecret=None): + global _WampConf, _WampSecret + _WampConfDefault = os.path.join(WorkingDir, "wampconf.json") + _WampSecretDefault = os.path.join(WorkingDir, "wamp.secret") - WSClientConf = LoadWampClientConf(wampconf)
+ # set config file path only if not already set + # default project's wampconf has precedance over commandline given + if os.path.exists(_WampConfDefault) or wampconf is None: + _WampConf = _WampConfDefault + WampClientConf = GetConfiguration()
- print(_("WAMP client connection not established!"))
+ # set secret file path only if not already set + if _WampSecret is None: + # default project's wamp secret also has precedance over commandline given + if os.path.exists(_WampSecretDefault): + _WampSecret = _WampSecretDefault + _WampSecret = wampsecret - WampSecret = LoadWampSecret(secretfname)
+ if _WampSecret is not None: + WampClientConf["secret"] = LoadWampSecret(_WampSecret) + print(_("WAMP authentication has no secret configured")) + _WampSecret = _WampSecretDefault - if WampSecret is not None:
- WSClientConf["secret"] = WampSecret
+ if not WampClientConf["active"]: + print(_("WAMP deactivated in configuration")) # create a WAMP application session factory
component_config = types.ComponentConfig(
- realm=WSClientConf["realm"],
+ realm=WampClientConf["realm"], session_factory = wamp.ApplicationSessionFactory(
session_factory.session = WampSession
# create a WAMP-over-WebSocket transport client factory
- transport_factory = ReconnectingWampWebSocketClientFactory(
+ ReconnectingWampWebSocketClientFactory( - url=WSClientConf["url"],
+ url=WampClientConf["url"], serializers=[MsgPackSerializer()])
# start the client from a Twisted endpoint
- conn = connectWS(transport_factory)
- print(_("WAMP client connecting to :"), WSClientConf["url"])
+ conn = connectWS(_transportFactory) + print(_("WAMP client connecting to :"), WampClientConf["url"]) + print(_("WAMP client can not connect to :"), WampClientConf["url"]) +def StopReconnectWampClient(): + if _transportFactory is not None : + _transportFactory.stopTrying() + if _WampSession is not None : +def StartReconnectWampClient(): + _WampSession.disconnect() + if _transportFactory is not None : + if _WampSession is not None : + if _WampSession.is_attached() :
+#### WEB CONFIGURATION INTERFACE #### +WAMP_SECRET_URL = "secret" +webExposedConfigItems = ['active', 'url', 'ID'] +def wampConfigDefault(ctx,argument): + if lastKnownConfig is not None : + return lastKnownConfig.get(argument.name, None) +def wampConfig(**kwargs): + secretfile_field = kwargs["secretfile"] + if secretfile_field is not None: + secretfile = getattr(secretfile_field, "file", None) + if secretfile is not None: + secret = secretfile_field.file.read() + newConfig = lastKnownConfig.copy() + for argname in webExposedConfigItems: + arg = kwargs.get(argname, None) + newConfig[argname] = arg + SetConfiguration(newConfig) +class FileUploadDownload(annotate.FileUpload): +class FileUploadDownloadRenderer(webform.FileUploadRenderer): + def input(self, context, slot, data, name, value): + slot = webform.FileUploadRenderer.input(self, context, slot, data, name, value) + download_url = data.typedValue.getAttribute('download_url') + return slot[tags.a(href=download_url)[_("Download")]] +registerAdapter(FileUploadDownloadRenderer, FileUploadDownload, formless.iformless.ITypedRenderer) +def getDownloadUrl(ctx, argument): + if lastKnownConfig is not None : + return url.URL.fromContext(ctx).\ + child(WAMP_SECRET_URL).\ + child(lastKnownConfig["ID"]+".secret") + annotate.String(label=_("Current status"), + default = lambda *k:getWampStatus())), + annotate.String(label=_("ID"), + default = wampConfigDefault)), + label = _("File containing secret for that ID"), + download_url = getDownloadUrl, + annotate.Boolean(label=_("Enable WAMP connection"), + default=wampConfigDefault)), + annotate.String(label=_("WAMP Server URL"), + default=wampConfigDefault))] +def deliverWampSecret(ctx, segments): + filename = segments[1].decode('utf-8') + # FIXME: compare filename to ID+".secret" + # for now all url under /secret returns the secret + # TODO: make beutifull message in case of exception + # while loading secret (if empty or dont exist) + secret = LoadWampSecret(_WampSecret) + return static.Data(secret, 'application/octet-stream'),() +def RegisterWebSettings(NS): + NS.ConfigurableSettings.addExtension( + NS.customSettingsURLs[WAMP_SECRET_URL] = deliverWampSecret --- a/tests/wamp/.crossbar/config.json Fri Jun 15 09:48:05 2018 +0200
+++ b/tests/wamp/.crossbar/config.json Fri Jul 20 11:05:17 2018 +0200
@@ -39,13 +39,15 @@
"url": "ws://127.0.0.1:8888/",
--- a/tests/wamp/README Fri Jun 15 09:48:05 2018 +0200
+++ b/tests/wamp/README Fri Jul 20 11:05:17 2018 +0200
@@ -1,25 +1,26 @@
-Crossbar test router configuration is available in .crossbar directory.
-This project contains wamp client config to be loaded at runtime startup.
-project_files/wampconf.json
+/* This project contains wamp client config to be loaded at runtime startup. */ +./project_files/wampconf.json wampconf.json is in "Project Files", so it is copied to runtime's working directory, and then loaded after program transfer + runtime restart.
Otherwise, wamp config file path can be forced :
./Beremiz_service.py -c /path/to/my/wampconf.json /working/dir
-Otherwise, path for CRA secret can be forced :
-./Beremiz_service.py -s /path/to/my/secret /working/dir
+#sudo apt-get -y dist-upgrade +sudo apt-get -y install build-essential libssl-dev libffi-dev libreadline-dev libbz2-dev libsqlite3-dev libncurses5-dev +sudo python -m pip install -U pip +sudo pip install crossbar +/* Start Crossbar command: */ +/* Crossbar test router configuration is available in .crossbar directory. */ - Crossbar.io : 17.12.1 (Crossbar.io COMMUNITY)
- Autobahn : 17.10.1 (with JSON, MessagePack, CBOR, UBJSON)
+ Crossbar.io : 18.3.1 (Crossbar.io COMMUNITY) + Autobahn : 18.3.1 (with JSON, MessagePack, CBOR, UBJSON) Twisted : 17.9.0-EPollReactor
- Python : 2.7.12/CPython
+ Python : 2.7.12/CPython \ No newline at end of file
--- a/tests/wamp/beremiz.xml Fri Jun 15 09:48:05 2018 +0200
+++ b/tests/wamp/beremiz.xml Fri Jul 20 11:05:17 2018 +0200
@@ -1,4 +1,4 @@
<?xml version='1.0' encoding='utf-8'?>
-<BeremizRoot xmlns:xsd="http://www.w3.org/2001/XMLSchema" URI_location="WAMP://127.0.0.1:8888#Automation#wamptest">
+<BeremizRoot xmlns:xsd="http://www.w3.org/2001/XMLSchema" URI_location="WAMP://127.0.0.1:8888#Automation#WampID">