Edouard@1439: #!/usr/bin/env python Edouard@1439: # -*- coding: utf-8 -*- Edouard@1439: andrej@1667: # This file is part of Beremiz runtime. andrej@1511: # andrej@1511: # Copyright (C) 2007: Edouard TISSERANT and Laurent BESSARD andrej@1511: # andrej@1667: # See COPYING.Runtime file for copyrights details. andrej@1511: # andrej@1667: # This library is free software; you can redistribute it and/or andrej@1667: # modify it under the terms of the GNU Lesser General Public andrej@1667: # License as published by the Free Software Foundation; either andrej@1667: # version 2.1 of the License, or (at your option) any later version. andrej@1667: andrej@1667: # This library is distributed in the hope that it will be useful, andrej@1511: # but WITHOUT ANY WARRANTY; without even the implied warranty of andrej@1667: # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU andrej@1667: # Lesser General Public License for more details. andrej@1667: andrej@1667: # You should have received a copy of the GNU Lesser General Public andrej@1667: # License along with this library; if not, write to the Free Software andrej@1667: # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA andrej@1511: andrej@1826: andrej@1881: from __future__ import absolute_import andrej@1826: from __future__ import print_function Edouard@1899: import time Edouard@1893: import json denis@2203: import os denis@2201: import re Edouard@2581: from six import text_type as text Edouard@1439: from autobahn.twisted import wamp Edouard@1439: from autobahn.twisted.websocket import WampWebSocketClientFactory, connectWS Edouard@1893: from autobahn.wamp import types, auth Edouard@1440: from autobahn.wamp.serializer import MsgPackSerializer Edouard@1441: from twisted.internet.protocol import ReconnectingClientFactory Edouard@2220: from twisted.python.components import registerAdapter andrej@1832: Edouard@2220: from formless import annotate, webform Edouard@2220: import formless Edouard@2220: from nevow import tags, url, static Edouard@2306: from runtime import GetPLCObjectSingleton Edouard@1439: Edouard@2212: mandatoryConfigItems = ["ID", "active", "realm", "url"] Edouard@2212: denis@2201: _transportFactory = None Edouard@1439: _WampSession = None Edouard@2220: WorkingDir = None Edouard@2220: Edouard@2220: # Find pre-existing project WAMP config file denis@2202: _WampConf = None denis@2202: _WampSecret = None Edouard@1439: andrej@1878: ExposedCalls = [ denis@2206: ("StartPLC", {}), denis@2206: ("StopPLC", {}), denis@2206: ("GetPLCstatus", {}), Edouard@2471: ("GetPLCID", {}), Edouard@2487: ("SeedBlob", {}), Edouard@2471: ("AppendChunkToBlob", {}), Edouard@2471: ("PurgeBlobs", {}), denis@2206: ("NewPLC", {}), Edouard@2607: ("RepairPLC", {}), denis@2206: ("MatchMD5", {}), denis@2206: ("SetTraceVariablesList", {}), denis@2206: ("GetTraceVariables", {}), denis@2206: ("RemoteExec", {}), denis@2206: ("GetLogMessage", {}), denis@2206: ("ResetLogCount", {}) andrej@1878: ] Edouard@1440: Edouard@2220: # de-activated dumb wamp config Edouard@2220: defaultWampConfig = { Edouard@2248: "ID": "wamptest", Edouard@2248: "active": False, Edouard@2248: "realm": "Automation", Edouard@3343: "url": "ws://127.0.0.1:8888", Edouard@3343: "clientFactoryOptions": { Edouard@3343: "maxDelay": 300 Edouard@3343: }, Edouard@3343: "protocolOptions": { Edouard@3343: "autoPingInterval": 10, Edouard@3343: "autoPingTimeout": 5 Edouard@3343: } Edouard@2220: } Edouard@2220: Edouard@1898: # Those two lists are meant to be filled by customized runtime Edouard@1901: # or User python code. Edouard@1898: Edouard@1901: """ crossbar Events to register to """ Edouard@1446: SubscribedEvents = [] Edouard@1446: Edouard@1901: """ things to do on join (callables) """ Edouard@1446: DoOnJoin = [] Edouard@1446: Edouard@2218: lastKnownConfig = None andrej@1736: Edouard@2248: Edouard@1445: def GetCallee(name): Edouard@1446: """ Get Callee or Subscriber corresponding to '.' spearated object path """ Edouard@1445: names = name.split('.') Edouard@2306: obj = GetPLCObjectSingleton() andrej@1756: while names: andrej@1756: obj = getattr(obj, names.pop(0)) Edouard@1445: return obj Edouard@1440: andrej@1736: Edouard@1439: class WampSession(wamp.ApplicationSession): Edouard@2248: Edouard@1893: def onConnect(self): Edouard@1901: if "secret" in self.config.extra: denis@2194: user = self.config.extra["ID"] Edouard@1893: self.join(u"Automation", [u"wampcra"], user) Edouard@1893: else: Edouard@1893: self.join(u"Automation") Edouard@1893: Edouard@1893: def onChallenge(self, challenge): Edouard@1893: if challenge.method == u"wampcra": Edouard@2218: if "secret" in self.config.extra: Edouard@2218: secret = self.config.extra["secret"].encode('utf8') Edouard@2248: signature = auth.compute_wcs( Edouard@2248: secret, challenge.extra['challenge'].encode('utf8')) Edouard@2218: return signature.decode("ascii") Edouard@2218: else: Edouard@2218: raise Exception("no secret given for authentication") Edouard@1893: else: Edouard@2248: raise Exception( Edouard@2248: "don't know how to handle authmethod {}".format(challenge.method)) Edouard@1440: Edouard@1439: def onJoin(self, details): Edouard@1439: global _WampSession Edouard@1439: _WampSession = self Edouard@1901: ID = self.config.extra["ID"] denis@2206: denis@2206: for name, kwargs in ExposedCalls: denis@2206: try: denis@2206: registerOptions = types.RegisterOptions(**kwargs) denis@2206: except TypeError as e: denis@2206: registerOptions = None denis@2206: print(_("TypeError register option: {}".format(e))) denis@2206: Edouard@2306: self.register(GetCallee(name), u'.'.join((ID, name)), registerOptions) Edouard@1439: Edouard@1446: for name in SubscribedEvents: andrej@2537: self.subscribe(GetCallee(name), text(name)) Edouard@1446: Edouard@1446: for func in DoOnJoin: Edouard@2306: func(self) Edouard@1446: denis@2194: print(_('WAMP session joined (%s) by:' % time.ctime()), ID) denis@2194: Edouard@1439: def onLeave(self, details): denis@2201: global _WampSession, _transportFactory denis@2201: super(WampSession, self).onLeave(details) Edouard@1439: _WampSession = None denis@2201: _transportFactory = None Edouard@1890: print(_('WAMP session left')) Edouard@1439: Edouard@2310: def publishWithOwnID(self, eventID, value): Edouard@2306: ID = self.config.extra["ID"] andrej@2537: self.publish(text(ID+'.'+eventID), value) andrej@1736: andrej@1736: Edouard@1441: class ReconnectingWampWebSocketClientFactory(WampWebSocketClientFactory, ReconnectingClientFactory): Edouard@2248: denis@2195: def __init__(self, config, *args, **kwargs): denis@2201: global _transportFactory denis@2195: WampWebSocketClientFactory.__init__(self, *args, **kwargs) denis@2195: denis@2207: try: dgaberscek@3342: clientFactoryOptions = config.extra.get("clientFactoryOptions") dgaberscek@3342: if clientFactoryOptions: dgaberscek@3342: self.setClientFactoryOptions(clientFactoryOptions) dgaberscek@3342: except Exception as e: dgaberscek@3342: print(_("Custom client factory options failed : "), e) dgaberscek@3342: _transportFactory = None dgaberscek@3342: dgaberscek@3342: try: denis@2207: protocolOptions = config.extra.get('protocolOptions', None) denis@2207: if protocolOptions: denis@2207: self.setProtocolOptions(**protocolOptions) denis@2207: _transportFactory = self andrej@2418: except Exception as e: denis@2207: print(_("Custom protocol options failed :"), e) denis@2207: _transportFactory = None denis@2195: dgaberscek@3342: def setClientFactoryOptions(self, options): dgaberscek@3342: for key, value in options.items(): Edouard@3343: if key in ["maxDelay", "initialDelay", "maxRetries", "factor", "jitter"]: Edouard@3343: setattr(self, key, value) dgaberscek@3342: denis@2193: def buildProtocol(self, addr): denis@2193: self.resetDelay() denis@2193: return ReconnectingClientFactory.buildProtocol(self, addr) denis@2193: Edouard@1441: def clientConnectionFailed(self, connector, reason): Edouard@2473: print(_("WAMP Client connection failed (%s) .. retrying ..") % Edouard@2473: time.ctime()) Edouard@2473: super(ReconnectingWampWebSocketClientFactory, Edouard@2473: self).clientConnectionFailed(connector, reason) andrej@1751: Edouard@1441: def clientConnectionLost(self, connector, reason): Edouard@2473: print(_("WAMP Client connection lost (%s) .. retrying ..") % Edouard@2473: time.ctime()) Edouard@2473: super(ReconnectingWampWebSocketClientFactory, Edouard@2473: self).clientConnectionFailed(connector, reason) Edouard@1441: andrej@1736: Edouard@2220: def CheckConfiguration(WampClientConf): Edouard@2220: url = WampClientConf["url"] Edouard@2218: if not IsCorrectUri(url): Edouard@2218: raise annotate.ValidateError( Edouard@2248: {"url": "Invalid URL: {}".format(url)}, Edouard@2223: _("WAMP configuration error:")) Edouard@2218: dgaberscek@3342: def UpdateWithDefault(d1, d2): dgaberscek@3342: for k, v in d2.items(): dgaberscek@3342: d1.setdefault(k, v) Edouard@2248: Edouard@2212: def GetConfiguration(): Edouard@2218: global lastKnownConfig Edouard@2218: Edouard@3285: WampClientConf = None Edouard@3285: Edouard@2220: if os.path.exists(_WampConf): Edouard@3285: try: Edouard@3285: WampClientConf = json.load(open(_WampConf)) Edouard@3343: UpdateWithDefault(WampClientConf, defaultWampConfig) Edouard@3285: except ValueError: Edouard@3285: pass Edouard@3285: Edouard@3285: if WampClientConf is None: Edouard@2221: WampClientConf = defaultWampConfig.copy() Edouard@2220: Edouard@2212: for itemName in mandatoryConfigItems: Edouard@2248: if WampClientConf.get(itemName, None) is None: Edouard@2248: raise Exception( Edouard@2248: _("WAMP configuration error : missing '{}' parameter.").format(itemName)) Edouard@2212: Edouard@2220: CheckConfiguration(WampClientConf) Edouard@2220: Edouard@2220: lastKnownConfig = WampClientConf.copy() Edouard@2220: return WampClientConf Edouard@2220: Edouard@2220: Edouard@2220: def SetWampSecret(wampSecret): Edouard@2220: with open(os.path.realpath(_WampSecret), 'w') as f: Edouard@2220: f.write(wampSecret) Edouard@2220: Edouard@2248: Edouard@2220: def SetConfiguration(WampClientConf): Edouard@2218: global lastKnownConfig Edouard@2218: Edouard@2220: CheckConfiguration(WampClientConf) Edouard@2220: Edouard@2220: lastKnownConfig = WampClientConf.copy() Edouard@2248: Edouard@2218: with open(os.path.realpath(_WampConf), 'w') as f: Edouard@2220: json.dump(WampClientConf, f, sort_keys=True, indent=4) Edouard@2474: StopReconnectWampClient() Edouard@2220: if 'active' in WampClientConf and WampClientConf['active']: Edouard@2218: StartReconnectWampClient() Edouard@2218: Edouard@2220: return WampClientConf Edouard@1901: Edouard@2212: Edouard@1893: def LoadWampSecret(secretfname): Edouard@2218: WSClientWampSecret = open(secretfname, 'rb').read() Edouard@2248: if len(WSClientWampSecret) == 0: Edouard@2218: raise Exception(_("WAMP secret empty")) Edouard@2218: return WSClientWampSecret Edouard@1446: andrej@1736: denis@2201: def IsCorrectUri(uri): Edouard@2218: return re.match(r'wss?://[^\s?:#-]+(:[0-9]+)?(/[^\s]*)?$', uri) is not None denis@2201: denis@2201: Edouard@2212: def RegisterWampClient(wampconf=None, wampsecret=None): Edouard@2212: global _WampConf, _WampSecret Edouard@2220: _WampConfDefault = os.path.join(WorkingDir, "wampconf.json") Edouard@2220: _WampSecretDefault = os.path.join(WorkingDir, "wamp.secret") Edouard@2220: Edouard@2221: # set config file path only if not already set Edouard@2221: if _WampConf is None: Edouard@2221: # default project's wampconf has precedance over commandline given Edouard@2221: if os.path.exists(_WampConfDefault) or wampconf is None: Edouard@2221: _WampConf = _WampConfDefault Edouard@2221: else: Edouard@2221: _WampConf = wampconf Edouard@2221: Edouard@2221: WampClientConf = GetConfiguration() Edouard@2221: Edouard@2221: # set secret file path only if not already set Edouard@2221: if _WampSecret is None: Edouard@2248: # default project's wamp secret also Edouard@2248: # has precedance over commandline given Edouard@2221: if os.path.exists(_WampSecretDefault): Edouard@2221: _WampSecret = _WampSecretDefault Edouard@2221: else: Edouard@2221: _WampSecret = wampsecret Edouard@2221: Edouard@2221: if _WampSecret is not None: Edouard@2221: WampClientConf["secret"] = LoadWampSecret(_WampSecret) Edouard@2248: else: Edouard@2221: print(_("WAMP authentication has no secret configured")) Edouard@2220: _WampSecret = _WampSecretDefault Edouard@2220: Edouard@2220: if not WampClientConf["active"]: Edouard@2212: print(_("WAMP deactivated in configuration")) Edouard@2212: return Edouard@2212: Edouard@1440: # create a WAMP application session factory Edouard@1439: component_config = types.ComponentConfig( Edouard@2220: realm=WampClientConf["realm"], Edouard@2220: extra=WampClientConf) Edouard@1439: session_factory = wamp.ApplicationSessionFactory( andrej@1744: config=component_config) Edouard@1439: session_factory.session = WampSession Edouard@1439: Edouard@1440: # create a WAMP-over-WebSocket transport client factory denis@2207: ReconnectingWampWebSocketClientFactory( denis@2195: component_config, Edouard@1439: session_factory, Edouard@2220: url=WampClientConf["url"], Edouard@1893: serializers=[MsgPackSerializer()]) Edouard@1439: Edouard@1440: # start the client from a Twisted endpoint denis@2207: if _transportFactory: Edouard@2248: connectWS(_transportFactory) Edouard@2220: print(_("WAMP client connecting to :"), WampClientConf["url"]) denis@2207: return True denis@2207: else: Edouard@2220: print(_("WAMP client can not connect to :"), WampClientConf["url"]) denis@2207: return False denis@2204: denis@2201: denis@2203: def StopReconnectWampClient(): Edouard@2248: if _transportFactory is not None: Edouard@2218: _transportFactory.stopTrying() Edouard@2248: if _WampSession is not None: Edouard@2218: _WampSession.leave() denis@2203: denis@2204: denis@2203: def StartReconnectWampClient(): denis@2203: if _WampSession: dgaberscek@3342: # do reconnect and reset continueTrying and initialDelay parameter dgaberscek@3342: if _transportFactory is not None: dgaberscek@3342: _transportFactory.resetDelay() denis@2202: _WampSession.disconnect() denis@2202: return True denis@2204: else: denis@2203: # do connect denis@2202: RegisterWampClient() denis@2202: return True denis@2203: andrej@1736: Edouard@1439: def GetSession(): Edouard@1439: return _WampSession Edouard@1439: Edouard@2248: Edouard@2218: def getWampStatus(): Edouard@2248: if _transportFactory is not None: Edouard@2248: if _WampSession is not None: Edouard@2248: if _WampSession.is_attached(): Edouard@2218: return "Attached" Edouard@2218: return "Established" Edouard@2218: return "Connecting" Edouard@2218: return "Disconnected" denis@2202: denis@2204: Edouard@2306: def PublishEvent(eventID, value): Edouard@2306: if getWampStatus() == "Attached": andrej@2537: _WampSession.publish(text(eventID), value) edouard@2309: Edouard@2306: Edouard@2306: def PublishEventWithOwnID(eventID, value): Edouard@2306: if getWampStatus() == "Attached": andrej@2537: _WampSession.publishWithOwnID(text(eventID), value) edouard@2309: Edouard@2218: Edouard@2248: # WEB CONFIGURATION INTERFACE Edouard@2220: WAMP_SECRET_URL = "secret" Edouard@3343: webExposedConfigItems = [ Edouard@3343: 'active', 'url', 'ID', Edouard@3343: "clientFactoryOptions.maxDelay", Edouard@3343: "protocolOptions.autoPingInterval", Edouard@3343: "protocolOptions.autoPingTimeout" Edouard@3343: ] Edouard@2218: Edouard@2248: Edouard@2248: def wampConfigDefault(ctx, argument): Edouard@2248: if lastKnownConfig is not None: dgaberscek@3342: # Check if name is composed with an intermediate dot symbol and go deep in lastKnownConfig if it is dgaberscek@3342: argument_name_path = argument.name.split(".") dgaberscek@3342: searchValue = lastKnownConfig dgaberscek@3342: while argument_name_path: dgaberscek@3342: if searchValue: dgaberscek@3342: searchValue = searchValue.get(argument_name_path.pop(0), None) dgaberscek@3342: else: dgaberscek@3342: break dgaberscek@3342: return searchValue Edouard@2218: Edouard@2248: Edouard@2218: def wampConfig(**kwargs): Edouard@2220: secretfile_field = kwargs["secretfile"] Edouard@2220: if secretfile_field is not None: Edouard@2221: secretfile = getattr(secretfile_field, "file", None) Edouard@2221: if secretfile is not None: Edouard@2221: secret = secretfile_field.file.read() Edouard@2221: SetWampSecret(secret) Edouard@2220: Edouard@2218: newConfig = lastKnownConfig.copy() Edouard@2218: for argname in webExposedConfigItems: dgaberscek@3342: # Check if name is composed with an intermediate dot symbol and go deep in lastKnownConfig if it is dgaberscek@3342: # and then set a new value. dgaberscek@3342: argname_path = argname.split(".") dgaberscek@3342: arg_last = argname_path.pop() Edouard@2221: arg = kwargs.get(argname, None) Edouard@2248: if arg is not None: dgaberscek@3342: tmpConf = newConfig dgaberscek@3342: while argname_path: dgaberscek@3342: tmpConf = tmpConf.setdefault(argname_path.pop(0), {}) dgaberscek@3342: tmpConf[arg_last] = arg Edouard@2218: Edouard@2218: SetConfiguration(newConfig) Edouard@2218: Edouard@2248: Edouard@2220: class FileUploadDownload(annotate.FileUpload): Edouard@2220: pass Edouard@2220: Edouard@2220: Edouard@2220: class FileUploadDownloadRenderer(webform.FileUploadRenderer): Edouard@2248: Edouard@2220: def input(self, context, slot, data, name, value): Edouard@2248: # pylint: disable=expression-not-assigned Edouard@2220: slot[_("Upload:")] Edouard@2248: slot = webform.FileUploadRenderer.input( Edouard@2248: self, context, slot, data, name, value) Edouard@2220: download_url = data.typedValue.getAttribute('download_url') Edouard@2220: return slot[tags.a(href=download_url)[_("Download")]] Edouard@2220: Edouard@2260: Edouard@2248: registerAdapter(FileUploadDownloadRenderer, FileUploadDownload, Edouard@2248: formless.iformless.ITypedRenderer) Edouard@2248: Edouard@2248: Edouard@2220: def getDownloadUrl(ctx, argument): Edouard@2248: if lastKnownConfig is not None: Edouard@2220: return url.URL.fromContext(ctx).\ Edouard@2220: child(WAMP_SECRET_URL).\ Edouard@2248: child(lastKnownConfig["ID"] + ".secret") Edouard@2220: Edouard@2260: Edouard@2218: webFormInterface = [ Edouard@2218: ("status", Edouard@2248: annotate.String(label=_("Current status"), Edouard@2248: immutable=True, Edouard@2248: default=lambda *k:getWampStatus())), Edouard@2218: ("ID", Edouard@2248: annotate.String(label=_("ID"), Edouard@2248: default=wampConfigDefault)), Edouard@2220: ("secretfile", Edouard@2248: FileUploadDownload(label=_("File containing secret for that ID"), Edouard@2248: download_url=getDownloadUrl)), Edouard@2218: ("active", Edouard@2248: annotate.Boolean(label=_("Enable WAMP connection"), Edouard@2248: default=wampConfigDefault)), Edouard@2218: ("url", Edouard@2248: annotate.String(label=_("WAMP Server URL"), Edouard@3343: default=wampConfigDefault)), Edouard@3343: ("clientFactoryOptions.maxDelay", Edouard@3343: annotate.Integer(label=_("Max reconnection delay (s)"), Edouard@3343: default=wampConfigDefault)), Edouard@3343: ("protocolOptions.autoPingInterval", Edouard@3343: annotate.Integer(label=_("Auto ping interval (s)"), Edouard@3343: default=wampConfigDefault)), Edouard@3343: ("protocolOptions.autoPingTimeout", Edouard@3343: annotate.Integer(label=_("Auto ping timeout (s)"), Edouard@3343: default=wampConfigDefault)) Edouard@3343: ] Edouard@2218: Edouard@2220: def deliverWampSecret(ctx, segments): Edouard@2248: # filename = segments[1].decode('utf-8') Edouard@2248: Edouard@2221: # FIXME: compare filename to ID+".secret" Edouard@2221: # for now all url under /secret returns the secret Edouard@2221: Edouard@2248: # TODO: make beautifull message in case of exception Edouard@2221: # while loading secret (if empty or dont exist) Edouard@2220: secret = LoadWampSecret(_WampSecret) Edouard@2248: return static.Data(secret, 'application/octet-stream'), () Edouard@2248: Edouard@2220: Edouard@2221: def RegisterWebSettings(NS): Edouard@2262: Edouard@2262: NS.ConfigurableSettings.addSettings( Edouard@2248: "wamp", Edouard@2221: _("Wamp Settings"), Edouard@2221: webFormInterface, Edouard@2221: _("Set"), Edouard@2221: wampConfig) Edouard@2221: Edouard@2221: NS.customSettingsURLs[WAMP_SECRET_URL] = deliverWampSecret