comparison velib_python/vedbus.py @ 8:9c0435a617db

Import velib_python
author Daniel O'Connor <darius@dons.net.au>
date Sun, 05 Dec 2021 14:35:36 +1030
parents
children
comparison
equal deleted inserted replaced
5:982eeffe9d95 8:9c0435a617db
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):
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
68 # dict containing the onchange callbacks, for each object. Object path is the key
69 self._onchangecallbacks = {}
70
71 # Connect to session bus whenever present, else use the system bus
72 self._dbusconn = bus or (dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus())
73
74 # make the dbus connection available to outside, could make this a true property instead, but ach..
75 self.dbusconn = self._dbusconn
76
77 # Register ourselves on the dbus, trigger an error if already in use (do_not_queue)
78 self._dbusname = dbus.service.BusName(servicename, self._dbusconn, do_not_queue=True)
79
80 # Add the root item that will return all items as a tree
81 self._dbusnodes['/'] = VeDbusRootExport(self._dbusconn, '/', self)
82
83 logging.info("registered ourselves on D-Bus as %s" % servicename)
84
85 # To force immediate deregistering of this dbus service and all its object paths, explicitly
86 # call __del__().
87 def __del__(self):
88 for node in list(self._dbusnodes.values()):
89 node.__del__()
90 self._dbusnodes.clear()
91 for item in list(self._dbusobjects.values()):
92 item.__del__()
93 self._dbusobjects.clear()
94 if self._dbusname:
95 self._dbusname.__del__() # Forces call to self._bus.release_name(self._name), see source code
96 self._dbusname = None
97
98 # @param callbackonchange function that will be called when this value is changed. First parameter will
99 # be the path of the object, second the new value. This callback should return
100 # True to accept the change, False to reject it.
101 def add_path(self, path, value, description="", writeable=False,
102 onchangecallback=None, gettextcallback=None):
103
104 if onchangecallback is not None:
105 self._onchangecallbacks[path] = onchangecallback
106
107 item = VeDbusItemExport(
108 self._dbusconn, path, value, description, writeable,
109 self._value_changed, gettextcallback, deletecallback=self._item_deleted)
110
111 spl = path.split('/')
112 for i in range(2, len(spl)):
113 subPath = '/'.join(spl[:i])
114 if subPath not in self._dbusnodes and subPath not in self._dbusobjects:
115 self._dbusnodes[subPath] = VeDbusTreeExport(self._dbusconn, subPath, self)
116 self._dbusobjects[path] = item
117 logging.debug('added %s with start value %s. Writeable is %s' % (path, value, writeable))
118
119 # Add the mandatory paths, as per victron dbus api doc
120 def add_mandatory_paths(self, processname, processversion, connection,
121 deviceinstance, productid, productname, firmwareversion, hardwareversion, connected):
122 self.add_path('/Mgmt/ProcessName', processname)
123 self.add_path('/Mgmt/ProcessVersion', processversion)
124 self.add_path('/Mgmt/Connection', connection)
125
126 # Create rest of the mandatory objects
127 self.add_path('/DeviceInstance', deviceinstance)
128 self.add_path('/ProductId', productid)
129 self.add_path('/ProductName', productname)
130 self.add_path('/FirmwareVersion', firmwareversion)
131 self.add_path('/HardwareVersion', hardwareversion)
132 self.add_path('/Connected', connected)
133
134 # Callback function that is called from the VeDbusItemExport objects when a value changes. This function
135 # maps the change-request to the onchangecallback given to us for this specific path.
136 def _value_changed(self, path, newvalue):
137 if path not in self._onchangecallbacks:
138 return True
139
140 return self._onchangecallbacks[path](path, newvalue)
141
142 def _item_deleted(self, path):
143 self._dbusobjects.pop(path)
144 for np in list(self._dbusnodes.keys()):
145 if np != '/':
146 for ip in self._dbusobjects:
147 if ip.startswith(np + '/'):
148 break
149 else:
150 self._dbusnodes[np].__del__()
151 self._dbusnodes.pop(np)
152
153 def __getitem__(self, path):
154 return self._dbusobjects[path].local_get_value()
155
156 def __setitem__(self, path, newvalue):
157 self._dbusobjects[path].local_set_value(newvalue)
158
159 def __delitem__(self, path):
160 self._dbusobjects[path].__del__() # Invalidates and then removes the object path
161 assert path not in self._dbusobjects
162
163 def __contains__(self, path):
164 return path in self._dbusobjects
165
166 def __enter__(self):
167 l = ServiceContext(self)
168 self._ratelimiters.append(l)
169 return l
170
171 def __exit__(self, *exc):
172 # pop off the top one and flush it. If with statements are nested
173 # then each exit flushes its own part.
174 if self._ratelimiters:
175 self._ratelimiters.pop().flush()
176
177 class ServiceContext(object):
178 def __init__(self, parent):
179 self.parent = parent
180 self.changes = {}
181
182 def __getitem__(self, path):
183 return self.parent[path]
184
185 def __setitem__(self, path, newvalue):
186 c = self.parent._dbusobjects[path]._local_set_value(newvalue)
187 if c is not None:
188 self.changes[path] = c
189
190 def flush(self):
191 if self.changes:
192 self.parent._dbusnodes['/'].ItemsChanged(self.changes)
193
194 class TrackerDict(defaultdict):
195 """ Same as defaultdict, but passes the key to default_factory. """
196 def __missing__(self, key):
197 self[key] = x = self.default_factory(key)
198 return x
199
200 class VeDbusRootTracker(object):
201 """ This tracks the root of a dbus path and listens for PropertiesChanged
202 signals. When a signal arrives, parse it and unpack the key/value changes
203 into traditional events, then pass it to the original eventCallback
204 method. """
205 def __init__(self, bus, serviceName):
206 self.importers = defaultdict(weakref.WeakSet)
207 self.serviceName = serviceName
208 self._match = bus.get_object(serviceName, '/', introspect=False).connect_to_signal(
209 "ItemsChanged", weak_functor(self._items_changed_handler))
210
211 def __del__(self):
212 self._match.remove()
213 self._match = None
214
215 def add(self, i):
216 self.importers[i.path].add(i)
217
218 def _items_changed_handler(self, items):
219 if not isinstance(items, dict):
220 return
221
222 for path, changes in items.items():
223 try:
224 v = changes['Value']
225 except KeyError:
226 continue
227
228 try:
229 t = changes['Text']
230 except KeyError:
231 t = str(unwrap_dbus_value(v))
232
233 for i in self.importers.get(path, ()):
234 i._properties_changed_handler({'Value': v, 'Text': t})
235
236 """
237 Importing basics:
238 - If when we power up, the D-Bus service does not exist, or it does exist and the path does not
239 yet exist, still subscribe to a signal: as soon as it comes online it will send a signal with its
240 initial value, which VeDbusItemImport will receive and use to update local cache. And, when set,
241 call the eventCallback.
242 - If when we power up, save it
243 - When using get_value, know that there is no difference between services (or object paths) that don't
244 exist and paths that are invalid (= empty array, see above). Both will return None. In case you do
245 really want to know ifa path exists or not, use the exists property.
246 - When a D-Bus service leaves the D-Bus, it will first invalidate all its values, and send signals
247 with that update, and only then leave the D-Bus. (or do we need to subscribe to the NameOwnerChanged-
248 signal!?!) To be discussed and make sure. Not really urgent, since all existing code that uses this
249 class already subscribes to the NameOwnerChanged signal, and subsequently removes instances of this
250 class.
251
252 Read when using this class:
253 Note that when a service leaves that D-Bus without invalidating all its exported objects first, for
254 example because it is killed, VeDbusItemImport doesn't have a clue. So when using VeDbusItemImport,
255 make sure to also subscribe to the NamerOwnerChanged signal on bus-level. Or just use dbusmonitor,
256 because that takes care of all of that for you.
257 """
258 class VeDbusItemImport(object):
259 def __new__(cls, bus, serviceName, path, eventCallback=None, createsignal=True):
260 instance = object.__new__(cls)
261
262 # If signal tracking should be done, also add to root tracker
263 if createsignal:
264 if "_roots" not in cls.__dict__:
265 cls._roots = TrackerDict(lambda k: VeDbusRootTracker(bus, k))
266
267 return instance
268
269 ## Constructor
270 # @param bus the bus-object (SESSION or SYSTEM).
271 # @param serviceName the dbus-service-name (string), for example 'com.victronenergy.battery.ttyO1'
272 # @param path the object-path, for example '/Dc/V'
273 # @param eventCallback function that you want to be called on a value change
274 # @param createSignal only set this to False if you use this function to one time read a value. When
275 # leaving it to True, make sure to also subscribe to the NameOwnerChanged signal
276 # elsewhere. See also note some 15 lines up.
277 def __init__(self, bus, serviceName, path, eventCallback=None, createsignal=True):
278 # TODO: is it necessary to store _serviceName and _path? Isn't it
279 # stored in the bus_getobjectsomewhere?
280 self._serviceName = serviceName
281 self._path = path
282 self._match = None
283 # TODO: _proxy is being used in settingsdevice.py, make a getter for that
284 self._proxy = bus.get_object(serviceName, path, introspect=False)
285 self.eventCallback = eventCallback
286
287 assert eventCallback is None or createsignal == True
288 if createsignal:
289 self._match = self._proxy.connect_to_signal(
290 "PropertiesChanged", weak_functor(self._properties_changed_handler))
291 self._roots[serviceName].add(self)
292
293 # store the current value in _cachedvalue. When it doesn't exists set _cachedvalue to
294 # None, same as when a value is invalid
295 self._cachedvalue = None
296 try:
297 v = self._proxy.GetValue()
298 except dbus.exceptions.DBusException:
299 pass
300 else:
301 self._cachedvalue = unwrap_dbus_value(v)
302
303 def __del__(self):
304 if self._match is not None:
305 self._match.remove()
306 self._match = None
307 self._proxy = None
308
309 def _refreshcachedvalue(self):
310 self._cachedvalue = unwrap_dbus_value(self._proxy.GetValue())
311
312 ## Returns the path as a string, for example '/AC/L1/V'
313 @property
314 def path(self):
315 return self._path
316
317 ## Returns the dbus service name as a string, for example com.victronenergy.vebus.ttyO1
318 @property
319 def serviceName(self):
320 return self._serviceName
321
322 ## Returns the value of the dbus-item.
323 # the type will be a dbus variant, for example dbus.Int32(0, variant_level=1)
324 # this is not a property to keep the name consistant with the com.victronenergy.busitem interface
325 # returns None when the property is invalid
326 def get_value(self):
327 return self._cachedvalue
328
329 ## Writes a new value to the dbus-item
330 def set_value(self, newvalue):
331 r = self._proxy.SetValue(wrap_dbus_value(newvalue))
332
333 # instead of just saving the value, go to the dbus and get it. So we have the right type etc.
334 if r == 0:
335 self._refreshcachedvalue()
336
337 return r
338
339 ## Resets the item to its default value
340 def set_default(self):
341 self._proxy.SetDefault()
342 self._refreshcachedvalue()
343
344 ## Returns the text representation of the value.
345 # For example when the value is an enum/int GetText might return the string
346 # belonging to that enum value. Another example, for a voltage, GetValue
347 # would return a float, 12.0Volt, and GetText could return 12 VDC.
348 #
349 # Note that this depends on how the dbus-producer has implemented this.
350 def get_text(self):
351 return self._proxy.GetText()
352
353 ## Returns true of object path exists, and false if it doesn't
354 @property
355 def exists(self):
356 # TODO: do some real check instead of this crazy thing.
357 r = False
358 try:
359 r = self._proxy.GetValue()
360 r = True
361 except dbus.exceptions.DBusException:
362 pass
363
364 return r
365
366 ## callback for the trigger-event.
367 # @param eventCallback the event-callback-function.
368 @property
369 def eventCallback(self):
370 return self._eventCallback
371
372 @eventCallback.setter
373 def eventCallback(self, eventCallback):
374 self._eventCallback = eventCallback
375
376 ## Is called when the value of the imported bus-item changes.
377 # Stores the new value in our local cache, and calls the eventCallback, if set.
378 def _properties_changed_handler(self, changes):
379 if "Value" in changes:
380 changes['Value'] = unwrap_dbus_value(changes['Value'])
381 self._cachedvalue = changes['Value']
382 if self._eventCallback:
383 # The reason behind this try/except is to prevent errors silently ending up the an error
384 # handler in the dbus code.
385 try:
386 self._eventCallback(self._serviceName, self._path, changes)
387 except:
388 traceback.print_exc()
389 os._exit(1) # sys.exit() is not used, since that also throws an exception
390
391
392 class VeDbusTreeExport(dbus.service.Object):
393 def __init__(self, bus, objectPath, service):
394 dbus.service.Object.__init__(self, bus, objectPath)
395 self._service = service
396 logging.debug("VeDbusTreeExport %s has been created" % objectPath)
397
398 def __del__(self):
399 # self._get_path() will raise an exception when retrieved after the call to .remove_from_connection,
400 # so we need a copy.
401 path = self._get_path()
402 if path is None:
403 return
404 self.remove_from_connection()
405 logging.debug("VeDbusTreeExport %s has been removed" % path)
406
407 def _get_path(self):
408 if len(self._locations) == 0:
409 return None
410 return self._locations[0][1]
411
412 def _get_value_handler(self, path, get_text=False):
413 logging.debug("_get_value_handler called for %s" % path)
414 r = {}
415 px = path
416 if not px.endswith('/'):
417 px += '/'
418 for p, item in self._service._dbusobjects.items():
419 if p.startswith(px):
420 v = item.GetText() if get_text else wrap_dbus_value(item.local_get_value())
421 r[p[len(px):]] = v
422 logging.debug(r)
423 return r
424
425 @dbus.service.method('com.victronenergy.BusItem', out_signature='v')
426 def GetValue(self):
427 value = self._get_value_handler(self._get_path())
428 return dbus.Dictionary(value, signature=dbus.Signature('sv'), variant_level=1)
429
430 @dbus.service.method('com.victronenergy.BusItem', out_signature='v')
431 def GetText(self):
432 return self._get_value_handler(self._get_path(), True)
433
434 def local_get_value(self):
435 return self._get_value_handler(self.path)
436
437 class VeDbusRootExport(VeDbusTreeExport):
438 @dbus.service.signal('com.victronenergy.BusItem', signature='a{sa{sv}}')
439 def ItemsChanged(self, changes):
440 pass
441
442 @dbus.service.method('com.victronenergy.BusItem', out_signature='a{sa{sv}}')
443 def GetItems(self):
444 return {
445 path: {
446 'Value': wrap_dbus_value(item.local_get_value()),
447 'Text': item.GetText() }
448 for path, item in self._service._dbusobjects.items()
449 }
450
451
452 class VeDbusItemExport(dbus.service.Object):
453 ## Constructor of VeDbusItemExport
454 #
455 # Use this object to export (publish), values on the dbus
456 # Creates the dbus-object under the given dbus-service-name.
457 # @param bus The dbus object.
458 # @param objectPath The dbus-object-path.
459 # @param value Value to initialize ourselves with, defaults to None which means Invalid
460 # @param description String containing a description. Can be called over the dbus with GetDescription()
461 # @param writeable what would this do!? :).
462 # @param callback Function that will be called when someone else changes the value of this VeBusItem
463 # over the dbus. First parameter passed to callback will be our path, second the new
464 # value. This callback should return True to accept the change, False to reject it.
465 def __init__(self, bus, objectPath, value=None, description=None, writeable=False,
466 onchangecallback=None, gettextcallback=None, deletecallback=None):
467 dbus.service.Object.__init__(self, bus, objectPath)
468 self._onchangecallback = onchangecallback
469 self._gettextcallback = gettextcallback
470 self._value = value
471 self._description = description
472 self._writeable = writeable
473 self._deletecallback = deletecallback
474
475 # To force immediate deregistering of this dbus object, explicitly call __del__().
476 def __del__(self):
477 # self._get_path() will raise an exception when retrieved after the
478 # call to .remove_from_connection, so we need a copy.
479 path = self._get_path()
480 if path == None:
481 return
482 if self._deletecallback is not None:
483 self._deletecallback(path)
484 self.local_set_value(None)
485 self.remove_from_connection()
486 logging.debug("VeDbusItemExport %s has been removed" % path)
487
488 def _get_path(self):
489 if len(self._locations) == 0:
490 return None
491 return self._locations[0][1]
492
493 ## Sets the value. And in case the value is different from what it was, a signal
494 # will be emitted to the dbus. This function is to be used in the python code that
495 # is using this class to export values to the dbus.
496 # set value to None to indicate that it is Invalid
497 def local_set_value(self, newvalue):
498 changes = self._local_set_value(newvalue)
499 if changes is not None:
500 self.PropertiesChanged(changes)
501
502 def _local_set_value(self, newvalue):
503 if self._value == newvalue:
504 return None
505
506 self._value = newvalue
507 return {
508 'Value': wrap_dbus_value(newvalue),
509 'Text': self.GetText()
510 }
511
512 def local_get_value(self):
513 return self._value
514
515 # ==== ALL FUNCTIONS BELOW THIS LINE WILL BE CALLED BY OTHER PROCESSES OVER THE DBUS ====
516
517 ## Dbus exported method SetValue
518 # Function is called over the D-Bus by other process. It will first check (via callback) if new
519 # value is accepted. And it is, stores it and emits a changed-signal.
520 # @param value The new value.
521 # @return completion-code When successful a 0 is return, and when not a -1 is returned.
522 @dbus.service.method('com.victronenergy.BusItem', in_signature='v', out_signature='i')
523 def SetValue(self, newvalue):
524 if not self._writeable:
525 return 1 # NOT OK
526
527 newvalue = unwrap_dbus_value(newvalue)
528
529 if newvalue == self._value:
530 return 0 # OK
531
532 # call the callback given to us, and check if new value is OK.
533 if (self._onchangecallback is None or
534 (self._onchangecallback is not None and self._onchangecallback(self.__dbus_object_path__, newvalue))):
535
536 self.local_set_value(newvalue)
537 return 0 # OK
538
539 return 2 # NOT OK
540
541 ## Dbus exported method GetDescription
542 #
543 # Returns the a description.
544 # @param language A language code (e.g. ISO 639-1 en-US).
545 # @param length Lenght of the language string.
546 # @return description
547 @dbus.service.method('com.victronenergy.BusItem', in_signature='si', out_signature='s')
548 def GetDescription(self, language, length):
549 return self._description if self._description is not None else 'No description given'
550
551 ## Dbus exported method GetValue
552 # Returns the value.
553 # @return the value when valid, and otherwise an empty array
554 @dbus.service.method('com.victronenergy.BusItem', out_signature='v')
555 def GetValue(self):
556 return wrap_dbus_value(self._value)
557
558 ## Dbus exported method GetText
559 # Returns the value as string of the dbus-object-path.
560 # @return text A text-value. '---' when local value is invalid
561 @dbus.service.method('com.victronenergy.BusItem', out_signature='s')
562 def GetText(self):
563 if self._value is None:
564 return '---'
565
566 # Default conversion from dbus.Byte will get you a character (so 'T' instead of '84'), so we
567 # have to convert to int first. Note that if a dbus.Byte turns up here, it must have come from
568 # the application itself, as all data from the D-Bus should have been unwrapped by now.
569 if self._gettextcallback is None and type(self._value) == dbus.Byte:
570 return str(int(self._value))
571
572 if self._gettextcallback is None and self.__dbus_object_path__ == '/ProductId':
573 return "0x%X" % self._value
574
575 if self._gettextcallback is None:
576 return str(self._value)
577
578 return self._gettextcallback(self.__dbus_object_path__, self._value)
579
580 ## The signal that indicates that the value has changed.
581 # Other processes connected to this BusItem object will have subscribed to the
582 # event when they want to track our state.
583 @dbus.service.signal('com.victronenergy.BusItem', signature='a{sv}')
584 def PropertiesChanged(self, changes):
585 pass
586
587 ## This class behaves like a regular reference to a class method (eg. self.foo), but keeps a weak reference
588 ## to the object which method is to be called.
589 ## Use this object to break circular references.
590 class weak_functor:
591 def __init__(self, f):
592 self._r = weakref.ref(f.__self__)
593 self._f = weakref.ref(f.__func__)
594
595 def __call__(self, *args, **kargs):
596 r = self._r()
597 f = self._f()
598 if r == None or f == None:
599 return
600 f(r, *args, **kargs)