├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── dbusdummyservice.py ├── dbusmonitor.py ├── examples ├── vedbusitem_import_examples.py └── vedbusservice_example.py ├── logger.py ├── mosquitto_bridge_registrator.py ├── settingsdevice.py ├── test ├── fixture_vedbus.py ├── mock_dbus_monitor.py ├── mock_dbus_service.py ├── mock_gobject.py ├── mock_settings_device.py ├── test_settingsdevice.py └── test_vedbus.py ├── tools └── dbus_signal_cntr.py ├── tracing.py ├── ve_utils.py └── vedbus.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: focal 3 | 4 | python: 5 | - "3.7" 6 | 7 | before_install: 8 | - sudo apt-get update 9 | - sudo apt-get install dbus-x11 libcairo2-dev libdbus-1-dev libgirepository1.0-dev pkg-config 10 | - python -m pip install --upgrade pip 11 | 12 | install: 13 | - pip3 install dbus-python PyGObject 14 | 15 | script: 16 | - eval `dbus-launch --sh-syntax` && cd test && python3 test_vedbus.py -v 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Victron Energy BV 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | velib_python 2 | ============ 3 | 4 | [![Build Status](https://travis-ci.com/victronenergy/velib_python.svg?branch=master)](https://travis-ci.org/victronenergy/velib_python) 5 | 6 | This is the general python library within Victron. It contains code that is related to D-Bus and the Color 7 | Control GX. See http://www.victronenergy.com/panel-systems-remote-monitoring/colorcontrol/ for more 8 | infomation about that panel. 9 | 10 | Files busitem.py, dbusitem.py and tracing.py are deprecated. 11 | 12 | The main files are vedbus.py, dbusmonitor.py and settingsdevice.py. 13 | 14 | - Use VeDbusService to put your process on dbus and let other services interact with you. 15 | - Use VeDbusItemImport to read a single value from other processes the dbus, and monitor its signals. 16 | - Use DbusMonitor to monitor multiple values from other processes 17 | - Use SettingsDevice to store your settings in flash, via the com.victronenergy.settings dbus service. See 18 | https://github.com/victronenergy/localsettings for more info. 19 | 20 | Code style 21 | ========== 22 | 23 | Comply with PEP8, except: 24 | - use tabs instead of spaces, since we use tabs for all projects within Victron. 25 | - max line length = 110 26 | 27 | Run this command to set git diff to tabsize is 4 spaces. Replace --local with --global to do it globally for the current 28 | user account. 29 | 30 | git config --local core.pager 'less -x4' 31 | 32 | Run this command to check your code agains PEP8 33 | 34 | pep8 --max-line-length=110 --ignore=W191 *.py 35 | 36 | D-Bus 37 | ===== 38 | 39 | D-Bus is an/the inter process communication bus used on Linux for many things. Victron uses it on the CCGX to have all the different processes exchange data. Protocol drivers publish data read from products (for example battery voltage) on the D-Bus, and other processes (the GUI for example) takes it from the D-Bus to show it on the display. 40 | 41 | Libraries that implement D-Bus connectivity are available in many programming languages (C, Python, etc). There are also many commandline tools available to talk to a running process via D-bus. See for example the dbuscli (executeable name dbus): http://code.google.com/p/dbus-tools/wiki/DBusCli, and also dbus-monitor and dbus-send. 42 | 43 | There are two sides in using the D-Bus, putting information on it (exporting as service with objects) and reading/writing to a process exporting a service. Note that instead of reading with GetValue, you can also subscribe to receive a signal when datachanges. Doing this saves unncessary context-switches in most cases. 44 | 45 | To get an idea of how to publish data on the dbus, run the example: 46 | 47 | matthijs@matthijs-VirtualBox:~/dev/velib_python/examples$ python vedbusservice_example.py 48 | vedbusservice_example.py starting up 49 | /Position value is 5 50 | /Position value is now 10 51 | try changing our RPM by executing the following command from a terminal 52 | 53 | dbus-send --print-reply --dest=com.victronenergy.example /RPM com.victronenergy.BusItem.SetValue int32:1200 54 | Reply will be <> 0 for values > 1000: not accepted. And reply will be 0 for values < 1000: accepted. 55 | 56 | Leave that terminal open, start a second terminal, and interrogate above service from the commandline: 57 | 58 | matthijs@matthijs-VirtualBox:~/dev/velib_python/examples$ dbus 59 | org.freedesktop.DBus 60 | org.freedesktop.PowerManagement 61 | com.victronenergy.example 62 | org.xfce.Terminal5 63 | org.xfce.Xfconf 64 | [and many more services in which we are not interested] 65 | 66 | To get more details, add the servicename: 67 | 68 | matthijs@matthijs-VirtualBox:~/dev/velib_python/examples$ dbus com.victronenergy.example 69 | / 70 | /Float 71 | /Int 72 | /NegativeInt 73 | /Position 74 | /RPM 75 | /String 76 | 77 | And get the value for the position: 78 | 79 | matthijs@matthijs-VirtualBox:~/dev/velib_python/examples$ dbus com.victronenergy.example /RPM GetValue 80 | 100 81 | 82 | And setting the value is also possible, the % makes dbus evaluate what comes behind it, resulting in an int instead of the default (a string).: 83 | 84 | matthijs@matthijs-VirtualBox:~/dev/velib_python/examples$ dbus com.victronenergy.example /RPM SetValue %1 85 | 0 86 | 87 | In this example, the 0 indicates succes. When trying an unsupported value, 2000, this is what happens: 88 | 89 | matthijs@matthijs-VirtualBox:~/dev/velib_python/examples$ dbus com.victronenergy.example /RPM SetValue %2000 90 | 2 91 | 92 | Exporting services, and the object paths (/Float, /Position, /Group1/Value1, etcetera) is standard D-Bus functionality. At Victron we designed and implemented a D-Bus interface, called com.victronenergy.BusItem. Example showing all interfaces supported by an object: 93 | 94 | matthijs@matthijs-VirtualBox:~/dev/velib_python/examples$ dbus com.victronenergy.example /RPM 95 | Interface org.freedesktop.DBus.Introspectable: 96 | String Introspect() 97 | 98 | Interface com.victronenergy.BusItem: 99 | Int32 SetValue(Variant newvalue) 100 | String GetDescription(String language, Int32 length) 101 | String GetText() 102 | Variant GetValue() 103 | -------------------------------------------------------------------------------- /dbusdummyservice.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | A class to put a simple service on the dbus, according to victron standards, with constantly updating 5 | paths. See example usage below. It is used to generate dummy data for other processes that rely on the 6 | dbus. See files in dbus_vebus_to_pvinverter/test and dbus_vrm/test for other usage examples. 7 | 8 | To change a value while testing, without stopping your dummy script and changing its initial value, write 9 | to the dummy data via the dbus. See example. 10 | 11 | https://github.com/victronenergy/dbus_vebus_to_pvinverter/tree/master/test 12 | """ 13 | from gi.repository import GLib 14 | import platform 15 | import argparse 16 | import logging 17 | import sys 18 | import os 19 | 20 | # our own packages 21 | sys.path.insert(1, os.path.join(os.path.dirname(__file__), '../ext/velib_python')) 22 | from vedbus import VeDbusService 23 | 24 | class DbusDummyService(object): 25 | def __init__(self, servicename, deviceinstance, paths, productname='Dummy product', connection='Dummy service', productid=0): 26 | self._dbusservice = VeDbusService(servicename) 27 | self._paths = paths 28 | 29 | logging.debug("%s /DeviceInstance = %d" % (servicename, deviceinstance)) 30 | 31 | # Create the management objects, as specified in the ccgx dbus-api document 32 | self._dbusservice.add_path('/Mgmt/ProcessName', __file__) 33 | self._dbusservice.add_path('/Mgmt/ProcessVersion', 'Unkown version, and running on Python ' + platform.python_version()) 34 | self._dbusservice.add_path('/Mgmt/Connection', connection) 35 | 36 | # Create the mandatory objects 37 | self._dbusservice.add_path('/DeviceInstance', deviceinstance) 38 | self._dbusservice.add_path('/ProductId', productid) 39 | self._dbusservice.add_path('/ProductName', productname) 40 | self._dbusservice.add_path('/FirmwareVersion', 0) 41 | self._dbusservice.add_path('/HardwareVersion', 0) 42 | self._dbusservice.add_path('/Connected', 1) 43 | 44 | for path, settings in self._paths.items(): 45 | self._dbusservice.add_path( 46 | path, settings['initial'], writeable=True, onchangecallback=self._handlechangedvalue) 47 | 48 | self._dbusservice.register() 49 | GLib.timeout_add(1000, self._update) 50 | 51 | def _update(self): 52 | with self._dbusservice as s: 53 | for path, settings in self._paths.items(): 54 | if 'update' in settings: 55 | update = settings['update'] 56 | if callable(update): 57 | s[path] = update(path, s[path]) 58 | else: 59 | s[path] += update 60 | logging.debug("%s: %s" % (path, s[path])) 61 | return True 62 | 63 | def _handlechangedvalue(self, path, value): 64 | logging.debug("someone else updated %s to %s" % (path, value)) 65 | return True # accept the change 66 | 67 | 68 | # === All code below is to simply run it from the commandline for debugging purposes === 69 | 70 | # It will created a dbus service called com.victronenergy.pvinverter.output. 71 | # To try this on commandline, start this program in one terminal, and try these commands 72 | # from another terminal: 73 | # dbus com.victronenergy.pvinverter.output 74 | # dbus com.victronenergy.pvinverter.output /Ac/Energy/Forward GetValue 75 | # dbus com.victronenergy.pvinverter.output /Ac/Energy/Forward SetValue %20 76 | # 77 | # Above examples use this dbus client: http://code.google.com/p/dbus-tools/wiki/DBusCli 78 | # See their manual to explain the % in %20 79 | 80 | def main(): 81 | logging.basicConfig(level=logging.DEBUG) 82 | 83 | from dbus.mainloop.glib import DBusGMainLoop 84 | # Have a mainloop, so we can send/receive asynchronous calls to and from dbus 85 | DBusGMainLoop(set_as_default=True) 86 | 87 | pvac_output = DbusDummyService( 88 | servicename='com.victronenergy.dummyservice.ttyO1', 89 | deviceinstance=0, 90 | paths={ 91 | '/Ac/Energy/Forward': {'initial': 0, 'update': 1}, 92 | '/Position': {'initial': 0, 'update': 0}, 93 | '/Nonupdatingvalue/UseForTestingWritesForExample': {'initial': None}, 94 | '/DbusInvalid': {'initial': None} 95 | }) 96 | 97 | logging.info('Connected to dbus, and switching over to GLib.MainLoop() (= event based)') 98 | mainloop = GLib.MainLoop() 99 | mainloop.run() 100 | 101 | 102 | if __name__ == "__main__": 103 | main() 104 | -------------------------------------------------------------------------------- /dbusmonitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | ## @package dbus_vrm 5 | # This code takes care of the D-Bus interface (not all of below is implemented yet): 6 | # - on startup it scans the dbus for services we know. For each known service found, it searches for 7 | # objects/paths we know. Everything we find is stored in items{}, and an event is registered: if a 8 | # value changes weĺl be notified and can pass that on to our owner. For example the vrmLogger. 9 | # we know. 10 | # - after startup, it continues to monitor the dbus: 11 | # 1) when services are added we do the same check on that 12 | # 2) when services are removed, we remove any items that we had that referred to that service 13 | # 3) if an existing services adds paths we update ourselves as well: on init, we make a 14 | # VeDbusItemImport for a non-, or not yet existing objectpaths as well1 15 | # 16 | # Code is used by the vrmLogger, and also the pubsub code. Both are other modules in the dbus_vrm repo. 17 | 18 | from dbus.mainloop.glib import DBusGMainLoop 19 | from gi.repository import GLib 20 | import dbus 21 | import dbus.service 22 | import inspect 23 | import logging 24 | import argparse 25 | import pprint 26 | import traceback 27 | import os 28 | from collections import defaultdict 29 | from functools import partial 30 | 31 | # our own packages 32 | from ve_utils import exit_on_error, wrap_dbus_value, unwrap_dbus_value, add_name_owner_changed_receiver 33 | 34 | # dbus interface 35 | VE_INTERFACE = "com.victronenergy.BusItem" 36 | 37 | # For lookups where None is a valid result 38 | notfound = object() 39 | 40 | logger = logging.getLogger(__name__) 41 | logger.setLevel(logging.INFO) 42 | class SystemBus(dbus.bus.BusConnection): 43 | def __new__(cls): 44 | return dbus.bus.BusConnection.__new__(cls, dbus.bus.BusConnection.TYPE_SYSTEM) 45 | 46 | class SessionBus(dbus.bus.BusConnection): 47 | def __new__(cls): 48 | return dbus.bus.BusConnection.__new__(cls, dbus.bus.BusConnection.TYPE_SESSION) 49 | 50 | class MonitoredValue(object): 51 | def __init__(self, value, text, options): 52 | super(MonitoredValue, self).__init__() 53 | self.value = value 54 | self.text = text 55 | self.options = options 56 | 57 | # For legacy code, allow treating this as a tuple/list 58 | def __iter__(self): 59 | return iter((self.value, self.text, self.options)) 60 | 61 | class Service(object): 62 | def __init__(self, id, serviceName, deviceInstance): 63 | super(Service, self).__init__() 64 | self.id = id 65 | self.name = serviceName 66 | self.paths = {} 67 | self._seen = set() 68 | self.deviceInstance = deviceInstance 69 | 70 | # For legacy code, attributes can still be accessed as if keys from a 71 | # dictionary. 72 | def __setitem__(self, key, value): 73 | self.__dict__[key] = value 74 | def __getitem__(self, key): 75 | return self.__dict__[key] 76 | 77 | def set_seen(self, path): 78 | self._seen.add(path) 79 | 80 | def seen(self, path): 81 | return path in self._seen 82 | 83 | @property 84 | def service_class(self): 85 | return '.'.join(self.name.split('.')[:3]) 86 | 87 | class DbusMonitor(object): 88 | ## Constructor 89 | def __init__(self, dbusTree, valueChangedCallback=None, deviceAddedCallback=None, 90 | deviceRemovedCallback=None, namespace="com.victronenergy", ignoreServices=[]): 91 | # valueChangedCallback is the callback that we call when something has changed. 92 | # def value_changed_on_dbus(dbusServiceName, dbusPath, options, changes, deviceInstance): 93 | # in which changes is a tuple with GetText() and GetValue() 94 | self.valueChangedCallback = valueChangedCallback 95 | self.deviceAddedCallback = deviceAddedCallback 96 | self.deviceRemovedCallback = deviceRemovedCallback 97 | self.dbusTree = dbusTree 98 | self.ignoreServices = ignoreServices 99 | 100 | # Lists all tracked services. Stores name, id, device instance, value per path, and whenToLog info 101 | # indexed by service name (eg. com.victronenergy.settings). 102 | self.servicesByName = {} 103 | 104 | # Same values as self.servicesByName, but indexed by service id (eg. :1.30) 105 | self.servicesById = {} 106 | 107 | # Keep track of services by class to speed up calls to get_service_list 108 | self.servicesByClass = defaultdict(list) 109 | 110 | # Keep track of any additional watches placed on items 111 | self.serviceWatches = defaultdict(list) 112 | 113 | # For a PC, connect to the SessionBus 114 | # For a CCGX, connect to the SystemBus 115 | self.dbusConn = SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else SystemBus() 116 | 117 | # subscribe to NameOwnerChange for bus connect / disconnect events. 118 | # NOTE: this is on a different bus then the one above! 119 | standardBus = (dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ \ 120 | else dbus.SystemBus()) 121 | 122 | add_name_owner_changed_receiver(standardBus, self.dbus_name_owner_changed) 123 | 124 | # Subscribe to PropertiesChanged for all services 125 | self.dbusConn.add_signal_receiver(self.handler_value_changes, 126 | dbus_interface='com.victronenergy.BusItem', 127 | signal_name='PropertiesChanged', path_keyword='path', 128 | sender_keyword='senderId') 129 | 130 | # Subscribe to ItemsChanged for all services 131 | self.dbusConn.add_signal_receiver(self.handler_item_changes, 132 | dbus_interface='com.victronenergy.BusItem', 133 | signal_name='ItemsChanged', path='/', 134 | sender_keyword='senderId') 135 | 136 | logger.info('===== Search on dbus for services that we will monitor starting... =====') 137 | serviceNames = self.dbusConn.list_names() 138 | for serviceName in serviceNames: 139 | self.scan_dbus_service(serviceName) 140 | 141 | logger.info('===== Search on dbus for services that we will monitor finished =====') 142 | 143 | @staticmethod 144 | def make_service(serviceId, serviceName, deviceInstance): 145 | """ Override this to use a different kind of service object. """ 146 | return Service(serviceId, serviceName, deviceInstance) 147 | 148 | def make_monitor(self, service, path, value, text, options): 149 | """ Override this to do more things with monitoring. """ 150 | return MonitoredValue(unwrap_dbus_value(value), unwrap_dbus_value(text), options) 151 | 152 | def dbus_name_owner_changed(self, name, oldowner, newowner): 153 | if not name.startswith("com.victronenergy."): 154 | return 155 | 156 | #decouple, and process in main loop 157 | GLib.idle_add(exit_on_error, self._process_name_owner_changed, name, oldowner, newowner) 158 | 159 | def _process_name_owner_changed(self, name, oldowner, newowner): 160 | if newowner != '': 161 | # so we found some new service. Check if we can do something with it. 162 | newdeviceadded = self.scan_dbus_service(name) 163 | if newdeviceadded and self.deviceAddedCallback is not None: 164 | self.deviceAddedCallback(name, self.get_device_instance(name)) 165 | 166 | elif name in self.servicesByName: 167 | # it disappeared, we need to remove it. 168 | logger.info("%s disappeared from the dbus. Removing it from our lists" % name) 169 | service = self.servicesByName[name] 170 | del self.servicesById[service.id] 171 | del self.servicesByName[name] 172 | for watch in self.serviceWatches[name]: 173 | watch.remove() 174 | del self.serviceWatches[name] 175 | self.servicesByClass[service.service_class].remove(service) 176 | if self.deviceRemovedCallback is not None: 177 | self.deviceRemovedCallback(name, service.deviceInstance) 178 | 179 | def scan_dbus_service(self, serviceName): 180 | try: 181 | return self.scan_dbus_service_inner(serviceName) 182 | except: 183 | logger.error("Ignoring %s because of error while scanning:" % (serviceName)) 184 | traceback.print_exc() 185 | return False 186 | 187 | # Errors 'org.freedesktop.DBus.Error.ServiceUnknown' and 188 | # 'org.freedesktop.DBus.Error.Disconnected' seem to happen when the service 189 | # disappears while its being scanned. Which might happen, but is not really 190 | # normal either, so letting them go into the logs. 191 | 192 | # Scans the given dbus service to see if it contains anything interesting for us. If it does, add 193 | # it to our list of monitored D-Bus services. 194 | def scan_dbus_service_inner(self, serviceName): 195 | 196 | # make it a normal string instead of dbus string 197 | serviceName = str(serviceName) 198 | 199 | if (len(self.ignoreServices) != 0 and any(serviceName.startswith(x) for x in self.ignoreServices)): 200 | logger.debug("Ignoring service %s" % serviceName) 201 | return False 202 | 203 | paths = self.dbusTree.get('.'.join(serviceName.split('.')[0:3]), None) 204 | if paths is None: 205 | logger.debug("Ignoring service %s, not in the tree" % serviceName) 206 | return False 207 | 208 | logger.info("Found: %s, scanning and storing items" % serviceName) 209 | serviceId = self.dbusConn.get_name_owner(serviceName) 210 | 211 | # we should never be notified to add a D-Bus service that we already have. If this assertion 212 | # raises, check process_name_owner_changed, and D-Bus workings. 213 | assert serviceName not in self.servicesByName 214 | assert serviceId not in self.servicesById 215 | 216 | # Try to fetch everything with a GetItems, then fall back to older 217 | # methods if that fails 218 | try: 219 | values = self.dbusConn.call_blocking(serviceName, '/', VE_INTERFACE, 'GetItems', '', []) 220 | except dbus.exceptions.DBusException: 221 | logger.info("GetItems failed, trying legacy methods") 222 | else: 223 | return self.scan_dbus_service_getitems_done(serviceName, serviceId, values) 224 | 225 | if serviceName == 'com.victronenergy.settings' or serviceName == 'com.victronenergy.platform': 226 | di = 0 227 | elif serviceName.startswith('com.victronenergy.vecan.'): 228 | di = 0 229 | else: 230 | try: 231 | di = self.dbusConn.call_blocking(serviceName, 232 | '/DeviceInstance', VE_INTERFACE, 'GetValue', '', []) 233 | except dbus.exceptions.DBusException: 234 | logger.info(" %s was skipped because it has no device instance" % serviceName) 235 | return False # Skip it 236 | else: 237 | di = int(di) 238 | 239 | logger.info(" %s has device instance %s" % (serviceName, di)) 240 | service = self.make_service(serviceId, serviceName, di) 241 | 242 | # Let's try to fetch everything in one go 243 | values = {} 244 | texts = {} 245 | try: 246 | values.update(self.dbusConn.call_blocking(serviceName, '/', VE_INTERFACE, 'GetValue', '', [])) 247 | texts.update(self.dbusConn.call_blocking(serviceName, '/', VE_INTERFACE, 'GetText', '', [])) 248 | except: 249 | pass 250 | 251 | for path, options in paths.items(): 252 | # path will be the D-Bus path: '/Ac/ActiveIn/L1/V' 253 | # options will be a dictionary: {'code': 'V', 'whenToLog': 'onIntervalAlways'} 254 | 255 | # Try to obtain the value we want from our bulk fetch. If we 256 | # cannot find it there, do an individual query. 257 | value = values.get(path[1:], notfound) 258 | if value != notfound: 259 | service.set_seen(path) 260 | text = texts.get(path[1:], notfound) 261 | if value is notfound or text is notfound: 262 | try: 263 | value = self.dbusConn.call_blocking(serviceName, path, VE_INTERFACE, 'GetValue', '', []) 264 | service.set_seen(path) 265 | text = self.dbusConn.call_blocking(serviceName, path, VE_INTERFACE, 'GetText', '', []) 266 | except dbus.exceptions.DBusException as e: 267 | if e.get_dbus_name() in ( 268 | 'org.freedesktop.DBus.Error.ServiceUnknown', 269 | 'org.freedesktop.DBus.Error.Disconnected'): 270 | raise # This exception will be handled below 271 | 272 | # TODO org.freedesktop.DBus.Error.UnknownMethod really 273 | # shouldn't happen but sometimes does. 274 | logger.debug("%s %s does not exist (yet)" % (serviceName, path)) 275 | value = None 276 | text = None 277 | 278 | service.paths[path] = self.make_monitor(service, path, unwrap_dbus_value(value), unwrap_dbus_value(text), options) 279 | 280 | 281 | logger.debug("Finished scanning and storing items for %s" % serviceName) 282 | 283 | # Adjust self at the end of the scan, so we don't have an incomplete set of 284 | # data if an exception occurs during the scan. 285 | self.servicesByName[serviceName] = service 286 | self.servicesById[serviceId] = service 287 | self.servicesByClass[service.service_class].append(service) 288 | 289 | return True 290 | 291 | def scan_dbus_service_getitems_done(self, serviceName, serviceId, values): 292 | # Keeping these exceptions for legacy reasons 293 | if serviceName == 'com.victronenergy.settings' or serviceName == 'com.victronenergy.platform': 294 | di = 0 295 | elif serviceName.startswith('com.victronenergy.vecan.'): 296 | di = 0 297 | else: 298 | try: 299 | di = values['/DeviceInstance']['Value'] 300 | except KeyError: 301 | logger.info(" %s was skipped because it has no device instance" % serviceName) 302 | return False 303 | else: 304 | di = int(di) 305 | 306 | logger.info(" %s has device instance %s" % (serviceName, di)) 307 | service = self.make_service(serviceId, serviceName, di) 308 | 309 | paths = self.dbusTree.get('.'.join(serviceName.split('.')[0:3]), {}) 310 | for path, options in paths.items(): 311 | item = values.get(path, notfound) 312 | if item is notfound: 313 | service.paths[path] = self.make_monitor(service, path, None, None, options) 314 | else: 315 | service.set_seen(path) 316 | value = item.get('Value', None) 317 | text = item.get('Text', None) 318 | service.paths[path] = self.make_monitor(service, path, unwrap_dbus_value(value), unwrap_dbus_value(text), options) 319 | 320 | self.servicesByName[serviceName] = service 321 | self.servicesById[serviceId] = service 322 | self.servicesByClass[service.service_class].append(service) 323 | return True 324 | 325 | def handler_item_changes(self, items, senderId): 326 | if not isinstance(items, dict): 327 | return 328 | 329 | try: 330 | service = self.servicesById[senderId] 331 | except KeyError: 332 | # senderId isn't there, which means it hasn't been scanned yet. 333 | return 334 | 335 | for path, changes in items.items(): 336 | try: 337 | v = unwrap_dbus_value(changes['Value']) 338 | except (KeyError, TypeError): 339 | continue 340 | 341 | try: 342 | t = changes['Text'] 343 | except KeyError: 344 | t = str(v) 345 | self._handler_value_changes(service, path, v, t) 346 | 347 | def handler_value_changes(self, changes, path, senderId): 348 | # If this properyChange does not involve a value, our work is done. 349 | if 'Value' not in changes: 350 | return 351 | 352 | try: 353 | service = self.servicesById[senderId] 354 | except KeyError: 355 | # senderId isn't there, which means it hasn't been scanned yet. 356 | return 357 | 358 | v = unwrap_dbus_value(changes['Value']) 359 | # Some services don't send Text with their PropertiesChanged events. 360 | try: 361 | t = changes['Text'] 362 | except KeyError: 363 | t = str(v) 364 | self._handler_value_changes(service, path, v, t) 365 | 366 | def _handler_value_changes(self, service, path, value, text): 367 | try: 368 | a = service.paths[path] 369 | except KeyError: 370 | # path isn't there, which means it hasn't been scanned yet. 371 | return 372 | 373 | service.set_seen(path) 374 | 375 | # First update our store to the new value 376 | if a.value == value: 377 | return 378 | 379 | a.value = value 380 | a.text = text 381 | 382 | # And do the rest of the processing in on the mainloop 383 | if self.valueChangedCallback is not None: 384 | GLib.idle_add(exit_on_error, self._execute_value_changes, service.name, path, { 385 | 'Value': value, 'Text': text}, a.options) 386 | 387 | def _execute_value_changes(self, serviceName, objectPath, changes, options): 388 | # double check that the service still exists, as it might have 389 | # disappeared between scheduling-for and executing this function. 390 | if serviceName not in self.servicesByName: 391 | return 392 | 393 | self.valueChangedCallback(serviceName, objectPath, 394 | options, changes, self.get_device_instance(serviceName)) 395 | 396 | # Gets the value for a certain servicename and path 397 | # The default_value is returned when: 398 | # 1. When the service doesn't exist. 399 | # 2. When the path asked for isn't being monitored. 400 | # 3. When the path exists, but has dbus-invalid, ie an empty byte array. 401 | # 4. When the path asked for is being monitored, but doesn't exist for that service. 402 | def get_value(self, serviceName, objectPath, default_value=None): 403 | service = self.servicesByName.get(serviceName, None) 404 | if service is None: 405 | return default_value 406 | 407 | value = service.paths.get(objectPath, None) 408 | if value is None or value.value is None: 409 | return default_value 410 | 411 | return value.value 412 | 413 | # returns if a dbus exists now, by doing a blocking dbus call. 414 | # Typically seen will be sufficient and doesn't need access to the dbus. 415 | def exists(self, serviceName, objectPath): 416 | try: 417 | self.dbusConn.call_blocking(serviceName, objectPath, VE_INTERFACE, 'GetValue', '', []) 418 | return True 419 | except dbus.exceptions.DBusException as e: 420 | return False 421 | 422 | # Returns if there ever was a successful GetValue or valueChanged event. 423 | # Unlike get_value this return True also if the actual value is invalid. 424 | # 425 | # Note: the path might no longer exists anymore, but that doesn't happen in 426 | # practice. If a service really wants to reconfigure itself typically it should 427 | # reconnect to the dbus which causes it to be rescanned and seen will be updated. 428 | # If it is really needed to know if a path still exists, use exists. 429 | def seen(self, serviceName, objectPath): 430 | try: 431 | return self.servicesByName[serviceName].seen(objectPath) 432 | except KeyError: 433 | return False 434 | 435 | # Sets the value for a certain servicename and path, returns the return value of the D-Bus SetValue 436 | # method. If the underlying item does not exist (the service does not exist, or the objectPath was not 437 | # registered) the function will return -1 438 | def set_value(self, serviceName, objectPath, value): 439 | # Check if the D-Bus object referenced by serviceName and objectPath is registered. There is no 440 | # necessity to do this, but it is in line with previous implementations which kept VeDbusItemImport 441 | # objects for registers items only. 442 | service = self.servicesByName.get(serviceName, None) 443 | if service is None: 444 | return -1 445 | if objectPath not in service.paths: 446 | return -1 447 | # We do not catch D-Bus exceptions here, because the previous implementation did not do that either. 448 | return self.dbusConn.call_blocking(serviceName, objectPath, 449 | dbus_interface=VE_INTERFACE, 450 | method='SetValue', signature=None, 451 | args=[wrap_dbus_value(value)]) 452 | 453 | # Similar to set_value, but operates asynchronously 454 | def set_value_async(self, serviceName, objectPath, value, 455 | reply_handler=None, error_handler=None): 456 | service = self.servicesByName.get(serviceName, None) 457 | if service is not None: 458 | if objectPath in service.paths: 459 | self.dbusConn.call_async(serviceName, objectPath, 460 | dbus_interface=VE_INTERFACE, 461 | method='SetValue', signature=None, 462 | args=[wrap_dbus_value(value)], 463 | reply_handler=reply_handler, error_handler=error_handler) 464 | return 465 | 466 | if error_handler is not None: 467 | error_handler(TypeError('Service or path not found, ' 468 | 'service=%s, path=%s' % (serviceName, objectPath))) 469 | 470 | # returns a dictionary, keys are the servicenames, value the instances 471 | # optionally use the classfilter to get only a certain type of services, for 472 | # example com.victronenergy.battery. 473 | def get_service_list(self, classfilter=None): 474 | if classfilter is None: 475 | return { servicename: service.deviceInstance \ 476 | for servicename, service in self.servicesByName.items() } 477 | 478 | if classfilter not in self.servicesByClass: 479 | return {} 480 | 481 | return { service.name: service.deviceInstance \ 482 | for service in self.servicesByClass[classfilter] } 483 | 484 | def get_device_instance(self, serviceName): 485 | return self.servicesByName[serviceName].deviceInstance 486 | 487 | def track_value(self, serviceName, objectPath, callback, *args, **kwargs): 488 | """ A DbusMonitor can watch specific service/path combos for changes 489 | so that it is not fully reliant on the global handler_value_changes 490 | in this class. Additional watches are deleted automatically when 491 | the service disappears from dbus. """ 492 | cb = partial(callback, *args, **kwargs) 493 | 494 | def root_tracker(items): 495 | # Check if objectPath in dict 496 | try: 497 | v = items[objectPath] 498 | _v = unwrap_dbus_value(v['Value']) 499 | except (KeyError, TypeError): 500 | return # not in this dict 501 | 502 | try: 503 | t = v['Text'] 504 | except KeyError: 505 | cb({'Value': _v }) 506 | else: 507 | cb({'Value': _v, 'Text': t}) 508 | 509 | # Track changes on the path, and also on root 510 | self.serviceWatches[serviceName].extend(( 511 | self.dbusConn.add_signal_receiver(cb, 512 | dbus_interface='com.victronenergy.BusItem', 513 | signal_name='PropertiesChanged', 514 | path=objectPath, bus_name=serviceName), 515 | self.dbusConn.add_signal_receiver(root_tracker, 516 | dbus_interface='com.victronenergy.BusItem', 517 | signal_name='ItemsChanged', 518 | path="/", bus_name=serviceName), 519 | )) 520 | 521 | 522 | # ====== ALL CODE BELOW THIS LINE IS PURELY FOR DEVELOPING THIS CLASS ====== 523 | 524 | # Example function that can be used as a starting point to use this code 525 | def value_changed_on_dbus(dbusServiceName, dbusPath, dict, changes, deviceInstance): 526 | logger.debug("0 ----------------") 527 | logger.debug("1 %s%s changed" % (dbusServiceName, dbusPath)) 528 | logger.debug("2 vrm dict : %s" % dict) 529 | logger.debug("3 changes-text: %s" % changes['Text']) 530 | logger.debug("4 changes-value: %s" % changes['Value']) 531 | logger.debug("5 deviceInstance: %s" % deviceInstance) 532 | logger.debug("6 - end") 533 | 534 | 535 | def nameownerchange(a, b): 536 | # used to find memory leaks in dbusmonitor and VeDbusItemImport 537 | import gc 538 | gc.collect() 539 | objects = gc.get_objects() 540 | print (len([o for o in objects if type(o).__name__ == 'VeDbusItemImport'])) 541 | print (len([o for o in objects if type(o).__name__ == 'SignalMatch'])) 542 | print (len(objects)) 543 | 544 | 545 | def print_values(dbusmonitor): 546 | a = dbusmonitor.get_value('wrongservice', '/DbusInvalid', default_value=1000) 547 | b = dbusmonitor.get_value('com.victronenergy.dummyservice.ttyO1', '/NotInTheMonitorList', default_value=1000) 548 | c = dbusmonitor.get_value('com.victronenergy.dummyservice.ttyO1', '/DbusInvalid', default_value=1000) 549 | d = dbusmonitor.get_value('com.victronenergy.dummyservice.ttyO1', '/NonExistingButMonitored', default_value=1000) 550 | 551 | print ("All should be 1000: Wrong Service: %s, NotInTheMonitorList: %s, DbusInvalid: %s, NonExistingButMonitored: %s" % (a, b, c, d)) 552 | return True 553 | 554 | # We have a mainloop, but that is just for developing this code. Normally above class & code is used from 555 | # some other class, such as vrmLogger or the pubsub Implementation. 556 | def main(): 557 | # Init logging 558 | logging.basicConfig(level=logging.DEBUG) 559 | logger.info(__file__ + " is starting up") 560 | 561 | # Have a mainloop, so we can send/receive asynchronous calls to and from dbus 562 | DBusGMainLoop(set_as_default=True) 563 | 564 | import os 565 | import sys 566 | sys.path.insert(1, os.path.join(os.path.dirname(__file__), '../../')) 567 | 568 | dummy = {'code': None, 'whenToLog': 'configChange', 'accessLevel': None} 569 | monitorlist = {'com.victronenergy.dummyservice': { 570 | '/Connected': dummy, 571 | '/ProductName': dummy, 572 | '/Mgmt/Connection': dummy, 573 | '/Dc/0/Voltage': dummy, 574 | '/Dc/0/Current': dummy, 575 | '/Dc/0/Temperature': dummy, 576 | '/Load/I': dummy, 577 | '/FirmwareVersion': dummy, 578 | '/DbusInvalid': dummy, 579 | '/NonExistingButMonitored': dummy}} 580 | 581 | d = DbusMonitor(monitorlist, value_changed_on_dbus, 582 | deviceAddedCallback=nameownerchange, deviceRemovedCallback=nameownerchange) 583 | 584 | GLib.timeout_add(1000, print_values, d) 585 | 586 | # Start and run the mainloop 587 | logger.info("Starting mainloop, responding on only events") 588 | mainloop = GLib.MainLoop() 589 | mainloop.run() 590 | 591 | if __name__ == "__main__": 592 | main() 593 | -------------------------------------------------------------------------------- /examples/vedbusitem_import_examples.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # This file has some tests, to do type checking of vedbus.py 5 | # This file makes it easy to compare the values put on the dbus through 6 | # Python (vedbus.VeDbusItemExport) with items exported in C (the mk2dbus process) 7 | 8 | # Note that this file requires vedbusitemexport_examples to be running. 9 | 10 | import dbus 11 | import pprint 12 | import os 13 | import sys 14 | from dbus.mainloop.glib import DBusGMainLoop 15 | 16 | # our own packages 17 | sys.path.insert(1, os.path.join(os.path.dirname(__file__), '../')) 18 | from vedbus import VeDbusItemExport, VeDbusItemImport 19 | 20 | DBusGMainLoop(set_as_default=True) 21 | 22 | # Connect to the sessionbus. Note that on ccgx we use systembus instead. 23 | dbusConn = dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus() 24 | 25 | 26 | # dictionary containing the different items 27 | dbusObjects = {} 28 | 29 | # check if the vbus.ttyO1 exists (it normally does on a ccgx, and for linux a pc, there is 30 | # some emulator. 31 | hasVEBus = 'com.victronenergy.vebus.ttyO1' in dbusConn.list_names() 32 | 33 | dbusObjects['PyString'] = VeDbusItemImport(dbusConn, 'com.victronenergy.example', '/String') 34 | if hasVEBus: dbusObjects['C_string'] = VeDbusItemImport(dbusConn, 'com.victronenergy.vebus.ttyO1', '/Mgmt/ProcessName') 35 | 36 | dbusObjects['PyFloat'] = VeDbusItemImport(dbusConn, 'com.victronenergy.example', '/Float') 37 | if hasVEBus: dbusObjects['C_float'] = VeDbusItemImport(dbusConn, 'com.victronenergy.vebus.ttyO1', '/Dc/V') 38 | 39 | dbusObjects['PyInt'] = VeDbusItemImport(dbusConn, 'com.victronenergy.example', '/Int') 40 | if hasVEBus: dbusObjects['C_int'] = VeDbusItemImport(dbusConn, 'com.victronenergy.vebus.ttyO1', '/State') 41 | 42 | dbusObjects['PyNegativeInt'] = VeDbusItemImport(dbusConn, 'com.victronenergy.example', '/NegativeInt') 43 | if hasVEBus: dbusObjects['C_negativeInt'] = VeDbusItemImport(dbusConn, 'com.victronenergy.vebus.ttyO1', '/Dc/I') 44 | 45 | # print the results 46 | print('----') 47 | for key, o in dbusObjects.items(): 48 | print(key + ' at ' + o.serviceName + o.path) 49 | pprint.pprint(dbusObjects[key]) 50 | print('pprint veBusItem.get_value(): ') 51 | pprint.pprint(dbusObjects[key].get_value()) 52 | print('pprint veBusItem.get_text(): ') 53 | pprint.pprint(dbusObjects[key].get_text()) 54 | print('----') 55 | -------------------------------------------------------------------------------- /examples/vedbusservice_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from dbus.mainloop.glib import DBusGMainLoop 5 | from gi.repository import GLib 6 | import dbus 7 | import dbus.service 8 | import inspect 9 | import pprint 10 | import os 11 | import sys 12 | 13 | # our own packages 14 | sys.path.insert(1, os.path.join(os.path.dirname(__file__), '../')) 15 | from vedbus import VeDbusService 16 | 17 | softwareVersion = '1.0' 18 | 19 | def validate_new_value(path, newvalue): 20 | # Max RPM setpoint = 1000 21 | return newvalue <= 1000 22 | 23 | def get_text_for_rpm(path, value): 24 | return('%d rotations per minute' % value) 25 | 26 | def main(argv): 27 | global dbusObjects 28 | 29 | print(__file__ + " starting up") 30 | 31 | # Have a mainloop, so we can send/receive asynchronous calls to and from dbus 32 | DBusGMainLoop(set_as_default=True) 33 | 34 | # Put ourselves on to the dbus 35 | dbusservice = VeDbusService('com.victronenergy.example', register=False) 36 | 37 | # Most simple and short way to add an object with an initial value of 5. 38 | dbusservice.add_path('/Position', value=5) 39 | 40 | # Most advanced way to add a path 41 | dbusservice.add_path('/RPM', value=100, description='RPM setpoint', writeable=True, 42 | onchangecallback=validate_new_value, gettextcallback=get_text_for_rpm) 43 | 44 | # Many types supported 45 | dbusservice.add_path('/String', 'this is a string') 46 | dbusservice.add_path('/Int', 0) 47 | dbusservice.add_path('/NegativeInt', -10) 48 | dbusservice.add_path('/Float', 1.5) 49 | 50 | # Call register after adding paths. More paths can be added later. 51 | # This claims the service name on dbus. 52 | dbusservice.register() 53 | 54 | # You can access the paths as if the dbusservice is a dictionary 55 | print('/Position value is %s' % dbusservice['/Position']) 56 | 57 | # Same for changing it 58 | dbusservice['/Position'] = 10 59 | 60 | print('/Position value is now %s' % dbusservice['/Position']) 61 | 62 | # To invalidate a value (see com.victronenergy.BusItem specs for definition of invalid), set to None 63 | dbusservice['/Position'] = None 64 | 65 | print('try changing our RPM by executing the following command from a terminal\n') 66 | print('dbus-send --print-reply --dest=com.victronenergy.example /RPM com.victronenergy.BusItem.SetValue int32:1200') 67 | print('Reply will be <> 0 for values > 1000: not accepted. And reply will be 0 for values < 1000: accepted.') 68 | mainloop = GLib.MainLoop() 69 | mainloop.run() 70 | 71 | main("") 72 | -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 -u 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import sys 6 | 7 | class LevelFilter(logging.Filter): 8 | def __init__(self, passlevels, reject): 9 | self.passlevels = passlevels 10 | self.reject = reject 11 | 12 | def filter(self, record): 13 | if self.reject: 14 | return (record.levelno not in self.passlevels) 15 | else: 16 | return (record.levelno in self.passlevels) 17 | 18 | # Leave the name set to None to get the root logger. For some reason specifying 'root' has a 19 | # different effect: there will be two root loggers, both with their own handlers... 20 | def setup_logging(debug=False, name=None): 21 | formatter = logging.Formatter(fmt='%(levelname)s:%(module)s:%(message)s') 22 | 23 | # Make info and debug stream to stdout and the rest to stderr 24 | h1 = logging.StreamHandler(sys.stdout) 25 | h1.addFilter(LevelFilter([logging.INFO, logging.DEBUG], False)) 26 | h1.setFormatter(formatter) 27 | 28 | h2 = logging.StreamHandler(sys.stderr) 29 | h2.addFilter(LevelFilter([logging.INFO, logging.DEBUG], True)) 30 | h2.setFormatter(formatter) 31 | 32 | logger = logging.getLogger(name) 33 | logger.addHandler(h1) 34 | logger.addHandler(h2) 35 | 36 | # Set the loglevel and show it 37 | logger.setLevel(level=(logging.DEBUG if debug else logging.INFO)) 38 | logLevel = {0: 'NOTSET', 10: 'DEBUG', 20: 'INFO', 30: 'WARNING', 40: 'ERROR'} 39 | logger.info('Loglevel set to ' + logLevel[logger.getEffectiveLevel()]) 40 | 41 | return logger 42 | -------------------------------------------------------------------------------- /mosquitto_bridge_registrator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 -u 2 | 3 | import dbus 4 | import fcntl 5 | import threading 6 | import logging 7 | import os 8 | import requests 9 | import subprocess 10 | import traceback 11 | from ve_utils import exit_on_error 12 | 13 | VrmNumberOfBrokers = 128 14 | VrmApiServer = 'https://ccgxlogging.victronenergy.com' 15 | CaBundlePath = "/etc/ssl/certs/ccgx-ca.pem" 16 | RpcBroker = 'mqtt-rpc.victronenergy.com' 17 | SettingsPath = os.environ.get('DBUS_MQTT_PATH') or '/data/conf/flashmq.d' 18 | BridgeConfigPath = os.path.join(SettingsPath, 'vrm_bridge.conf') 19 | MosquittoConfig = '/data/conf/mosquitto.d/vrm_bridge.conf' 20 | MqttPasswordFile = "/data/conf/mqtt_password.txt" 21 | 22 | BridgeSettingsRPC = ''' 23 | bridge {{ 24 | protocol_version mqtt5 25 | max_outgoing_topic_aliases 5000 26 | address {3} 27 | port 443 28 | tls on 29 | bridge_protocol_bit true 30 | publish P/{0}/out/# 31 | subscribe P/{0}/in/# 32 | clientid_prefix GXrpc 33 | remote_username {5} 34 | remote_password {1} 35 | ca_file {4} 36 | }} 37 | 38 | ''' 39 | 40 | BridgeSettingsDbus = ''' 41 | bridge {{ 42 | protocol_version mqtt5 43 | address {2} 44 | port 443 45 | tls on 46 | bridge_protocol_bit true 47 | publish N/{0}/# 48 | subscribe R/{0}/# 49 | subscribe W/{0}/# 50 | publish I/{0}/out/# 51 | subscribe I/{0}/in/# 52 | clientid_prefix GXdbus 53 | remote_username {5} 54 | remote_password {1} 55 | ca_file {4} 56 | }} 57 | 58 | ''' 59 | 60 | LockFilePath = "/run/mosquittobridgeregistrator.lock" 61 | 62 | 63 | def get_setting(path): 64 | """Throwing exceptions on fail is desired.""" 65 | 66 | bus = dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus() 67 | msg = dbus.lowlevel.MethodCallMessage( 68 | 'com.victronenergy.settings', path, 'com.victronenergy.BusItem', 'GetValue') 69 | reply = bus.send_message_with_reply_and_block(msg) 70 | answer = reply.get_args_list()[0].real 71 | return answer 72 | 73 | class RepeatingTimer(threading.Thread): 74 | def __init__(self, callback, interval): 75 | threading.Thread.__init__(self) 76 | self.event = threading.Event() 77 | self.callback = callback 78 | self.interval = interval 79 | 80 | def run(self): 81 | while not self.event.is_set(): 82 | if not self.callback(): 83 | self.event.set() 84 | 85 | # either call your function here, 86 | # or put the body of the function here 87 | self.event.wait(self.interval) 88 | 89 | def stop(self): 90 | self.event.set() 91 | 92 | 93 | class MosquittoBridgeRegistrator(object): 94 | """ 95 | The MosquittoBridgeRegistrator manages a bridge connection between the local 96 | MQTT server, and the global VRM broker. It can be called 97 | concurrently by different processes; efforts will be synchronized using an 98 | advisory lock file. 99 | 100 | It now also supports registering the API key and getting it and the password without 101 | restarting the MQTT server. This allows using the API key, but not use the local broker 102 | and instead connect directly to the VRM broker url. 103 | """ 104 | 105 | def __init__(self, system_id): 106 | self._init_broker_timer = None 107 | self._aborted = threading.Event() 108 | self._system_id = system_id 109 | self._global_broker_username = "ccgxapikey_" + self._system_id 110 | self._global_broker_password = None 111 | self._requests_log_level = logging.getLogger("requests").getEffectiveLevel() 112 | 113 | def _get_vrm_broker_url(self): 114 | """To allow scaling, the VRM broker URL is generated based on the system identifier 115 | The function returns a numbered broker URL between 0 and VrmNumberOfBrokers, which makes sure 116 | that broker connections are distributed equally between all VRM brokers 117 | """ 118 | sum = 0 119 | for character in self._system_id.lower().strip(): 120 | sum += ord(character) 121 | broker_index = sum % VrmNumberOfBrokers 122 | return "mqtt{}.victronenergy.com".format(broker_index) 123 | 124 | 125 | def load_or_generate_mqtt_password(self): 126 | """In case posting the password to storemqttpassword.php was processed 127 | by the server, but we never saw the response, we need to keep it around 128 | for the next time (don't post a random new one). 129 | 130 | This way of storing the password was incepted later, and makes it 131 | backwards compatible. 132 | 133 | The MQTT password is now stored in the EEPROM on some devices and it is written 134 | to the mqtt_password file during boot. Note that not all devices have 135 | an EEPROM and this file is added later one. So while it is leading now, it 136 | might not be there... 137 | """ 138 | 139 | password = None 140 | 141 | if os.path.exists(MqttPasswordFile): 142 | with open(MqttPasswordFile, "r") as f: 143 | logging.info("Using {}".format(MqttPasswordFile)) 144 | password = f.read().strip() 145 | return password 146 | 147 | # before FlashMQ, mosquitto was used. Check if it has a password. 148 | elif os.path.exists(MosquittoConfig): 149 | try: 150 | with open(MosquittoConfig, 'rt') as in_file: 151 | config = in_file.read() 152 | for l in config.split('\n'): 153 | if l.startswith("remote_password"): 154 | password = l.split(' ')[1] 155 | print("Using mosquitto password") 156 | break 157 | except: 158 | pass 159 | 160 | if password == None: 161 | password = get_random_string(32) 162 | 163 | with open(MqttPasswordFile + ".tmp", "w") as f: 164 | logging.info("Writing new {}".format(MqttPasswordFile)) 165 | 166 | # make sure the password is on the disk 167 | f.write(password) 168 | f.flush() 169 | os.fsync(f.fileno()) 170 | 171 | os.rename(MqttPasswordFile + ".tmp", MqttPasswordFile) 172 | 173 | # update the directory meta-info 174 | fd = os.open(os.path.dirname(MqttPasswordFile), 0) 175 | os.fsync(fd) 176 | os.close(fd) 177 | 178 | if os.path.exists(MosquittoConfig): 179 | self._delete_silently(MosquittoConfig) 180 | 181 | return password 182 | 183 | def register(self): 184 | if self._init_broker_timer is not None: 185 | return 186 | if self._init_broker(quiet=False, timeout=5): 187 | if not self._aborted.is_set(): 188 | logging.info("[InitBroker] Registration failed. Retrying in thread, silently.") 189 | logging.getLogger("requests").setLevel(logging.WARNING) 190 | # Not using gobject to keep these blocking operations out of the event loop 191 | self._init_broker_timer = RepeatingTimer(self._init_broker, 60) 192 | self._init_broker_timer.start() 193 | 194 | def abort_gracefully(self): 195 | self._aborted.set() 196 | if self._init_broker_timer: 197 | self._init_broker_timer.stop() 198 | self._init_broker_timer.join() 199 | 200 | def _write_config_atomically(self, path, contents): 201 | 202 | config_dir = os.path.dirname(path) 203 | if not os.path.exists(config_dir): 204 | os.makedirs(config_dir) 205 | 206 | with open(path + ".tmp", 'wt') as out_file: 207 | # make sure the new config is on the disk 208 | out_file.write(contents) 209 | out_file.flush() 210 | os.fsync(out_file.fileno()) 211 | 212 | # make sure there is either the old file or the new one 213 | os.rename(path + ".tmp", path) 214 | 215 | # update the directory meta-info 216 | fd = os.open(os.path.dirname(path), 0) 217 | os.fsync(fd) 218 | os.close(fd) 219 | 220 | def _delete_silently(self, path): 221 | try: 222 | os.remove(path) 223 | except: 224 | pass 225 | 226 | def _init_broker(self, quiet=True, timeout=5): 227 | try: 228 | with open(LockFilePath, "a") as lockFile: 229 | fcntl.flock(lockFile, fcntl.LOCK_EX) 230 | 231 | orig_config = None 232 | # Read the current config file (if present) 233 | try: 234 | if not quiet: 235 | logging.info('[InitBroker] Reading config file') 236 | with open(BridgeConfigPath, 'rt') as in_file: 237 | orig_config = in_file.read() 238 | except IOError: 239 | if not quiet: 240 | logging.info('[InitBroker] Reading config file failed.') 241 | # We need a guarantee an empty file, otherwise Mosquitto crashes on load. 242 | if not os.path.exists(BridgeConfigPath): 243 | self._write_config_atomically(BridgeConfigPath, ""); 244 | self._global_broker_password = self.load_or_generate_mqtt_password() 245 | 246 | # Get to the actual registration 247 | if not quiet: 248 | logging.info('[InitBroker] Registering CCGX at VRM portal') 249 | with requests.Session() as session: 250 | headers = {'content-type': 'application/x-www-form-urlencoded', 'User-Agent': 'dbus-mqtt'} 251 | r = session.post( 252 | VrmApiServer + '/log/storemqttpassword.php', 253 | data=dict(identifier=self._global_broker_username, mqttPassword=self._global_broker_password), 254 | headers=headers, 255 | verify=CaBundlePath, 256 | timeout=(timeout,timeout)) 257 | if r.status_code == requests.codes.ok: 258 | vrm_portal_mode = get_setting('/Settings/Network/VrmPortal') 259 | 260 | config_rpc = "" 261 | config_dbus = "" 262 | 263 | if vrm_portal_mode == 2: 264 | config_rpc = BridgeSettingsRPC.format(self._system_id, 265 | self._global_broker_password, 266 | self._get_vrm_broker_url(), RpcBroker, CaBundlePath, 267 | self._global_broker_username) 268 | if vrm_portal_mode >= 1: 269 | config_dbus = BridgeSettingsDbus.format(self._system_id, 270 | self._global_broker_password, 271 | self._get_vrm_broker_url(), RpcBroker, CaBundlePath, 272 | self._global_broker_username) 273 | 274 | config = "# Generated by BridgeRegistrator. Any changes will be overwritten on service start.\n" 275 | config += config_rpc 276 | config += config_dbus 277 | # Do we need to adjust the settings file? 278 | changed = config != orig_config 279 | if changed: 280 | logging.info('[InitBroker] Writing new config file') 281 | self._write_config_atomically(BridgeConfigPath, config) 282 | else: 283 | logging.info('[InitBroker] Not updating the config file, because config is correct.') 284 | self._init_broker_timer = None 285 | logging.getLogger("requests").setLevel(self._requests_log_level) 286 | logging.info('[InitBroker] Registration successful') 287 | if changed: 288 | os._exit(100) 289 | return False 290 | if not quiet: 291 | logging.error('VRM registration failed. Http status was: {}'.format(r.status_code)) 292 | logging.error('Message was: {}'.format(r.text)) 293 | except: 294 | if not quiet: 295 | traceback.print_exc() 296 | # Notify the timer we want to be called again 297 | return True 298 | 299 | def get_password(self): 300 | assert self._global_broker_password is not None 301 | return self._global_broker_password 302 | 303 | def get_apikey(self): 304 | return self._global_broker_username 305 | 306 | 307 | def get_random_string(size=32): 308 | """Creates a random (hex) string which contains 'size' characters.""" 309 | return ''.join("{0:02x}".format(b) for b in open('/dev/urandom', 'rb').read(int(size/2))) 310 | 311 | def main(): 312 | from ve_utils import get_vrm_portal_id 313 | vrmid = get_vrm_portal_id() 314 | 315 | registrator = MosquittoBridgeRegistrator(vrmid) 316 | registrator.register() 317 | 318 | if __name__ == "__main__": 319 | main() 320 | 321 | # vim: noexpandtab:shiftwidth=4:tabstop=4:softtabstop=0 322 | -------------------------------------------------------------------------------- /settingsdevice.py: -------------------------------------------------------------------------------- 1 | import dbus 2 | import logging 3 | import time 4 | from functools import partial 5 | 6 | # Local imports 7 | from vedbus import VeDbusItemImport 8 | 9 | ## Indexes for the setting dictonary. 10 | PATH = 0 11 | VALUE = 1 12 | MINIMUM = 2 13 | MAXIMUM = 3 14 | SILENT = 4 15 | 16 | ## The Settings Device class. 17 | # Used by python programs, such as the vrm-logger, to read and write settings they 18 | # need to store on disk. And since these settings might be changed from a different 19 | # source, such as the GUI, the program can pass an eventCallback that will be called 20 | # as soon as some setting is changed. 21 | # 22 | # The settings are stored in flash via the com.victronenergy.settings service on dbus. 23 | # See https://github.com/victronenergy/localsettings for more info. 24 | # 25 | # If there are settings in de supportSettings list which are not yet on the dbus, 26 | # and therefore not yet in the xml file, they will be added through the dbus-addSetting 27 | # interface of com.victronenergy.settings. 28 | class SettingsDevice(object): 29 | ## The constructor processes the tree of dbus-items. 30 | # @param bus the system-dbus object 31 | # @param name the dbus-service-name of the settings dbus service, 'com.victronenergy.settings' 32 | # @param supportedSettings dictionary with all setting-names, and their defaultvalue, min, max and whether 33 | # the setting is silent. The 'silent' entry is optional. If set to true, no changes in the setting will 34 | # be logged by localsettings. 35 | # @param eventCallback function that will be called on changes on any of these settings 36 | # @param timeout Maximum interval to wait for localsettings. An exception is thrown at the end of the 37 | # interval if the localsettings D-Bus service has not appeared yet. 38 | def __init__(self, bus, supportedSettings, eventCallback, name='com.victronenergy.settings', timeout=0): 39 | logging.debug("===== Settings device init starting... =====") 40 | self._bus = bus 41 | self._dbus_name = name 42 | self._eventCallback = eventCallback 43 | self._values = {} # stored the values, used to pass the old value along on a setting change 44 | self._settings = {} 45 | 46 | count = 0 47 | while True: 48 | if 'com.victronenergy.settings' in self._bus.list_names(): 49 | break 50 | if count == timeout: 51 | raise Exception("The settings service com.victronenergy.settings does not exist!") 52 | count += 1 53 | logging.info('waiting for settings') 54 | time.sleep(1) 55 | 56 | # Add the items. 57 | self.addSettings(supportedSettings) 58 | 59 | logging.debug("===== Settings device init finished =====") 60 | 61 | def addSettings(self, settings): 62 | for setting, options in settings.items(): 63 | silent = len(options) > SILENT and options[SILENT] 64 | busitem = self.addSetting(options[PATH], options[VALUE], 65 | options[MINIMUM], options[MAXIMUM], silent, callback=partial(self.handleChangedSetting, setting)) 66 | self._settings[setting] = busitem 67 | self._values[setting] = busitem.get_value() 68 | 69 | def addSetting(self, path, value, _min, _max, silent=False, callback=None): 70 | busitem = VeDbusItemImport(self._bus, self._dbus_name, path, callback) 71 | if busitem.exists and (value, _min, _max, silent) == busitem._proxy.GetAttributes(): 72 | logging.debug("Setting %s found" % path) 73 | else: 74 | logging.info("Setting %s does not exist yet or must be adjusted" % path) 75 | 76 | # Prepare to add the setting. Most dbus types extend the python 77 | # type so it is only necessary to additionally test for Int64. 78 | if isinstance(value, (int, dbus.Int64)): 79 | itemType = 'i' 80 | elif isinstance(value, float): 81 | itemType = 'f' 82 | else: 83 | itemType = 's' 84 | 85 | # Add the setting 86 | # TODO, make an object that inherits VeDbusItemImport, and complete the D-Bus settingsitem interface 87 | settings_item = VeDbusItemImport(self._bus, self._dbus_name, '/Settings', createsignal=False) 88 | setting_path = path.replace('/Settings/', '', 1) 89 | if silent: 90 | settings_item._proxy.AddSilentSetting('', setting_path, value, itemType, _min, _max) 91 | else: 92 | settings_item._proxy.AddSetting('', setting_path, value, itemType, _min, _max) 93 | 94 | busitem = VeDbusItemImport(self._bus, self._dbus_name, path, callback) 95 | 96 | return busitem 97 | 98 | def handleChangedSetting(self, setting, servicename, path, changes): 99 | oldvalue = self._values[setting] if setting in self._values else None 100 | self._values[setting] = changes['Value'] 101 | 102 | if self._eventCallback is None: 103 | return 104 | 105 | self._eventCallback(setting, oldvalue, changes['Value']) 106 | 107 | def setDefault(self, path): 108 | item = VeDbusItemImport(self._bus, self._dbus_name, path, createsignal=False) 109 | item.set_default() 110 | 111 | def __getitem__(self, setting): 112 | return self._settings[setting].get_value() 113 | 114 | def __setitem__(self, setting, newvalue): 115 | result = self._settings[setting].set_value(newvalue) 116 | if result != 0: 117 | # Trying to make some false change to our own settings? How dumb! 118 | assert False 119 | -------------------------------------------------------------------------------- /test/fixture_vedbus.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from dbus.mainloop.glib import DBusGMainLoop 5 | import dbus 6 | import dbus.service 7 | import inspect 8 | import platform 9 | import pprint 10 | import sys 11 | import os 12 | 13 | # our own packages 14 | sys.path.insert(1, os.path.join(os.path.dirname(__file__), '../')) 15 | from gi.repository import GLib 16 | from vedbus import VeDbusItemExport 17 | 18 | # Dictionary containing all objects exported to dbus 19 | dbusObjects = {} 20 | 21 | def changerequest(path, newvalue): 22 | if newvalue < 100: 23 | return True 24 | else: 25 | return False 26 | 27 | def gettext(path, value): 28 | return 'gettexted %s %s' % (path, value) 29 | 30 | def main(argv): 31 | global dbusObjects 32 | 33 | # Have a mainloop, so we can send/receive asynchronous calls to and from dbus 34 | DBusGMainLoop(set_as_default=True) 35 | 36 | # Connect to session bus whenever present, else use the system bus 37 | dbusConn = dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus() 38 | 39 | # Register ourserves on the dbus as a service 40 | name = dbus.service.BusName("com.victronenergy.dbusexample", dbusConn) 41 | 42 | # Create the management objects, as specified in the ccgx dbus-api document 43 | 44 | # Keep a reference in the global dictionary. Without this they would be removed by 45 | # garbage collector again. 46 | dbusObjects['string'] = VeDbusItemExport(dbusConn, '/String', 'this is a string') 47 | dbusObjects['int'] = VeDbusItemExport(dbusConn, '/Int', 40000) 48 | dbusObjects['negativeInt'] = VeDbusItemExport(dbusConn, '/NegativeInt', -10) 49 | dbusObjects['float'] = VeDbusItemExport(dbusConn, '/Float', 1.5) 50 | dbusObjects['invalid'] = VeDbusItemExport(dbusConn, '/Invalid', None) 51 | dbusObjects['byte'] = VeDbusItemExport(dbusConn, '/Byte', dbus.Byte(84)) 52 | dbusObjects['writeable'] = VeDbusItemExport(dbusConn, '/Writeable', 'original', writeable=True) 53 | dbusObjects['not-writeable'] = VeDbusItemExport(dbusConn, '/NotWriteable', 'original', writeable=False) 54 | 55 | dbusObjects['not-writeable with cb'] = VeDbusItemExport(dbusConn, '/WriteableUpTo100', 56 | 'original', writeable=True, onchangecallback=changerequest) 57 | 58 | dbusObjects['gettextcallback'] = VeDbusItemExport(dbusConn, '/Gettextcallback', 59 | '10', gettextcallback=gettext, writeable=True) 60 | 61 | mainloop = GLib.MainLoop() 62 | print("up and running") 63 | sys.stdout.flush() 64 | 65 | mainloop.run() 66 | 67 | main("") 68 | -------------------------------------------------------------------------------- /test/mock_dbus_monitor.py: -------------------------------------------------------------------------------- 1 | import dbus 2 | from collections import defaultdict 3 | from functools import partial 4 | 5 | # Simulation a DbusMonitor object, without using the D-Bus (intended for unit tests). Instead of changes values 6 | # on the D-Bus you can use the set_value function. set_value will automatically expand the service list. Note 7 | # that all simulated D-Bus paths passed to set_value must be part of the dbusTree passed to the constructor of 8 | # the monitor. 9 | class MockDbusMonitor(object): 10 | def __init__(self, dbusTree, valueChangedCallback=None, deviceAddedCallback=None, 11 | deviceRemovedCallback=None, mountEventCallback=None, vebusDeviceInstance0=False, checkPaths=True): 12 | self._services = {} 13 | self._tree = {} 14 | self._seen = defaultdict(set) 15 | self._watches = defaultdict(dict) 16 | self._checkPaths = checkPaths 17 | self._value_changed_callback = valueChangedCallback 18 | self._device_removed_callback = deviceRemovedCallback 19 | self._device_added_callback = deviceAddedCallback 20 | for s, sv in dbusTree.items(): 21 | service = self._tree.setdefault(s, set()) 22 | service.update(['/Connected', '/ProductName', '/Mgmt/Connection', '/DeviceInstance']) 23 | for p in sv: 24 | service.add(p) 25 | 26 | # Gets the value for a certain servicename and path, returns the default_value when 27 | # request service and objectPath combination does not not exists or when it is invalid 28 | def get_value(self, serviceName, objectPath, default_value=None): 29 | item = self._get_item(serviceName, objectPath) 30 | if item is None: 31 | return default_value 32 | r = item.get_value() 33 | return default_value if r is None else r 34 | 35 | def _get_item(self, serviceName, objectPath): 36 | service = self._services.get(serviceName) 37 | if service is None: 38 | return None 39 | if objectPath not in self._tree[_class_name(serviceName)]: 40 | return None 41 | item = service.get(objectPath) 42 | if item is None: 43 | item = MockImportItem(None, valid=False) 44 | service[objectPath] = item 45 | return item 46 | 47 | def exists(self, serviceName, objectPath): 48 | if serviceName not in self._services: 49 | return False 50 | if objectPath not in self._tree[_class_name(serviceName)]: 51 | return False 52 | return True 53 | 54 | def set_seen(self, serviceName, path): 55 | self._seen[serviceName].add(path) 56 | 57 | def seen(self, serviceName, objectPath): 58 | return objectPath in self._seen[serviceName] 59 | 60 | # returns a dictionary, keys are the servicenames, value the instances 61 | # optionally use the classfilter to get only a certain type of services, for 62 | # example com.victronenergy.battery. 63 | def get_service_list(self, classfilter=None): 64 | r = {} 65 | for servicename,items in self._services.items(): 66 | if not classfilter or _class_name(servicename) == classfilter: 67 | item = items.get('/DeviceInstance') 68 | r[servicename] = None if item is None else item.get_value() 69 | return r 70 | 71 | def add_value(self, service, path, value): 72 | class_name = _class_name(service) 73 | s = self._tree.get(class_name, None) 74 | if s is None: 75 | raise Exception('service not found') 76 | if self._checkPaths and path not in s: 77 | raise Exception('Path not found: {}{} (check dbusTree passed to __init__)'.format(service, path)) 78 | s = self._services.setdefault(service, {}) 79 | s[path] = MockImportItem(value) 80 | self.set_seen(service, path) 81 | 82 | def set_value(self, serviceName, objectPath, value): 83 | item = self._get_item(serviceName, objectPath) 84 | if item is None: 85 | return -1 86 | item.set_value(value) 87 | self.set_seen(serviceName, objectPath) 88 | if self._value_changed_callback != None: 89 | self._value_changed_callback(serviceName, objectPath, None, {'Value': value, 'Text': str(value)}, None) 90 | if serviceName in self._watches: 91 | if objectPath in self._watches[serviceName]: 92 | self._watches[serviceName][objectPath]({'Value': value, 'Text': str(value)}) 93 | elif None in self._watches[serviceName]: 94 | self._watches[serviceName][None]({'Value': value, 'Text': str(value)}) 95 | return 0 96 | 97 | def set_value_async(self, serviceName, objectPath, value, 98 | reply_handler=None, error_handler=None): 99 | item = self._get_item(serviceName, objectPath) 100 | 101 | if item is not None and item.exists: 102 | item.set_value(value) 103 | if reply_handler is not None: 104 | reply_handler(0) 105 | return 106 | 107 | if error_handler is not None: 108 | error_handler(TypeError('Service or path not found, ' 109 | 'service=%s, path=%s' % (serviceName, objectPath))) 110 | 111 | def add_service(self, service, values): 112 | if service in self._services: 113 | raise Exception('Service already exists: {}'.format(service)) 114 | self._services[service] = {} 115 | for k,v in values.items(): 116 | self.add_value(service, k, v) 117 | if self._device_added_callback != None: 118 | self._device_added_callback(service, values.get('/DeviceInstance', 0)) 119 | 120 | def remove_service(self, service): 121 | s = self._services.get(service) 122 | if s is None: 123 | return 124 | item = s.get('/DeviceInstance') 125 | instance = 0 if item is None else item.get_value() 126 | for item in s.values(): 127 | item.set_service_exists(False) 128 | self._services.pop(service) 129 | if self._device_removed_callback != None: 130 | self._device_removed_callback(service, instance) 131 | if service in self._watches: 132 | del self._watches[service] 133 | 134 | def track_value(self, serviceName, objectPath, callback, *args, **kwargs): 135 | self._watches[serviceName][objectPath] = partial(callback, *args, **kwargs) 136 | 137 | @property 138 | def dbusConn(self): 139 | raise dbus.DBusException("No Connection") 140 | 141 | 142 | class MockImportItem(object): 143 | def __init__(self, value, valid=True, service_exists=True): 144 | self._value = value 145 | self._valid = valid 146 | self._service_exists = service_exists 147 | 148 | def set_service_exists(self, service_exists): 149 | self._service_exists = service_exists 150 | 151 | def get_value(self): 152 | return self._value 153 | 154 | @property 155 | def exists(self): 156 | return self._valid 157 | 158 | def set_value(self, value): 159 | if not self._service_exists: 160 | raise dbus.exceptions.DBusException('org.freedesktop.DBus.Error.ServiceUnknown') 161 | if not self._valid: 162 | raise dbus.exceptions.DBusException('org.freedesktop.DBus.Error.UnknownObject') 163 | self._value = value 164 | 165 | 166 | def _class_name(service): 167 | return '.'.join(service.split('.')[:3]) 168 | -------------------------------------------------------------------------------- /test/mock_dbus_service.py: -------------------------------------------------------------------------------- 1 | # Simulates the busService object without using the D-Bus (intended for unit tests). Data usually stored in 2 | # D-Bus items is now stored in memory. 3 | class MockDbusService(object): 4 | def __init__(self, servicename): 5 | self._dbusobjects = {} 6 | self._callbacks = {} 7 | self._service_name = servicename 8 | 9 | def add_path(self, path, value, description="", writeable=False, onchangecallback=None, 10 | gettextcallback=None, itemtype=None): 11 | self._dbusobjects[path] = value 12 | if onchangecallback is not None: 13 | self._callbacks[path] = onchangecallback 14 | 15 | def register(self): 16 | # Nothing to do when mocking 17 | pass 18 | 19 | # Add the mandatory paths, as per victron dbus api doc 20 | def add_mandatory_paths(self, processname, processversion, connection, 21 | deviceinstance, productid, productname, firmwareversion, hardwareversion, connected): 22 | self.add_path('/Management/ProcessName', processname) 23 | self.add_path('/Management/ProcessVersion', processversion) 24 | self.add_path('/Management/Connection', connection) 25 | 26 | # Create rest of the mandatory objects 27 | self.add_path('/DeviceInstance', deviceinstance) 28 | self.add_path('/ProductId', productid) 29 | self.add_path('/ProductName', productname) 30 | self.add_path('/FirmwareVersion', firmwareversion) 31 | self.add_path('/HardwareVersion', hardwareversion) 32 | self.add_path('/Connected', connected) 33 | 34 | # Simulates a SetValue from the D-Bus, if avaible the onchangecallback associated with the path will 35 | # be called before the data is changed. 36 | def set_value(self, path, newvalue): 37 | callback = self._callbacks.get(path) 38 | if callback is None or callback(path, newvalue): 39 | self._dbusobjects[path] = newvalue 40 | 41 | def __getitem__(self, path): 42 | return self._dbusobjects[path] 43 | 44 | def __setitem__(self, path, newvalue): 45 | if path not in self._dbusobjects: 46 | raise Exception('Path not registered in service: {}{} (use add_path to register)'.\ 47 | format(self._service_name, path)) 48 | self._dbusobjects[path] = newvalue 49 | 50 | def __delitem__(self, path): 51 | del self._dbusobjects[path] 52 | 53 | def __contains__(self, path): 54 | return path in self._dbusobjects 55 | 56 | def __enter__(self): 57 | # No batching done in mock object, and we already 58 | # support the required dict interface. 59 | return self 60 | 61 | def __exit__(self, *exc): 62 | pass 63 | -------------------------------------------------------------------------------- /test/mock_gobject.py: -------------------------------------------------------------------------------- 1 | # This module contains mock functions for some of the functionality in gobject. 2 | # You can use this to create unit tests on code using gobject timers without having to wait for those timer. 3 | # Use the patch functions to replace the original gobject functions. The timer_manager object defined here 4 | # allows you to set a virtual time stamp, which will invoke all timers that would normally run in the 5 | # specified interval. 6 | 7 | from datetime import datetime as dt 8 | import time 9 | 10 | class MockTimer(object): 11 | def __init__(self, start, timeout, callback, *args, **kwargs): 12 | self._timeout = timeout 13 | self._next = start + timeout 14 | self._callback = callback 15 | self._args = args 16 | self._kwargs = kwargs 17 | 18 | def run(self): 19 | self._next += self._timeout 20 | return self._callback(*self._args, **self._kwargs) 21 | 22 | @property 23 | def next(self): 24 | return self._next 25 | 26 | 27 | class MockTimerManager(object): 28 | def __init__(self, start_time=None): 29 | self._resources = [] 30 | self._time = 0 31 | self._id = 0 32 | self._timestamp = start_time or time.time() 33 | 34 | def add_timer(self, timeout, callback, *args, **kwargs): 35 | return self._add_resource(MockTimer(self._time, timeout, callback, *args, **kwargs)) 36 | 37 | def add_idle(self, callback, *args, **kwargs): 38 | return self.add_timer(self._time, callback, *args, **kwargs) 39 | 40 | def remove_resouce(self, id): 41 | for rid, rr in self._resources: 42 | if rid == id: 43 | self._resources.remove((rid, rr)) 44 | return 45 | raise Exception('Resource not found: {}'.format(id)) 46 | 47 | def _add_resource(self, resource): 48 | self._id += 1 49 | self._resources.append((self._id, resource)) 50 | return self._id 51 | 52 | def _terminate(self): 53 | raise StopIteration() 54 | 55 | @property 56 | def time(self): 57 | return self._time 58 | 59 | @property 60 | def datetime(self): 61 | return dt.fromtimestamp(self._timestamp + self._time / 1000.0) 62 | 63 | def run(self, interval=None): 64 | ''' 65 | Simulate the given interval. Starting from the current (mock) time until time + interval, all timers 66 | will be triggered. The timers will be triggered in chronological order. Timer removal (calling 67 | source_remove or a False/None return value) and addition within the callback function is supported. 68 | If interval is None or not supplied, the function will run until there are no timers left. 69 | ''' 70 | if interval != None: 71 | self.add_timer(interval, self._terminate) 72 | try: 73 | while True: 74 | next_timer = None 75 | next_id = None 76 | for id,t in self._resources: 77 | if next_timer == None or t.next < next_timer.next: 78 | next_timer = t 79 | next_id = id 80 | if next_timer == None: 81 | return 82 | self._time = next_timer.next 83 | if not next_timer.run(): 84 | self._resources.remove((next_id, next_timer)) 85 | except StopIteration: 86 | self._resources.remove((next_id, next_timer)) 87 | pass 88 | 89 | def reset(self): 90 | self._resources = [] 91 | self._time = 0 92 | 93 | 94 | timer_manager = MockTimerManager() 95 | 96 | 97 | def idle_add(callback, *args, **kwargs): 98 | return timer_manager.add_idle(callback, *args, **kwargs) 99 | 100 | 101 | def timeout_add(timeout, callback, *args, **kwargs): 102 | return timer_manager.add_timer(timeout, callback, *args, **kwargs) 103 | 104 | 105 | def timeout_add_seconds(timeout, callback, *args, **kwargs): 106 | return timeout_add(timeout * 1000, callback, *args, **kwargs) 107 | 108 | 109 | class datetime(object): 110 | @staticmethod 111 | def now(): 112 | return timer_manager.datetime 113 | 114 | @staticmethod 115 | def strptime(*args, **kwargs): 116 | return dt.strptime(*args, **kwargs) 117 | 118 | 119 | def source_remove(id): 120 | timer_manager.remove_resouce(id) 121 | 122 | 123 | def test_function(m, name): 124 | print(m.time, m.datetime, name) 125 | return True 126 | 127 | 128 | def patch_gobject(dest): 129 | ''' 130 | Use this function to replace the original gobject/GLib functions with the 131 | mocked versions in this file. Suppose your source files being tested uses 132 | 'from gi.repository import GLib' and the unit test uses 'import tested' you 133 | should call path(tested.GLib). 134 | ''' 135 | dest.timeout_add = timeout_add 136 | dest.timeout_add_seconds = timeout_add_seconds 137 | dest.idle_add = idle_add 138 | dest.source_remove = source_remove 139 | 140 | 141 | def patch_datetime(dest): 142 | dest.datetime = datetime 143 | 144 | 145 | if __name__ == '__main__': 146 | m = MockTimerManager() 147 | id1 = m.add_timer(100, test_function, m, 'F1') 148 | id2 = m.add_timer(30, test_function, m, 'F2') 149 | m.run(5000) 150 | m.remove_resouce(id1) 151 | m.run(2000) 152 | m.remove_resouce(id2) 153 | m.run(2000) 154 | -------------------------------------------------------------------------------- /test/mock_settings_device.py: -------------------------------------------------------------------------------- 1 | PATH = 0 2 | VALUE = 1 3 | MINIMUM = 2 4 | MAXIMUM = 3 5 | SILENT = 4 6 | 7 | class MockSettingsItem(object): 8 | def __init__(self, parent, path): 9 | self._parent = parent 10 | self.path = path 11 | 12 | def get_value(self): 13 | setting = 'addSetting'+self.path 14 | if setting in self._parent._settings: 15 | return self._parent[setting] 16 | return None 17 | 18 | def set_value(self, value): 19 | self._parent['addSetting'+self.path] = value 20 | 21 | @property 22 | def exists(self): 23 | return 'addSetting'+self.path in self._parent._settings 24 | 25 | # Simulates the SettingsSevice object without using the D-Bus (intended for unit tests). Values passed to 26 | # __setitem__ (or the [] operator) will be stored in memory for later retrieval by __getitem__. 27 | class MockSettingsDevice(object): 28 | def __init__(self, supported_settings, event_callback, name='com.victronenergy.settings', timeout=0): 29 | self._dbus_name = name 30 | self._settings = supported_settings 31 | self._event_callback = event_callback 32 | self._settings_items = {} 33 | 34 | def addSetting(self, path, value, _min, _max, silent=False, callback=None): 35 | # Persist in our settings stash so the settings is available through 36 | # the mock item 37 | self._settings['addSetting'+path] = [path, value, _min, _max, silent] 38 | return MockSettingsItem(self, path) 39 | 40 | def addSettings(self, settings): 41 | for setting, options in settings.items(): 42 | silent = len(options) > SILENT and options[SILENT] 43 | self._settings[setting] = [options[PATH], options[VALUE], options[MINIMUM], options[MAXIMUM], silent, None] 44 | 45 | def get_short_name(self, path): 46 | for k,v in self._settings.items(): 47 | if v[PATH] == path: 48 | return k 49 | return None 50 | 51 | def __getitem__(self, setting): 52 | return self._settings[setting][VALUE] 53 | 54 | def __setitem__(self, setting, new_value): 55 | s = self._settings.get(setting, None) 56 | if s is None: 57 | raise Exception('setting not found') 58 | old_value = s[VALUE] 59 | if old_value == new_value: 60 | return 61 | s[VALUE] = new_value 62 | if self._event_callback is not None: 63 | self._event_callback(setting, old_value, new_value) 64 | -------------------------------------------------------------------------------- /test/test_settingsdevice.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # Python 5 | import logging 6 | import os 7 | import sqlite3 8 | import sys 9 | import unittest 10 | import subprocess 11 | import time 12 | import dbus 13 | import threading 14 | import fcntl 15 | from dbus.mainloop.glib import DBusGMainLoop 16 | 17 | # Local 18 | sys.path.insert(1, os.path.join(os.path.dirname(__file__), '../')) 19 | from settingsdevice import SettingsDevice 20 | 21 | logger = logging.getLogger(__file__) 22 | 23 | class CreateSettingsTest(unittest.TestCase): 24 | # The actual code calling VeDbusItemExport is in fixture_vedbus.py, which is ran as a subprocess. That 25 | # code exports several values to the dbus. And then below test cases check if the exported values are 26 | # what the should be, by using the bare dbus import objects and functions. 27 | 28 | def setUp(self): 29 | pass 30 | 31 | def tearDown(self): 32 | pass 33 | 34 | def test_adding_new_settings(self): 35 | # to make sure that we make new settings, put something random in its name: 36 | rnd = os.urandom(16).encode('hex') 37 | 38 | # ofcourse below could be simplified, for now just use all settings from the example: 39 | settings = SettingsDevice( 40 | bus=dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus(), 41 | supportedSettings={ 42 | 'loggingenabled': ['/Settings/' + rnd + '/Logscript/Enabled', 1, 0, 1], 43 | 'proxyaddress': ['/Settings/' + rnd + '/Logscript/Http/Proxy', '', 0, 0], 44 | 'proxyport': ['/Settings/' + rnd + '/Logscript/Http/ProxyPort', '', 0, 0], 45 | 'backlogenabled': ['/Settings/' + rnd + '/Logscript/LogFlash/Enabled', 1, 0, 1], 46 | 'backlogpath': ['/Settings/' + rnd + '/Logscript/LogFlash/Path', '', 0, 0], # When empty, default path will be used. 47 | 'interval': ['/Settings/' + rnd + '/Logscript/LogInterval', 900, 0, 0], 48 | 'url': ['/Settings/' + rnd + '/Logscript/Url', '', 0, 0] # When empty, the default url will be used. 49 | }, 50 | eventCallback=self.handle_changed_setting) 51 | 52 | """ 53 | self.assertIs(type(v), dbus.Double) 54 | self.assertEqual(self.dbusConn.get_object('com.victronenergy.dbusexample', '/Float').GetText(), '1.5') 55 | """ 56 | 57 | def handle_changed_setting(setting, oldvalue, newvalue): 58 | pass 59 | 60 | if __name__ == "__main__": 61 | logging.basicConfig(stream=sys.stderr) 62 | logging.getLogger('').setLevel(logging.WARNING) 63 | DBusGMainLoop(set_as_default=True) 64 | unittest.main() 65 | -------------------------------------------------------------------------------- /test/test_vedbus.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # Python 5 | import logging 6 | import os 7 | import sys 8 | import unittest 9 | import subprocess 10 | import time 11 | import dbus 12 | import threading 13 | import fcntl 14 | from dbus.mainloop.glib import DBusGMainLoop 15 | 16 | # Local 17 | sys.path.insert(1, os.path.join(os.path.dirname(__file__), '../')) 18 | from vedbus import VeDbusService, VeDbusItemImport 19 | 20 | logger = logging.getLogger(__file__) 21 | """ 22 | class VeDbusServiceTests(unittest.TestCase): 23 | def incrementcallback(self, path, value): 24 | self.calledback += 1 25 | return True if value < 50 else False 26 | 27 | def setUp(self): 28 | self.calledback = 0 29 | 30 | 31 | self.service = VeDbusService('com.victronenergy.testservice') 32 | self.service.add_path(path='/Int', value=10, description="int", writeable=True, 33 | onchangecallback=self.incrementcallback, gettextcallback=None) 34 | 35 | self.thread = threading.Thread(target=self.mainloop.run) 36 | self.thread.start() 37 | 38 | def test_callback(self): 39 | a = subprocess.check_output('dbus', '-y com.victronenergy.testservice') 40 | print(a) 41 | 42 | def tearDown(self): 43 | self.thread.kill() 44 | self.thread = None 45 | """ 46 | 47 | 48 | class VeDbusItemExportTests(unittest.TestCase): 49 | # The actual code calling VeDbusItemExport is in fixture_vedbus.py, which is ran as a subprocess. That 50 | # code exports several values to the dbus. And then below test cases check if the exported values are 51 | # what the should be, by using the bare dbus import objects and functions. 52 | 53 | def setUp(self): 54 | self.sp = subprocess.Popen([sys.executable, "fixture_vedbus.py"], stdout=subprocess.PIPE) 55 | self.dbusConn = dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus() 56 | 57 | # Wait for fixture to be up and running. 'b' prefix is for python3, 58 | # it works in both python versions. 59 | while (self.sp.stdout.readline().rstrip() != b'up and running'): 60 | pass 61 | 62 | def tearDown(self): 63 | self.sp.kill() 64 | self.sp.wait() 65 | self.sp.stdout.close() 66 | 67 | def test_get_value_invalid(self): 68 | v = self.dbusConn.get_object('com.victronenergy.dbusexample', '/Invalid').GetValue() 69 | self.assertEqual(v, dbus.Array([], signature=dbus.Signature('i'), variant_level=1)) 70 | self.assertIs(type(v), dbus.Array) 71 | self.assertEqual(self.dbusConn.get_object('com.victronenergy.dbusexample', '/Invalid').GetText(), '---') 72 | 73 | def test_get_value_string(self): 74 | v = self.dbusConn.get_object('com.victronenergy.dbusexample', '/String').GetValue() 75 | self.assertEqual(v, 'this is a string') 76 | self.assertIs(type(v), dbus.String) 77 | self.assertEqual(self.dbusConn.get_object('com.victronenergy.dbusexample', '/String').GetText(), 'this is a string') 78 | 79 | def test_get_value_int(self): 80 | v = self.dbusConn.get_object('com.victronenergy.dbusexample', '/Int').GetValue() 81 | self.assertEqual(v, 40000) 82 | self.assertIs(type(v), dbus.Int32) 83 | self.assertEqual(self.dbusConn.get_object('com.victronenergy.dbusexample', '/Int').GetText(), '40000') 84 | 85 | def test_get_value_negativeint(self): 86 | v = self.dbusConn.get_object('com.victronenergy.dbusexample', '/NegativeInt').GetValue() 87 | self.assertEqual(v, -10) 88 | self.assertIs(type(v), dbus.Int32) 89 | self.assertEqual(self.dbusConn.get_object('com.victronenergy.dbusexample', '/NegativeInt').GetText(), '-10') 90 | 91 | def test_get_value_float(self): 92 | v = self.dbusConn.get_object('com.victronenergy.dbusexample', '/Float').GetValue() 93 | self.assertEqual(v, 1.5) 94 | self.assertIs(type(v), dbus.Double) 95 | self.assertEqual(self.dbusConn.get_object('com.victronenergy.dbusexample', '/Float').GetText(), '1.5') 96 | 97 | def test_get_text_byte(self): 98 | v = self.dbusConn.get_object('com.victronenergy.dbusexample', '/Byte').GetText() 99 | self.assertEqual('84', v) 100 | 101 | def test_get_value_byte(self): 102 | v = self.dbusConn.get_object('com.victronenergy.dbusexample', '/Byte').GetValue() 103 | self.assertEqual(84, v) 104 | 105 | def test_set_value(self): 106 | self.assertNotEqual(0, self.dbusConn.get_object('com.victronenergy.dbusexample', '/NotWriteable').SetValue(12)) 107 | self.assertEqual('original', self.dbusConn.get_object('com.victronenergy.dbusexample', '/NotWriteable').GetValue()) 108 | 109 | self.assertEqual(0, self.dbusConn.get_object('com.victronenergy.dbusexample', '/Writeable').SetValue(12)) 110 | self.assertEqual(12, self.dbusConn.get_object('com.victronenergy.dbusexample', '/Writeable').GetValue()) 111 | 112 | self.assertNotEqual(0, self.dbusConn.get_object('com.victronenergy.dbusexample', '/WriteableUpTo100').SetValue(102)) 113 | self.assertEqual('original', self.dbusConn.get_object('com.victronenergy.dbusexample', '/WriteableUpTo100').GetValue()) 114 | 115 | self.assertEqual(0, self.dbusConn.get_object('com.victronenergy.dbusexample', '/WriteableUpTo100').SetValue(50)) 116 | self.assertEqual(50, self.dbusConn.get_object('com.victronenergy.dbusexample', '/WriteableUpTo100').GetValue()) 117 | 118 | def test_gettextcallback(self): 119 | self.assertEqual('gettexted /Gettextcallback 10', self.dbusConn.get_object('com.victronenergy.dbusexample', '/Gettextcallback').GetText()) 120 | 121 | def waitandkill(self, seconds=5): 122 | time.sleep(seconds) 123 | self.process.kill() 124 | self.process.wait() 125 | 126 | def test_changedsignal(self): 127 | self.process = subprocess.Popen(['dbus-monitor', "type='signal',sender='com.victronenergy.dbusexample',interface='com.victronenergy.BusItem'"], stdout=subprocess.PIPE) 128 | 129 | #wait for dbus-monitor to start up 130 | time.sleep(0.5) 131 | 132 | #set timeout 133 | thread = threading.Thread(target=self.waitandkill) 134 | thread.start() 135 | 136 | self.dbusConn.get_object('com.victronenergy.dbusexample', '/Gettextcallback').SetValue(60) 137 | 138 | fcntl.fcntl(self.process.stdout.fileno(), fcntl.F_SETFL, os.O_NONBLOCK) 139 | 140 | time.sleep(0.5) 141 | 142 | t = bytes() 143 | while self.process.returncode is None: 144 | try: 145 | t += self.process.stdout.readline() 146 | except IOError: 147 | break 148 | self.process.stdout.close() 149 | 150 | text = b" dict entry(\n" 151 | text += b" string \"Text\"\n" 152 | text += b" variant string \"gettexted /Gettextcallback 60\"\n" 153 | text += b" )\n" 154 | 155 | value = b" dict entry(\n" 156 | value += b" string \"Value\"\n" 157 | value += b" variant int32 60\n" 158 | value += b" )\n" 159 | 160 | self.assertNotEqual(-1, t.find(text)) 161 | self.assertNotEqual(-1, t.find(value)) 162 | 163 | thread.join() 164 | 165 | """ 166 | MVA 2014-08-30: this test of VEDbusItemImport doesn't work, since there is no gobject-mainloop. 167 | Probably making some automated functional test, using bash and some scripts, will work much 168 | simpler and better 169 | class VeDbusItemImportTests(unittest.TestCase): 170 | # VeDbusItemImport class is tested against dbus objects exported by fixture_vedbus.py, which is ran as a 171 | # subprocess. 172 | 173 | def setUp(self): 174 | self.sp = subprocess.Popen([sys.executable, "fixture_vedbus.py"], stdout=subprocess.PIPE) 175 | self.dbusConn = dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus() 176 | 177 | #wait for fixture to be up and running 178 | while (self.sp.stdout.readline().rstrip() != 'up and running'): 179 | pass 180 | 181 | def tearDown(self): 182 | self.sp.kill() 183 | self.sp.wait() 184 | 185 | def test_get_invalid(self): 186 | self.assertIs(None, VeDbusItemImport(self.dbusConn, 'com.victronenergy.dbusexample', '/Invalid').get_value()) 187 | self.assertEqual('---', VeDbusItemImport(self.dbusConn, 'com.victronenergy.dbusexample', '/Invalid').get_text()) 188 | 189 | def test_get_string(self): 190 | v = VeDbusItemImport(self.dbusConn, 'com.victronenergy.dbusexample', '/String') 191 | self.assertEqual('this is a string', v.get_value()) 192 | self.assertIs(dbus.String, type(v.get_value())) 193 | self.assertEqual('this is a string', v.get_text()) 194 | 195 | def test_get_int(self): 196 | v = VeDbusItemImport(self.dbusConn, 'com.victronenergy.dbusexample', '/Int') 197 | self.assertEqual(40000, v.get_value()) 198 | self.assertIs(dbus.Int32, type(v.get_value())) 199 | self.assertEqual('40000', v.get_text()) 200 | 201 | def test_get_byte(self): 202 | v = VeDbusItemImport(self.dbusConn, 'com.victronenergy.dbusexample', '/Byte') 203 | self.assertEqual(84, v.get_value()) 204 | self.assertEqual('84', v.get_text()) 205 | 206 | def test_set_value(self): 207 | nw = VeDbusItemImport(self.dbusConn, 'com.victronenergy.dbusexample', '/NotWriteable') 208 | wr = VeDbusItemImport(self.dbusConn, 'com.victronenergy.dbusexample', '/Writeable') 209 | wc = VeDbusItemImport(self.dbusConn, 'com.victronenergy.dbusexample', '/WriteableUpTo100') 210 | 211 | self.assertNotEqual(0, nw.set_value(12)) 212 | self.assertEqual('original', nw.get_value()) 213 | 214 | self.assertEqual(0, wr.set_value(12)) 215 | self.assertEqual(12, wr.get_value()) 216 | 217 | self.assertNotEqual(0, wc.set_value(102)) 218 | self.assertEqual('original', wc.get_value()) 219 | 220 | self.assertEqual(0, wc.set_value(50)) 221 | self.assertEqual(50, wc.get_value()) 222 | """ 223 | 224 | if __name__ == "__main__": 225 | logging.basicConfig(stream=sys.stderr) 226 | logging.getLogger('').setLevel(logging.WARNING) 227 | unittest.main() 228 | -------------------------------------------------------------------------------- /tools/dbus_signal_cntr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from dbus.mainloop.glib import DBusGMainLoop 5 | from gi.repository import GObject as gobject 6 | import dbus 7 | import dbus.service 8 | from pprint import pprint 9 | import os 10 | import signal 11 | from time import time 12 | 13 | items = {} 14 | total = 0 15 | t_started = time() 16 | 17 | class DbusTracker(object): 18 | def __init__(self): 19 | 20 | self.items = {} 21 | 22 | # For a PC, connect to the SessionBus, otherwise (Venus device) connect to the systembus 23 | self.dbusConn = dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus() 24 | 25 | # subscribe to all signals 26 | self.dbusConn.add_signal_receiver(self._signal_receive_handler, 27 | sender_keyword='sender', 28 | path_keyword='path') 29 | 30 | names = self.dbusConn.list_names() 31 | for name in names: 32 | if name.startswith(":"): 33 | continue 34 | 35 | items[str(self.dbusConn.get_name_owner(name))] = {"_total": 0, "_name": str(name)} 36 | 37 | 38 | def _signal_receive_handler(*args, **kwargs): 39 | global total 40 | total = total + 1 41 | 42 | sender = str(kwargs['sender']) 43 | path = str(kwargs['path']) 44 | 45 | d = items.get(sender) 46 | if d is None: 47 | items[sender] = {"_total": 1, path: 1} 48 | return 49 | 50 | d["_total"] = d["_total"] + 1 51 | 52 | p = d.get(path) 53 | if p is None: 54 | d[path] = 1 55 | return 56 | 57 | d[path] = p + 1 58 | 59 | 60 | def printall(): 61 | t_elapsed = time() - t_started 62 | 63 | print(chr(27) + "[2J" + chr(27) + "[;H") 64 | 65 | row_format = "{:<60} {:>4} {:>4}% {:>4.2f} / s" 66 | 67 | print(row_format.format("Total", total, 100, total / t_elapsed)) 68 | 69 | for service, values in items.items(): 70 | # skip the services that didn't emit any signals 71 | if len(values) == 2 and "_name" in values: 72 | continue 73 | 74 | print(row_format.format(values.get("_name", service), values["_total"], values["_total"] * 100 / total, values["_total"] / t_elapsed)) 75 | 76 | # uncomment this to see all the paths as well. 77 | # print("--------------") 78 | # pprint(items) 79 | return True 80 | 81 | 82 | def main(): 83 | DBusGMainLoop(set_as_default=True) 84 | 85 | d = DbusTracker() 86 | 87 | gobject.timeout_add(2000, printall) 88 | 89 | mainloop = gobject.MainLoop() 90 | mainloop.run() 91 | 92 | 93 | if __name__ == "__main__": 94 | main() 95 | -------------------------------------------------------------------------------- /tracing.py: -------------------------------------------------------------------------------- 1 | ## IMPORTANT NOTE - MVA 2015-2-5 2 | # This file is deprecated. Use the standard logging package of Python instead 3 | 4 | ## @package tracing 5 | # The tracing module for debug-purpose. 6 | 7 | log = None 8 | 9 | ## Setup the debug traces. 10 | # The traces can be logged to console and/or file. 11 | # When logged to file a logrotate is used. 12 | # @param enabled When True traces are enabled. 13 | # @param path The path for the trace-file. 14 | # @param fileName The trace-file-name. 15 | # @param toConsole When True show traces to console. 16 | # @param debugOn When True show debug-traces. 17 | def setupTraces(enabled, path, fileName, toConsole, toFile, debugOn): 18 | global log 19 | 20 | if enabled: 21 | import logging 22 | import logging.handlers 23 | 24 | log = logging.getLogger(fileName) 25 | if debugOn == True: 26 | level = logging.DEBUG 27 | else: 28 | level = logging.INFO 29 | log.setLevel(level) 30 | log.disabled = not enabled 31 | if toConsole == True: 32 | sth = logging.StreamHandler() 33 | fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s') 34 | sth.setFormatter(fmt) 35 | sth.setLevel(level) 36 | log.addHandler(sth) 37 | if toFile == True: 38 | fd = logging.handlers.RotatingFileHandler(path + fileName, maxBytes=1048576, backupCount=5) 39 | fmt = logging.Formatter('%(asctime)s %(levelname)s %(message)s') 40 | fd.setFormatter(fmt) 41 | fd.setLevel(level) 42 | log.addHandler(fd) 43 | else: 44 | log = LogDummy() 45 | 46 | class LogDummy(object): 47 | def __init__(self): 48 | self._str = '' 49 | 50 | def info(self, str, *args): 51 | self._str = str 52 | 53 | def debug(self, str, *args): 54 | self._str = str 55 | 56 | def warning(self, str, *args): 57 | print("Warning: " + (str % args)) 58 | 59 | def error(self, str, *args): 60 | print("Error: " + (str % args)) 61 | -------------------------------------------------------------------------------- /ve_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | from traceback import print_exc 5 | from os import _exit as os_exit 6 | from os import statvfs 7 | from subprocess import check_output, CalledProcessError 8 | import logging 9 | import dbus 10 | logger = logging.getLogger(__name__) 11 | 12 | VEDBUS_INVALID = dbus.Array([], signature=dbus.Signature('i'), variant_level=1) 13 | 14 | class NoVrmPortalIdError(Exception): 15 | pass 16 | 17 | # Use this function to make sure the code quits on an unexpected exception. Make sure to use it 18 | # when using GLib.idle_add and also GLib.timeout_add. 19 | # Without this, the code will just keep running, since GLib does not stop the mainloop on an 20 | # exception. 21 | # Example: GLib.idle_add(exit_on_error, myfunc, arg1, arg2) 22 | def exit_on_error(func, *args, **kwargs): 23 | try: 24 | return func(*args, **kwargs) 25 | except: 26 | try: 27 | print ('exit_on_error: there was an exception. Printing stacktrace will be tried and then exit') 28 | print_exc() 29 | except: 30 | pass 31 | 32 | # sys.exit() is not used, since that throws an exception, which does not lead to a program 33 | # halt when used in a dbus callback, see connection.py in the Python/Dbus libraries, line 230. 34 | os_exit(1) 35 | 36 | 37 | __vrm_portal_id = None 38 | def get_vrm_portal_id(): 39 | # The original definition of the VRM Portal ID is that it is the mac 40 | # address of the onboard- ethernet port (eth0), stripped from its colons 41 | # (:) and lower case. This may however differ between platforms. On Venus 42 | # the task is therefore deferred to /sbin/get-unique-id so that a 43 | # platform specific method can be easily defined. 44 | # 45 | # If /sbin/get-unique-id does not exist, then use the ethernet address 46 | # of eth0. This also handles the case where velib_python is used as a 47 | # package install on a Raspberry Pi. 48 | # 49 | # On a Linux host where the network interface may not be eth0, you can set 50 | # the VRM_IFACE environment variable to the correct name. 51 | 52 | global __vrm_portal_id 53 | 54 | if __vrm_portal_id: 55 | return __vrm_portal_id 56 | 57 | portal_id = None 58 | 59 | # First try the method that works if we don't have a data partition. This 60 | # will fail when the current user is not root. 61 | try: 62 | portal_id = check_output("/sbin/get-unique-id").decode("utf-8", "ignore").strip() 63 | if not portal_id: 64 | raise NoVrmPortalIdError("get-unique-id returned blank") 65 | __vrm_portal_id = portal_id 66 | return portal_id 67 | except CalledProcessError: 68 | # get-unique-id returned non-zero 69 | raise NoVrmPortalIdError("get-unique-id returned non-zero") 70 | except OSError: 71 | # File doesn't exist, use fallback 72 | pass 73 | 74 | # Fall back to getting our id using a syscall. Assume we are on linux. 75 | # Allow the user to override what interface is used using an environment 76 | # variable. 77 | import fcntl, socket, struct, os 78 | 79 | iface = os.environ.get('VRM_IFACE', 'eth0').encode('ascii') 80 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 81 | try: 82 | info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', iface[:15])) 83 | except IOError: 84 | raise NoVrmPortalIdError("ioctl failed for eth0") 85 | 86 | __vrm_portal_id = info[18:24].hex() 87 | return __vrm_portal_id 88 | 89 | 90 | # See VE.Can registers - public.docx for definition of this conversion 91 | def convert_vreg_version_to_readable(version): 92 | def str_to_arr(x, length): 93 | a = [] 94 | for i in range(0, len(x), length): 95 | a.append(x[i:i+length]) 96 | return a 97 | 98 | x = "%x" % version 99 | x = x.upper() 100 | 101 | if len(x) == 5 or len(x) == 3 or len(x) == 1: 102 | x = '0' + x 103 | 104 | a = str_to_arr(x, 2); 105 | 106 | # remove the first 00 if there are three bytes and it is 00 107 | if len(a) == 3 and a[0] == '00': 108 | a.remove(0); 109 | 110 | # if we have two or three bytes now, and the first character is a 0, remove it 111 | if len(a) >= 2 and a[0][0:1] == '0': 112 | a[0] = a[0][1]; 113 | 114 | result = '' 115 | for item in a: 116 | result += ('.' if result != '' else '') + item 117 | 118 | 119 | result = 'v' + result 120 | 121 | return result 122 | 123 | 124 | def get_free_space(path): 125 | result = -1 126 | 127 | try: 128 | s = statvfs(path) 129 | result = s.f_frsize * s.f_bavail # Number of free bytes that ordinary users 130 | except Exception as ex: 131 | logger.info("Error while retrieving free space for path %s: %s" % (path, ex)) 132 | 133 | return result 134 | 135 | 136 | def _get_sysfs_machine_name(): 137 | try: 138 | with open('/sys/firmware/devicetree/base/model', 'r') as f: 139 | return f.read().rstrip('\x00') 140 | except IOError: 141 | pass 142 | 143 | return None 144 | 145 | # Returns None if it cannot find a machine name. Otherwise returns the string 146 | # containing the name 147 | def get_machine_name(): 148 | # First try calling the venus utility script 149 | try: 150 | return check_output("/usr/bin/product-name").strip().decode('UTF-8') 151 | except (CalledProcessError, OSError): 152 | pass 153 | 154 | # Fall back to sysfs 155 | name = _get_sysfs_machine_name() 156 | if name is not None: 157 | return name 158 | 159 | # Fall back to venus build machine name 160 | try: 161 | with open('/etc/venus/machine', 'r', encoding='UTF-8') as f: 162 | return f.read().strip() 163 | except IOError: 164 | pass 165 | 166 | return None 167 | 168 | 169 | def get_product_id(): 170 | """ Find the machine ID and return it. """ 171 | 172 | # First try calling the venus utility script 173 | try: 174 | return check_output("/usr/bin/product-id").strip().decode('UTF-8') 175 | except (CalledProcessError, OSError): 176 | pass 177 | 178 | # Fall back machine name mechanism 179 | name = _get_sysfs_machine_name() 180 | return { 181 | 'Color Control GX': 'C001', 182 | 'Venus GX': 'C002', 183 | 'Octo GX': 'C006', 184 | 'EasySolar-II': 'C007', 185 | 'MultiPlus-II': 'C008', 186 | 'Maxi GX': 'C009', 187 | 'Cerbo GX': 'C00A' 188 | }.get(name, 'C003') # C003 is Generic 189 | 190 | 191 | # Returns False if it cannot open the file. Otherwise returns its rstripped contents 192 | def read_file(path): 193 | content = False 194 | 195 | try: 196 | with open(path, 'r') as f: 197 | content = f.read().rstrip() 198 | except Exception as ex: 199 | logger.debug("Error while reading %s: %s" % (path, ex)) 200 | 201 | return content 202 | 203 | 204 | def wrap_dbus_value(value): 205 | if value is None: 206 | return VEDBUS_INVALID 207 | if isinstance(value, float): 208 | return dbus.Double(value, variant_level=1) 209 | if isinstance(value, bool): 210 | return dbus.Boolean(value, variant_level=1) 211 | if isinstance(value, int): 212 | try: 213 | return dbus.Int32(value, variant_level=1) 214 | except OverflowError: 215 | return dbus.Int64(value, variant_level=1) 216 | if isinstance(value, str): 217 | return dbus.String(value, variant_level=1) 218 | if isinstance(value, list): 219 | if len(value) == 0: 220 | # If the list is empty we cannot infer the type of the contents. So assume unsigned integer. 221 | # A (signed) integer is dangerous, because an empty list of signed integers is used to encode 222 | # an invalid value. 223 | return dbus.Array([], signature=dbus.Signature('u'), variant_level=1) 224 | return dbus.Array([wrap_dbus_value(x) for x in value], variant_level=1) 225 | if isinstance(value, dict): 226 | # Wrapping the keys of the dictionary causes D-Bus errors like: 227 | # 'arguments to dbus_message_iter_open_container() were incorrect, 228 | # assertion "(type == DBUS_TYPE_ARRAY && contained_signature && 229 | # *contained_signature == DBUS_DICT_ENTRY_BEGIN_CHAR) || (contained_signature == NULL || 230 | # _dbus_check_is_valid_signature (contained_signature))" failed in file ...' 231 | return dbus.Dictionary({(k, wrap_dbus_value(v)) for k, v in value.items()}, variant_level=1) 232 | return value 233 | 234 | 235 | dbus_int_types = (dbus.Int32, dbus.UInt32, dbus.Byte, dbus.Int16, dbus.UInt16, dbus.UInt32, dbus.Int64, dbus.UInt64) 236 | 237 | 238 | def unwrap_dbus_value(val): 239 | """Converts D-Bus values back to the original type. For example if val is of type DBus.Double, 240 | a float will be returned.""" 241 | if isinstance(val, dbus_int_types): 242 | return int(val) 243 | if isinstance(val, dbus.Double): 244 | return float(val) 245 | if isinstance(val, dbus.Array): 246 | v = [unwrap_dbus_value(x) for x in val] 247 | return None if len(v) == 0 else v 248 | if isinstance(val, (dbus.Signature, dbus.String)): 249 | return str(val) 250 | # Python has no byte type, so we convert to an integer. 251 | if isinstance(val, dbus.Byte): 252 | return int(val) 253 | if isinstance(val, dbus.ByteArray): 254 | return "".join([bytes(x) for x in val]) 255 | if isinstance(val, (list, tuple)): 256 | return [unwrap_dbus_value(x) for x in val] 257 | if isinstance(val, (dbus.Dictionary, dict)): 258 | # Do not unwrap the keys, see comment in wrap_dbus_value 259 | return dict([(x, unwrap_dbus_value(y)) for x, y in val.items()]) 260 | if isinstance(val, dbus.Boolean): 261 | return bool(val) 262 | return val 263 | 264 | # When supported, only name owner changes for the the given namespace are reported. This 265 | # prevents spending cpu time at irrelevant changes, like scripts accessing the bus temporarily. 266 | def add_name_owner_changed_receiver(dbus, name_owner_changed, namespace="com.victronenergy"): 267 | # support for arg0namespace is submitted upstream, but not included at the time of 268 | # writing, Venus OS does support it, so try if it works. 269 | if namespace is None: 270 | dbus.add_signal_receiver(name_owner_changed, signal_name='NameOwnerChanged') 271 | else: 272 | try: 273 | dbus.add_signal_receiver(name_owner_changed, 274 | signal_name='NameOwnerChanged', arg0namespace=namespace) 275 | except TypeError: 276 | dbus.add_signal_receiver(name_owner_changed, signal_name='NameOwnerChanged') 277 | -------------------------------------------------------------------------------- /vedbus.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import dbus.service 5 | import logging 6 | import traceback 7 | import os 8 | import weakref 9 | from collections import defaultdict 10 | from ve_utils import wrap_dbus_value, unwrap_dbus_value 11 | 12 | # vedbus contains three classes: 13 | # VeDbusItemImport -> use this to read data from the dbus, ie import 14 | # VeDbusItemExport -> use this to export data to the dbus (one value) 15 | # VeDbusService -> use that to create a service and export several values to the dbus 16 | 17 | # Code for VeDbusItemImport is copied from busitem.py and thereafter modified. 18 | # All projects that used busitem.py need to migrate to this package. And some 19 | # projects used to define there own equivalent of VeDbusItemExport. Better to 20 | # use VeDbusItemExport, or even better the VeDbusService class that does it all for you. 21 | 22 | # TODOS 23 | # 1 check for datatypes, it works now, but not sure if all is compliant with 24 | # com.victronenergy.BusItem interface definition. See also the files in 25 | # tests_and_examples. And see 'if type(v) == dbus.Byte:' on line 102. Perhaps 26 | # something similar should also be done in VeDbusBusItemExport? 27 | # 2 Shouldn't VeDbusBusItemExport inherit dbus.service.Object? 28 | # 7 Make hard rules for services exporting data to the D-Bus, in order to make tracking 29 | # changes possible. Does everybody first invalidate its data before leaving the bus? 30 | # And what about before taking one object away from the bus, instead of taking the 31 | # whole service offline? 32 | # They should! And after taking one value away, do we need to know that someone left 33 | # the bus? Or we just keep that value in invalidated for ever? Result is that we can't 34 | # see the difference anymore between an invalidated value and a value that was first on 35 | # the bus and later not anymore. See comments above VeDbusItemImport as well. 36 | # 9 there are probably more todos in the code below. 37 | 38 | # Some thoughts with regards to the data types: 39 | # 40 | # Text from: http://dbus.freedesktop.org/doc/dbus-python/doc/tutorial.html#data-types 41 | # --- 42 | # Variants are represented by setting the variant_level keyword argument in the 43 | # constructor of any D-Bus data type to a value greater than 0 (variant_level 1 44 | # means a variant containing some other data type, variant_level 2 means a variant 45 | # containing a variant containing some other data type, and so on). If a non-variant 46 | # is passed as an argument but introspection indicates that a variant is expected, 47 | # it'll automatically be wrapped in a variant. 48 | # --- 49 | # 50 | # Also the different dbus datatypes, such as dbus.Int32, and dbus.UInt32 are a subclass 51 | # of Python int. dbus.String is a subclass of Python standard class unicode, etcetera 52 | # 53 | # So all together that explains why we don't need to explicitly convert back and forth 54 | # between the dbus datatypes and the standard python datatypes. Note that all datatypes 55 | # in python are objects. Even an int is an object. 56 | 57 | # The signature of a variant is 'v'. 58 | 59 | # Export ourselves as a D-Bus service. 60 | class VeDbusService(object): 61 | def __init__(self, servicename, bus=None, register=None): 62 | # dict containing the VeDbusItemExport objects, with their path as the key. 63 | self._dbusobjects = {} 64 | self._dbusnodes = {} 65 | self._ratelimiters = [] 66 | self._dbusname = None 67 | self.name = servicename 68 | 69 | # dict containing the onchange callbacks, for each object. Object path is the key 70 | self._onchangecallbacks = {} 71 | 72 | # Connect to session bus whenever present, else use the system bus 73 | self._dbusconn = bus or (dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus()) 74 | 75 | # make the dbus connection available to outside, could make this a true property instead, but ach.. 76 | self.dbusconn = self._dbusconn 77 | 78 | # Add the root item that will return all items as a tree 79 | self._dbusnodes['/'] = VeDbusRootExport(self._dbusconn, '/', self) 80 | 81 | # Immediately register the service unless requested not to 82 | if register is None: 83 | logging.warning("USING OUTDATED REGISTRATION METHOD!") 84 | logging.warning("Please set register=False, then call the register method " 85 | "after adding all mandatory paths. See " 86 | "https://github.com/victronenergy/venus/wiki/dbus-api") 87 | self.register() 88 | elif register: 89 | self.register() 90 | 91 | def register(self): 92 | # Register ourselves on the dbus, trigger an error if already in use (do_not_queue) 93 | self._dbusname = dbus.service.BusName(self.name, self._dbusconn, do_not_queue=True) 94 | logging.info("registered ourselves on D-Bus as %s" % self.name) 95 | 96 | # To force immediate deregistering of this dbus service and all its object paths, explicitly 97 | # call __del__(). 98 | def __del__(self): 99 | for node in list(self._dbusnodes.values()): 100 | node.__del__() 101 | self._dbusnodes.clear() 102 | for item in list(self._dbusobjects.values()): 103 | item.__del__() 104 | self._dbusobjects.clear() 105 | if self._dbusname: 106 | self._dbusname.__del__() # Forces call to self._bus.release_name(self._name), see source code 107 | self._dbusname = None 108 | 109 | def get_name(self): 110 | return self.name 111 | 112 | # @param callbackonchange function that will be called when this value is changed. First parameter will 113 | # be the path of the object, second the new value. This callback should return 114 | # True to accept the change, False to reject it. 115 | def add_path(self, path, value, description="", writeable=False, 116 | onchangecallback=None, gettextcallback=None, valuetype=None, itemtype=None): 117 | 118 | if onchangecallback is not None: 119 | self._onchangecallbacks[path] = onchangecallback 120 | 121 | itemtype = itemtype or VeDbusItemExport 122 | item = itemtype(self._dbusconn, path, value, description, writeable, 123 | self._value_changed, gettextcallback, deletecallback=self._item_deleted, valuetype=valuetype) 124 | 125 | spl = path.split('/') 126 | for i in range(2, len(spl)): 127 | subPath = '/'.join(spl[:i]) 128 | if subPath not in self._dbusnodes and subPath not in self._dbusobjects: 129 | self._dbusnodes[subPath] = VeDbusTreeExport(self._dbusconn, subPath, self) 130 | self._dbusobjects[path] = item 131 | logging.debug('added %s with start value %s. Writeable is %s' % (path, value, writeable)) 132 | return item 133 | 134 | # Add the mandatory paths, as per victron dbus api doc 135 | def add_mandatory_paths(self, processname, processversion, connection, 136 | deviceinstance, productid, productname, firmwareversion, hardwareversion, connected): 137 | self.add_path('/Mgmt/ProcessName', processname) 138 | self.add_path('/Mgmt/ProcessVersion', processversion) 139 | self.add_path('/Mgmt/Connection', connection) 140 | 141 | # Create rest of the mandatory objects 142 | self.add_path('/DeviceInstance', deviceinstance) 143 | self.add_path('/ProductId', productid) 144 | self.add_path('/ProductName', productname) 145 | self.add_path('/FirmwareVersion', firmwareversion) 146 | self.add_path('/HardwareVersion', hardwareversion) 147 | self.add_path('/Connected', connected) 148 | 149 | # Callback function that is called from the VeDbusItemExport objects when a value changes. This function 150 | # maps the change-request to the onchangecallback given to us for this specific path. 151 | def _value_changed(self, path, newvalue): 152 | if path not in self._onchangecallbacks: 153 | return True 154 | 155 | return self._onchangecallbacks[path](path, newvalue) 156 | 157 | def _item_deleted(self, path): 158 | self._dbusobjects.pop(path) 159 | for np in list(self._dbusnodes.keys()): 160 | if np != '/': 161 | for ip in self._dbusobjects: 162 | if ip.startswith(np + '/'): 163 | break 164 | else: 165 | self._dbusnodes[np].__del__() 166 | self._dbusnodes.pop(np) 167 | 168 | def __getitem__(self, path): 169 | return self._dbusobjects[path].local_get_value() 170 | 171 | def __setitem__(self, path, newvalue): 172 | self._dbusobjects[path].local_set_value(newvalue) 173 | 174 | def __delitem__(self, path): 175 | self._dbusobjects[path].__del__() # Invalidates and then removes the object path 176 | assert path not in self._dbusobjects 177 | 178 | def __contains__(self, path): 179 | return path in self._dbusobjects 180 | 181 | def __enter__(self): 182 | l = ServiceContext(self) 183 | self._ratelimiters.append(l) 184 | return l 185 | 186 | def __exit__(self, *exc): 187 | # pop off the top one and flush it. If with statements are nested 188 | # then each exit flushes its own part. 189 | if self._ratelimiters: 190 | self._ratelimiters.pop().flush() 191 | 192 | class ServiceContext(object): 193 | def __init__(self, parent): 194 | self.parent = parent 195 | self.changes = {} 196 | 197 | def __contains__(self, path): 198 | return path in self.parent 199 | 200 | def __getitem__(self, path): 201 | return self.parent[path] 202 | 203 | def __setitem__(self, path, newvalue): 204 | c = self.parent._dbusobjects[path]._local_set_value(newvalue) 205 | if c is not None: 206 | self.changes[path] = c 207 | 208 | def __delitem__(self, path): 209 | if path in self.changes: 210 | del self.changes[path] 211 | del self.parent[path] 212 | 213 | def flush(self): 214 | if self.changes: 215 | self.parent._dbusnodes['/'].ItemsChanged(self.changes) 216 | self.changes.clear() 217 | 218 | def add_path(self, path, value, *args, **kwargs): 219 | self.parent.add_path(path, value, *args, **kwargs) 220 | self.changes[path] = { 221 | 'Value': wrap_dbus_value(value), 222 | 'Text': self.parent._dbusobjects[path].GetText() 223 | } 224 | 225 | def del_tree(self, root): 226 | root = root.rstrip('/') 227 | for p in list(self.parent._dbusobjects.keys()): 228 | if p == root or p.startswith(root + '/'): 229 | self[p] = None 230 | self.parent._dbusobjects[p].__del__() 231 | 232 | def get_name(self): 233 | return self.parent.get_name() 234 | 235 | class TrackerDict(defaultdict): 236 | """ Same as defaultdict, but passes the key to default_factory. """ 237 | def __missing__(self, key): 238 | self[key] = x = self.default_factory(key) 239 | return x 240 | 241 | class VeDbusRootTracker(object): 242 | """ This tracks the root of a dbus path and listens for PropertiesChanged 243 | signals. When a signal arrives, parse it and unpack the key/value changes 244 | into traditional events, then pass it to the original eventCallback 245 | method. """ 246 | def __init__(self, bus, serviceName): 247 | self.importers = defaultdict(weakref.WeakSet) 248 | self.serviceName = serviceName 249 | self._match = bus.get_object(serviceName, '/', introspect=False).connect_to_signal( 250 | "ItemsChanged", weak_functor(self._items_changed_handler)) 251 | 252 | def __del__(self): 253 | self._match.remove() 254 | self._match = None 255 | 256 | def add(self, i): 257 | self.importers[i.path].add(i) 258 | 259 | def _items_changed_handler(self, items): 260 | if not isinstance(items, dict): 261 | return 262 | 263 | for path, changes in items.items(): 264 | try: 265 | v = changes['Value'] 266 | except KeyError: 267 | continue 268 | 269 | try: 270 | t = changes['Text'] 271 | except KeyError: 272 | t = str(unwrap_dbus_value(v)) 273 | 274 | for i in self.importers.get(path, ()): 275 | i._properties_changed_handler({'Value': v, 'Text': t}) 276 | 277 | """ 278 | Importing basics: 279 | - If when we power up, the D-Bus service does not exist, or it does exist and the path does not 280 | yet exist, still subscribe to a signal: as soon as it comes online it will send a signal with its 281 | initial value, which VeDbusItemImport will receive and use to update local cache. And, when set, 282 | call the eventCallback. 283 | - If when we power up, save it 284 | - When using get_value, know that there is no difference between services (or object paths) that don't 285 | exist and paths that are invalid (= empty array, see above). Both will return None. In case you do 286 | really want to know ifa path exists or not, use the exists property. 287 | - When a D-Bus service leaves the D-Bus, it will first invalidate all its values, and send signals 288 | with that update, and only then leave the D-Bus. (or do we need to subscribe to the NameOwnerChanged- 289 | signal!?!) To be discussed and make sure. Not really urgent, since all existing code that uses this 290 | class already subscribes to the NameOwnerChanged signal, and subsequently removes instances of this 291 | class. 292 | 293 | Read when using this class: 294 | Note that when a service leaves that D-Bus without invalidating all its exported objects first, for 295 | example because it is killed, VeDbusItemImport doesn't have a clue. So when using VeDbusItemImport, 296 | make sure to also subscribe to the NamerOwnerChanged signal on bus-level. Or just use dbusmonitor, 297 | because that takes care of all of that for you. 298 | """ 299 | class VeDbusItemImport(object): 300 | def __new__(cls, bus, serviceName, path, eventCallback=None, createsignal=True): 301 | instance = object.__new__(cls) 302 | 303 | # If signal tracking should be done, also add to root tracker 304 | if createsignal: 305 | if "_roots" not in cls.__dict__: 306 | cls._roots = TrackerDict(lambda k: VeDbusRootTracker(bus, k)) 307 | 308 | return instance 309 | 310 | ## Constructor 311 | # @param bus the bus-object (SESSION or SYSTEM). 312 | # @param serviceName the dbus-service-name (string), for example 'com.victronenergy.battery.ttyO1' 313 | # @param path the object-path, for example '/Dc/V' 314 | # @param eventCallback function that you want to be called on a value change 315 | # @param createSignal only set this to False if you use this function to one time read a value. When 316 | # leaving it to True, make sure to also subscribe to the NameOwnerChanged signal 317 | # elsewhere. See also note some 15 lines up. 318 | def __init__(self, bus, serviceName, path, eventCallback=None, createsignal=True): 319 | # TODO: is it necessary to store _serviceName and _path? Isn't it 320 | # stored in the bus_getobjectsomewhere? 321 | self._serviceName = serviceName 322 | self._path = path 323 | self._match = None 324 | # TODO: _proxy is being used in settingsdevice.py, make a getter for that 325 | self._proxy = bus.get_object(serviceName, path, introspect=False) 326 | self.eventCallback = eventCallback 327 | 328 | assert eventCallback is None or createsignal == True 329 | if createsignal: 330 | self._match = self._proxy.connect_to_signal( 331 | "PropertiesChanged", weak_functor(self._properties_changed_handler)) 332 | self._roots[serviceName].add(self) 333 | 334 | # store the current value in _cachedvalue. When it doesn't exists set _cachedvalue to 335 | # None, same as when a value is invalid 336 | self._cachedvalue = None 337 | try: 338 | v = self._proxy.GetValue() 339 | except dbus.exceptions.DBusException: 340 | pass 341 | else: 342 | self._cachedvalue = unwrap_dbus_value(v) 343 | 344 | def __del__(self): 345 | if self._match is not None: 346 | self._match.remove() 347 | self._match = None 348 | self._proxy = None 349 | 350 | def _refreshcachedvalue(self): 351 | self._cachedvalue = unwrap_dbus_value(self._proxy.GetValue()) 352 | 353 | ## Returns the path as a string, for example '/AC/L1/V' 354 | @property 355 | def path(self): 356 | return self._path 357 | 358 | ## Returns the dbus service name as a string, for example com.victronenergy.vebus.ttyO1 359 | @property 360 | def serviceName(self): 361 | return self._serviceName 362 | 363 | ## Returns the value of the dbus-item. 364 | # the type will be a dbus variant, for example dbus.Int32(0, variant_level=1) 365 | # this is not a property to keep the name consistant with the com.victronenergy.busitem interface 366 | # returns None when the property is invalid 367 | def get_value(self): 368 | return self._cachedvalue 369 | 370 | ## Writes a new value to the dbus-item 371 | def set_value(self, newvalue): 372 | r = self._proxy.SetValue(wrap_dbus_value(newvalue)) 373 | 374 | # instead of just saving the value, go to the dbus and get it. So we have the right type etc. 375 | if r == 0: 376 | self._refreshcachedvalue() 377 | 378 | return r 379 | 380 | ## Resets the item to its default value 381 | def set_default(self): 382 | self._proxy.SetDefault() 383 | self._refreshcachedvalue() 384 | 385 | ## Returns the text representation of the value. 386 | # For example when the value is an enum/int GetText might return the string 387 | # belonging to that enum value. Another example, for a voltage, GetValue 388 | # would return a float, 12.0Volt, and GetText could return 12 VDC. 389 | # 390 | # Note that this depends on how the dbus-producer has implemented this. 391 | def get_text(self): 392 | return self._proxy.GetText() 393 | 394 | ## Returns true of object path exists, and false if it doesn't 395 | @property 396 | def exists(self): 397 | # TODO: do some real check instead of this crazy thing. 398 | r = False 399 | try: 400 | r = self._proxy.GetValue() 401 | r = True 402 | except dbus.exceptions.DBusException: 403 | pass 404 | 405 | return r 406 | 407 | ## callback for the trigger-event. 408 | # @param eventCallback the event-callback-function. 409 | @property 410 | def eventCallback(self): 411 | return self._eventCallback 412 | 413 | @eventCallback.setter 414 | def eventCallback(self, eventCallback): 415 | self._eventCallback = eventCallback 416 | 417 | ## Is called when the value of the imported bus-item changes. 418 | # Stores the new value in our local cache, and calls the eventCallback, if set. 419 | def _properties_changed_handler(self, changes): 420 | if "Value" in changes: 421 | changes['Value'] = unwrap_dbus_value(changes['Value']) 422 | self._cachedvalue = changes['Value'] 423 | if self._eventCallback: 424 | # The reason behind this try/except is to prevent errors silently ending up the an error 425 | # handler in the dbus code. 426 | try: 427 | self._eventCallback(self._serviceName, self._path, changes) 428 | except: 429 | traceback.print_exc() 430 | os._exit(1) # sys.exit() is not used, since that also throws an exception 431 | 432 | 433 | class VeDbusTreeExport(dbus.service.Object): 434 | def __init__(self, bus, objectPath, service): 435 | dbus.service.Object.__init__(self, bus, objectPath) 436 | self._service = service 437 | logging.debug("VeDbusTreeExport %s has been created" % objectPath) 438 | 439 | def __del__(self): 440 | # self._get_path() will raise an exception when retrieved after the call to .remove_from_connection, 441 | # so we need a copy. 442 | path = self._get_path() 443 | if path is None: 444 | return 445 | self.remove_from_connection() 446 | logging.debug("VeDbusTreeExport %s has been removed" % path) 447 | 448 | def _get_path(self): 449 | if len(self._locations) == 0: 450 | return None 451 | return self._locations[0][1] 452 | 453 | def _get_value_handler(self, path, get_text=False): 454 | logging.debug("_get_value_handler called for %s" % path) 455 | r = {} 456 | px = path 457 | if not px.endswith('/'): 458 | px += '/' 459 | for p, item in self._service._dbusobjects.items(): 460 | if p.startswith(px): 461 | v = item.GetText() if get_text else wrap_dbus_value(item.local_get_value()) 462 | r[p[len(px):]] = v 463 | logging.debug(r) 464 | return r 465 | 466 | @dbus.service.method('com.victronenergy.BusItem', out_signature='v') 467 | def GetValue(self): 468 | value = self._get_value_handler(self._get_path()) 469 | return dbus.Dictionary(value, signature=dbus.Signature('sv'), variant_level=1) 470 | 471 | @dbus.service.method('com.victronenergy.BusItem', out_signature='v') 472 | def GetText(self): 473 | return self._get_value_handler(self._get_path(), True) 474 | 475 | def local_get_value(self): 476 | return self._get_value_handler(self.path) 477 | 478 | class VeDbusRootExport(VeDbusTreeExport): 479 | @dbus.service.signal('com.victronenergy.BusItem', signature='a{sa{sv}}') 480 | def ItemsChanged(self, changes): 481 | pass 482 | 483 | @dbus.service.method('com.victronenergy.BusItem', out_signature='a{sa{sv}}') 484 | def GetItems(self): 485 | return { 486 | path: { 487 | 'Value': wrap_dbus_value(item.local_get_value()), 488 | 'Text': item.GetText() } 489 | for path, item in self._service._dbusobjects.items() 490 | } 491 | 492 | 493 | class VeDbusItemExport(dbus.service.Object): 494 | ## Constructor of VeDbusItemExport 495 | # 496 | # Use this object to export (publish), values on the dbus 497 | # Creates the dbus-object under the given dbus-service-name. 498 | # @param bus The dbus object. 499 | # @param objectPath The dbus-object-path. 500 | # @param value Value to initialize ourselves with, defaults to None which means Invalid 501 | # @param description String containing a description. Can be called over the dbus with GetDescription() 502 | # @param writeable what would this do!? :). 503 | # @param callback Function that will be called when someone else changes the value of this VeBusItem 504 | # over the dbus. First parameter passed to callback will be our path, second the new 505 | # value. This callback should return True to accept the change, False to reject it. 506 | def __init__(self, bus, objectPath, value=None, description=None, writeable=False, 507 | onchangecallback=None, gettextcallback=None, deletecallback=None, 508 | valuetype=None): 509 | dbus.service.Object.__init__(self, bus, objectPath) 510 | self._onchangecallback = onchangecallback 511 | self._gettextcallback = gettextcallback 512 | self._value = value 513 | self._description = description 514 | self._writeable = writeable 515 | self._deletecallback = deletecallback 516 | self._type = valuetype 517 | 518 | # To force immediate deregistering of this dbus object, explicitly call __del__(). 519 | def __del__(self): 520 | # self._get_path() will raise an exception when retrieved after the 521 | # call to .remove_from_connection, so we need a copy. 522 | path = self._get_path() 523 | if path == None: 524 | return 525 | if self._deletecallback is not None: 526 | self._deletecallback(path) 527 | self.remove_from_connection() 528 | logging.debug("VeDbusItemExport %s has been removed" % path) 529 | 530 | def _get_path(self): 531 | if len(self._locations) == 0: 532 | return None 533 | return self._locations[0][1] 534 | 535 | ## Sets the value. And in case the value is different from what it was, a signal 536 | # will be emitted to the dbus. This function is to be used in the python code that 537 | # is using this class to export values to the dbus. 538 | # set value to None to indicate that it is Invalid 539 | def local_set_value(self, newvalue): 540 | changes = self._local_set_value(newvalue) 541 | if changes is not None: 542 | self.PropertiesChanged(changes) 543 | 544 | def _local_set_value(self, newvalue): 545 | if self._value == newvalue: 546 | return None 547 | 548 | self._value = newvalue 549 | return { 550 | 'Value': wrap_dbus_value(newvalue), 551 | 'Text': self.GetText() 552 | } 553 | 554 | def local_get_value(self): 555 | return self._value 556 | 557 | # ==== ALL FUNCTIONS BELOW THIS LINE WILL BE CALLED BY OTHER PROCESSES OVER THE DBUS ==== 558 | 559 | ## Dbus exported method SetValue 560 | # Function is called over the D-Bus by other process. It will first check (via callback) if new 561 | # value is accepted. And it is, stores it and emits a changed-signal. 562 | # @param value The new value. 563 | # @return completion-code When successful a 0 is return, and when not a -1 is returned. 564 | @dbus.service.method('com.victronenergy.BusItem', in_signature='v', out_signature='i') 565 | def SetValue(self, newvalue): 566 | if not self._writeable: 567 | return 1 # NOT OK 568 | 569 | newvalue = unwrap_dbus_value(newvalue) 570 | 571 | # If value type is enforced, cast it. If the type can be coerced 572 | # python will do it for us. This allows ints to become floats, 573 | # or bools to become ints. Additionally also allow None, so that 574 | # a path may be invalidated. 575 | if self._type is not None and newvalue is not None: 576 | try: 577 | newvalue = self._type(newvalue) 578 | except (ValueError, TypeError): 579 | return 1 # NOT OK 580 | 581 | if newvalue == self._value: 582 | return 0 # OK 583 | 584 | # call the callback given to us, and check if new value is OK. 585 | if (self._onchangecallback is None or 586 | (self._onchangecallback is not None and self._onchangecallback(self.__dbus_object_path__, newvalue))): 587 | 588 | self.local_set_value(newvalue) 589 | return 0 # OK 590 | 591 | return 2 # NOT OK 592 | 593 | ## Dbus exported method GetDescription 594 | # 595 | # Returns the a description. 596 | # @param language A language code (e.g. ISO 639-1 en-US). 597 | # @param length Lenght of the language string. 598 | # @return description 599 | @dbus.service.method('com.victronenergy.BusItem', in_signature='si', out_signature='s') 600 | def GetDescription(self, language, length): 601 | return self._description if self._description is not None else 'No description given' 602 | 603 | ## Dbus exported method GetValue 604 | # Returns the value. 605 | # @return the value when valid, and otherwise an empty array 606 | @dbus.service.method('com.victronenergy.BusItem', out_signature='v') 607 | def GetValue(self): 608 | return wrap_dbus_value(self._value) 609 | 610 | ## Dbus exported method GetText 611 | # Returns the value as string of the dbus-object-path. 612 | # @return text A text-value. '---' when local value is invalid 613 | @dbus.service.method('com.victronenergy.BusItem', out_signature='s') 614 | def GetText(self): 615 | if self._value is None: 616 | return '---' 617 | 618 | # Default conversion from dbus.Byte will get you a character (so 'T' instead of '84'), so we 619 | # have to convert to int first. Note that if a dbus.Byte turns up here, it must have come from 620 | # the application itself, as all data from the D-Bus should have been unwrapped by now. 621 | if self._gettextcallback is None and type(self._value) == dbus.Byte: 622 | return str(int(self._value)) 623 | 624 | if self._gettextcallback is None and self.__dbus_object_path__ == '/ProductId': 625 | return "0x%X" % self._value 626 | 627 | if self._gettextcallback is None: 628 | return str(self._value) 629 | 630 | return self._gettextcallback(self.__dbus_object_path__, self._value) 631 | 632 | ## The signal that indicates that the value has changed. 633 | # Other processes connected to this BusItem object will have subscribed to the 634 | # event when they want to track our state. 635 | @dbus.service.signal('com.victronenergy.BusItem', signature='a{sv}') 636 | def PropertiesChanged(self, changes): 637 | pass 638 | 639 | ## This class behaves like a regular reference to a class method (eg. self.foo), but keeps a weak reference 640 | ## to the object which method is to be called. 641 | ## Use this object to break circular references. 642 | class weak_functor: 643 | def __init__(self, f): 644 | self._r = weakref.ref(f.__self__) 645 | self._f = weakref.ref(f.__func__) 646 | 647 | def __call__(self, *args, **kargs): 648 | r = self._r() 649 | f = self._f() 650 | if r == None or f == None: 651 | return 652 | f(r, *args, **kargs) 653 | --------------------------------------------------------------------------------