dbus2mqtt 0.3.1__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dbus2mqtt might be problematic. Click here for more details.

@@ -1,3 +1,4 @@
1
+ import fnmatch
1
2
  import json
2
3
  import logging
3
4
 
@@ -5,11 +6,19 @@ from datetime import datetime
5
6
  from typing import Any
6
7
 
7
8
  import dbus_fast.aio as dbus_aio
9
+ import dbus_fast.constants as dbus_constants
10
+ import dbus_fast.errors as dbus_errors
8
11
  import dbus_fast.introspection as dbus_introspection
12
+ import dbus_fast.message as dbus_message
13
+ import janus
9
14
 
10
15
  from dbus2mqtt import AppContext
11
- from dbus2mqtt.config import InterfaceConfig, SubscriptionConfig
12
- from dbus2mqtt.dbus.dbus_types import BusNameSubscriptions, SubscribedInterface
16
+ from dbus2mqtt.config import SubscriptionConfig
17
+ from dbus2mqtt.dbus.dbus_types import (
18
+ BusNameSubscriptions,
19
+ DbusSignalWithState,
20
+ SubscribedInterface,
21
+ )
13
22
  from dbus2mqtt.dbus.dbus_util import (
14
23
  camel_to_snake,
15
24
  unwrap_dbus_object,
@@ -19,11 +28,13 @@ from dbus2mqtt.dbus.introspection_patches.mpris_playerctl import (
19
28
  mpris_introspection_playerctl,
20
29
  )
21
30
  from dbus2mqtt.dbus.introspection_patches.mpris_vlc import mpris_introspection_vlc
22
- from dbus2mqtt.event_broker import DbusSignalWithState, MqttMessage
31
+ from dbus2mqtt.event_broker import MqttMessage
23
32
  from dbus2mqtt.flow.flow_processor import FlowScheduler, FlowTriggerMessage
24
33
 
25
34
  logger = logging.getLogger(__name__)
26
35
 
36
+ # TODO: Redo flow registration in _handle_bus_name_added, might want to move that to a separate file
37
+ # TODO: deregister signal watcher on shutdown
27
38
 
28
39
  class DbusClient:
29
40
 
@@ -36,6 +47,13 @@ class DbusClient:
36
47
  self.flow_scheduler = flow_scheduler
37
48
  self.subscriptions: dict[str, BusNameSubscriptions] = {}
38
49
 
50
+ self._dbus_signal_queue = janus.Queue[DbusSignalWithState]()
51
+ self._dbus_object_lifecycle_signal_queue = janus.Queue[dbus_message.Message]()
52
+
53
+ self._name_owner_match_rule = "sender='org.freedesktop.DBus',interface='org.freedesktop.DBus',path='/org/freedesktop/DBus',member='NameOwnerChanged'"
54
+ self._interfaces_added_match_rule = "interface='org.freedesktop.DBus.ObjectManager',type='signal',member='InterfacesAdded'"
55
+ self._interfaces_removed_match_rule = "interface='org.freedesktop.DBus.ObjectManager',type='signal',member='InterfacesRemoved'"
56
+
39
57
  async def connect(self):
40
58
 
41
59
  if not self.bus.connected:
@@ -46,37 +64,124 @@ class DbusClient:
46
64
  else:
47
65
  logger.warning(f"Failed to connect to {self.bus._bus_address}")
48
66
 
67
+ self.bus.add_message_handler(self.object_lifecycle_signal_handler)
68
+
69
+ # Add dbus match rules to get notified of new bus_names or interfaces
70
+ await self._add_match_rule(self._name_owner_match_rule)
71
+ await self._add_match_rule(self._interfaces_added_match_rule)
72
+ await self._add_match_rule(self._interfaces_removed_match_rule)
73
+
49
74
  introspection = await self.bus.introspect('org.freedesktop.DBus', '/org/freedesktop/DBus')
50
75
  obj = self.bus.get_proxy_object('org.freedesktop.DBus', '/org/freedesktop/DBus', introspection)
51
76
  dbus_interface = obj.get_interface('org.freedesktop.DBus')
52
77
 
53
- # subscribe to NameOwnerChanged which allows us to detect new bus_names
54
- dbus_interface.__getattribute__("on_name_owner_changed")(self._dbus_name_owner_changed_callback)
55
-
56
78
  # subscribe to existing registered bus_names we are interested in
57
79
  connected_bus_names = await dbus_interface.__getattribute__("call_list_names")()
58
80
 
59
- new_subscriped_interfaces: list[SubscribedInterface] = []
81
+ new_subscribed_interfaces: list[SubscribedInterface] = []
60
82
  for bus_name in connected_bus_names:
61
- new_subscriped_interfaces.extend(await self._handle_bus_name_added(bus_name))
83
+ new_subscribed_interfaces.extend(await self._handle_bus_name_added(bus_name))
84
+
85
+ logger.info(f"subscriptions on startup: {list(set([si.bus_name for si in new_subscribed_interfaces]))}")
86
+
87
+ async def _add_match_rule(self, match_rule: str):
88
+ reply = await self.bus.call(dbus_message.Message(
89
+ destination='org.freedesktop.DBus',
90
+ path='/org/freedesktop/DBus',
91
+ interface='org.freedesktop.DBus',
92
+ member='AddMatch',
93
+ signature='s',
94
+ body=[(match_rule)]
95
+ ))
96
+ assert reply and reply.message_type == dbus_constants.MessageType.METHOD_RETURN
97
+
98
+ async def _remove_match_rule(self, match_rule: str):
99
+ reply = await self.bus.call(dbus_message.Message(
100
+ destination='org.freedesktop.DBus',
101
+ path='/org/freedesktop/DBus',
102
+ interface='org.freedesktop.DBus',
103
+ member='RemoveMatch',
104
+ signature='s',
105
+ body=[(match_rule)]
106
+ ))
107
+ assert reply and reply.message_type == dbus_constants.MessageType.METHOD_RETURN
108
+
109
+ def get_well_known_bus_name(self, unique_bus_name: str) -> str:
110
+
111
+ for bns in self.subscriptions.values():
112
+ if unique_bus_name == bns.unique_name:
113
+ return bns.bus_name
114
+
115
+ # # dbus_fast keeps track of well known bus_names for the high-level API.
116
+ # # We can use this to find the bus_name for the sender.
117
+ # for k, v in self.bus._name_owners.items():
118
+ # if v == unique_bus_name:
119
+ # return v
120
+
121
+ return unique_bus_name
122
+
123
+ async def get_unique_name(self, name) -> str | None:
124
+
125
+ if name.startswith(":"):
126
+ return name
127
+
128
+ introspect = await self.bus.introspect("org.freedesktop.DBus", "/org/freedesktop/DBus")
129
+ proxy = self.bus.get_proxy_object("org.freedesktop.DBus", "/org/freedesktop/DBus", introspect)
130
+ dbus_interface = proxy.get_interface("org.freedesktop.DBus")
131
+
132
+ return await dbus_interface.call_get_name_owner(name) # type: ignore
133
+
134
+ def object_lifecycle_signal_handler(self, message: dbus_message.Message) -> None:
135
+
136
+ if not message.message_type == dbus_constants.MessageType.SIGNAL:
137
+ return
138
+
139
+ logger.debug(f'object_lifecycle_signal_handler: interface={message.interface}, member={message.member}, body={message.body}')
62
140
 
63
- logger.info(f"subscriptions on startup: {list(set([si.bus_name for si in new_subscriped_interfaces]))}")
141
+ if message.interface in ['org.freedesktop.DBus', 'org.freedesktop.DBus.ObjectManager']:
142
+ self._dbus_object_lifecycle_signal_queue.sync_q.put(message)
64
143
 
65
- def get_proxy_object(self, bus_name: str, path: str) -> dbus_aio.proxy_object.ProxyObject | None:
144
+ def get_bus_name_subscriptions(self, bus_name: str) -> BusNameSubscriptions | None:
66
145
 
67
- bus_name_subscriptions = self.subscriptions.get(bus_name)
146
+ return self.subscriptions.get(bus_name)
147
+
148
+ def get_subscribed_proxy_object(self, bus_name: str, path: str) -> dbus_aio.proxy_object.ProxyObject | None:
149
+
150
+ bus_name_subscriptions = self.get_bus_name_subscriptions(bus_name)
68
151
  if bus_name_subscriptions:
69
152
  proxy_object = bus_name_subscriptions.path_objects.get(path)
70
153
  if proxy_object:
71
154
  return proxy_object
72
155
 
156
+ async def get_subscribed_or_new_proxy_object(self, bus_name: str, path: str) -> dbus_aio.proxy_object.ProxyObject | None:
157
+
158
+ proxy_object = self.get_subscribed_proxy_object(bus_name, path)
159
+ if proxy_object:
160
+ return proxy_object
161
+
162
+ # No existing subscription that contains the requested proxy_object
163
+ logger.warning(f"Returning temporary proxy_object with an additional introspection call, bus_name={bus_name}, path={path}")
164
+ introspection = await self.bus.introspect(bus_name=bus_name, path=path)
165
+ proxy_object = self.bus.get_proxy_object(bus_name, path, introspection)
166
+ if proxy_object:
167
+ return proxy_object
168
+
73
169
  return None
74
170
 
75
- def _ensure_proxy_object_subscription(self, bus_name: str, path: str, introspection: dbus_introspection.Node):
171
+ async def _create_proxy_object_subscription(self, bus_name: str, path: str, introspection: dbus_introspection.Node):
76
172
 
77
- bus_name_subscriptions = self.subscriptions.get(bus_name)
173
+ bus_name_subscriptions = self.get_bus_name_subscriptions(bus_name)
78
174
  if not bus_name_subscriptions:
79
- bus_name_subscriptions = BusNameSubscriptions(bus_name)
175
+
176
+ if bus_name.startswith(":"):
177
+ unique_name = bus_name
178
+ else:
179
+ # make sure we have both the well known and unique bus_name
180
+ unique_name = await self.get_unique_name(bus_name)
181
+
182
+ assert unique_name is not None
183
+
184
+ bus_name_subscriptions = BusNameSubscriptions(bus_name, unique_name)
80
185
  self.subscriptions[bus_name] = bus_name_subscriptions
81
186
 
82
187
  proxy_object = bus_name_subscriptions.path_objects.get(path)
@@ -87,10 +192,25 @@ class DbusClient:
87
192
  return proxy_object, bus_name_subscriptions
88
193
 
89
194
  def _dbus_fast_signal_publisher(self, dbus_signal_state: dict[str, Any], *args):
195
+ """publish a dbus signal to the event broker, one for each subscription_config"""
196
+
90
197
  unwrapped_args = unwrap_dbus_objects(args)
91
- self.event_broker.on_dbus_signal(
92
- DbusSignalWithState(**dbus_signal_state, args=unwrapped_args)
93
- )
198
+
199
+ signal_subscriptions = dbus_signal_state["signal_subscriptions"]
200
+ for signal_subscription in signal_subscriptions:
201
+ subscription_config = signal_subscription["subscription_config"]
202
+ signal_config = signal_subscription["signal_config"]
203
+
204
+ self._dbus_signal_queue.sync_q.put(
205
+ DbusSignalWithState(
206
+ bus_name=dbus_signal_state["bus_name"],
207
+ path=dbus_signal_state["path"],
208
+ interface_name=dbus_signal_state["interface_name"],
209
+ subscription_config=subscription_config,
210
+ signal_config=signal_config,
211
+ args=unwrapped_args
212
+ )
213
+ )
94
214
 
95
215
  def _dbus_fast_signal_handler(self, signal: dbus_introspection.Signal, state: dict[str, Any]) -> Any:
96
216
  expected_args = len(signal.args)
@@ -105,58 +225,81 @@ class DbusClient:
105
225
  return lambda a, b, c, d: self._dbus_fast_signal_publisher(state, a, b, c, d)
106
226
  raise ValueError("Unsupported nr of arguments")
107
227
 
108
- async def _subscribe_interface(self, bus_name: str, path: str, introspection: dbus_introspection.Node, interface: dbus_introspection.Interface, subscription_config: SubscriptionConfig, si: InterfaceConfig) -> SubscribedInterface:
228
+ async def _subscribe_interface_signals(self, bus_name: str, path: str, interface: dbus_introspection.Interface, configured_signals: dict[str, list[dict]]) -> int:
229
+
230
+ proxy_object = self.get_subscribed_proxy_object(bus_name, path)
231
+ assert proxy_object is not None
109
232
 
110
- proxy_object, bus_name_subscriptions = self._ensure_proxy_object_subscription(bus_name, path, introspection)
111
233
  obj_interface = proxy_object.get_interface(interface.name)
112
234
 
113
235
  interface_signals = dict((s.name, s) for s in interface.signals)
114
236
 
115
237
  logger.debug(f"subscribe: bus_name={bus_name}, path={path}, interface={interface.name}, proxy_interface: signals={list(interface_signals.keys())}")
238
+ signal_subscription_count = 0
116
239
 
117
- for signal_config in si.signals:
118
- interface_signal = interface_signals.get(signal_config.signal)
240
+ for signal, signal_subscriptions in configured_signals.items():
241
+ interface_signal = interface_signals.get(signal)
119
242
  if interface_signal:
120
243
 
121
- on_signal_method_name = "on_" + camel_to_snake(signal_config.signal)
244
+ on_signal_method_name = "on_" + camel_to_snake(signal)
122
245
  dbus_signal_state = {
123
246
  "bus_name": bus_name,
124
247
  "path": path,
125
248
  "interface_name": interface.name,
126
- "subscription_config": subscription_config,
127
- "signal_config": signal_config,
249
+ "signal_subscriptions": signal_subscriptions
128
250
  }
129
251
 
130
252
  handler = self._dbus_fast_signal_handler(interface_signal, dbus_signal_state)
131
253
  obj_interface.__getattribute__(on_signal_method_name)(handler)
132
- logger.info(f"subscribed with signal_handler: signal={signal_config.signal}, bus_name={bus_name}, path={path}, interface={interface.name}")
254
+ logger.info(f"subscribed with signal_handler: signal={signal}, bus_name={bus_name}, path={path}, interface={interface.name}")
255
+
256
+ signal_subscription_count += 1
133
257
 
134
258
  else:
135
- logger.warning(f"Invalid signal: signal={signal_config.signal}, bus_name={bus_name}, path={path}, interface={interface.name}")
259
+ logger.warning(f"Invalid signal: signal={signal}, bus_name={bus_name}, path={path}, interface={interface.name}")
136
260
 
137
- return SubscribedInterface(
138
- bus_name=bus_name,
139
- path=path,
140
- interface_name=interface.name,
141
- subscription_config=subscription_config,
142
- interface_config=si
143
- )
261
+ return signal_subscription_count
144
262
 
145
263
  async def _process_interface(self, bus_name: str, path: str, introspection: dbus_introspection.Node, interface: dbus_introspection.Interface) -> list[SubscribedInterface]:
146
264
 
147
265
  logger.debug(f"process_interface: {bus_name}, {path}, {interface.name}")
148
- subscription_configs = self.config.get_subscription_configs(bus_name, path)
266
+
149
267
  new_subscriptions: list[SubscribedInterface] = []
268
+ configured_signals: dict[str, list[dict[str, Any]]] = {}
269
+
270
+ subscription_configs = self.config.get_subscription_configs(bus_name, path)
150
271
  for subscription in subscription_configs:
151
272
  logger.debug(f"processing subscription config: {subscription.bus_name}, {subscription.path}")
152
273
  for subscription_interface in subscription.interfaces:
153
274
  if subscription_interface.interface == interface.name:
154
275
  logger.debug(f"matching config found for bus_name={bus_name}, path={path}, interface={interface.name}")
155
- subscribed_iterface = await self._subscribe_interface(bus_name, path, introspection, interface, subscription, subscription_interface)
156
276
 
157
- new_subscriptions.append(subscribed_iterface)
277
+ # Determine signals we need to subscribe to
278
+ for signal_config in subscription_interface.signals:
279
+ signal_subscriptions = configured_signals.get(signal_config.signal, [])
280
+ signal_subscriptions.append({
281
+ "signal_config": signal_config,
282
+ "subscription_config": subscription
283
+ })
284
+ configured_signals[signal_config.signal] = signal_subscriptions
285
+
286
+ if subscription_interface.signals:
287
+ new_subscriptions.append(SubscribedInterface(
288
+ bus_name=bus_name,
289
+ path=path,
290
+ interface_name=interface.name,
291
+ subscription_config=subscription
292
+ ))
293
+
294
+ if len(configured_signals) > 0:
295
+
296
+ signal_subscription_count = await self._subscribe_interface_signals(
297
+ bus_name, path, interface, configured_signals
298
+ )
299
+ if signal_subscription_count > 0:
300
+ return new_subscriptions
158
301
 
159
- return new_subscriptions
302
+ return []
160
303
 
161
304
  async def _introspect(self, bus_name: str, path: str) -> dbus_introspection.Node:
162
305
 
@@ -174,93 +317,107 @@ class DbusClient:
174
317
 
175
318
  return introspection
176
319
 
177
- async def _visit_bus_name_path(self, bus_name: str, path: str) -> list[SubscribedInterface]:
320
+ async def _list_bus_name_paths(self, bus_name: str, path: str) -> list[str]:
321
+ """list all nested paths. Only paths that have interfaces are returned"""
178
322
 
179
- new_subscriptions: list[SubscribedInterface] = []
323
+ paths: list[str] = []
180
324
 
181
325
  try:
182
326
  introspection = await self._introspect(bus_name, path)
183
327
  except TypeError as e:
184
328
  logger.warning(f"bus.introspect failed, bus_name={bus_name}, path={path}: {e}")
185
- return new_subscriptions
329
+ return paths
186
330
 
187
331
  if len(introspection.nodes) == 0:
188
332
  logger.debug(f"leaf node: bus_name={bus_name}, path={path}, is_root={introspection.is_root}, interfaces={[i.name for i in introspection.interfaces]}")
189
333
 
190
- for interface in introspection.interfaces:
191
- new_subscriptions.extend(
192
- await self._process_interface(bus_name, path, introspection, interface)
193
- )
334
+ if len(introspection.interfaces) > 0:
335
+ paths.append(path)
194
336
 
195
337
  for node in introspection.nodes:
196
338
  path_seperator = "" if path.endswith('/') else "/"
197
- new_subscriptions.extend(
198
- await self._visit_bus_name_path(bus_name, f"{path}{path_seperator}{node.name}")
339
+ paths.extend(
340
+ await self._list_bus_name_paths(bus_name, f"{path}{path_seperator}{node.name}")
199
341
  )
200
342
 
201
- return new_subscriptions
202
-
203
- async def _handle_bus_name_added(self, bus_name: str) -> list[SubscribedInterface]:
343
+ return paths
204
344
 
345
+ async def _subscribe_dbus_object(self, bus_name: str, path: str) -> list[SubscribedInterface]:
346
+ """Subscribes to a dbus object at the given bus_name and path.
347
+ For each matching subscription config, subscribe to all configured interfaces,
348
+ start listening to signals and start/register flows if configured.
349
+ """
205
350
  if not self.config.is_bus_name_configured(bus_name):
206
351
  return []
207
352
 
208
- # sanity checks
209
- for umh in self.bus._user_message_handlers:
210
- umh_bus_name = umh.__self__.bus_name
211
- if umh_bus_name == bus_name:
212
- logger.warning(f"handle_bus_name_added: {umh_bus_name} already added")
353
+ new_subscriptions: list[SubscribedInterface] = []
213
354
 
214
- new_subscriped_interfaces = await self._visit_bus_name_path(bus_name, "/")
355
+ try:
356
+ introspection = await self._introspect(bus_name, path)
357
+ except TypeError as e:
358
+ logger.warning(f"bus.introspect failed, bus_name={bus_name}, path={path}: {e}")
359
+ return new_subscriptions
215
360
 
216
- logger.info(f"new_subscriptions: {list(set([si.bus_name for si in new_subscriped_interfaces]))}")
361
+ if len(introspection.interfaces) == 0:
362
+ logger.warning(f"Skipping dbus_object subscription, no interfaces found for bus_name={bus_name}, path={path}")
363
+ return new_subscriptions
217
364
 
218
- # setup and process triggers for each flow in each subscription
219
- processed_new_subscriptions: set[str] = set()
365
+ interfaces_names = [i.name for i in introspection.interfaces]
366
+ logger.info(f"subscribe_dbus_object: bus_name={bus_name}, path={path}, interfaces={interfaces_names}")
220
367
 
221
- # With all subscriptions in place, we can now ensure schedulers are created
222
- # create a FlowProcessor per bus_name/path subscription?
223
- # One global or a per subscription FlowProcessor.flow_processor_task?
224
- # Start a new timer job, but leverage existing FlowScheduler
225
- # How does the FlowScheduler now it should invoke the local FlowPocessor?
226
- # Maybe use queues to communicate from here with the FlowProcessor?
227
- # e.g.: StartFlows, StopFlows,
368
+ await self._create_proxy_object_subscription(bus_name, path, introspection)
369
+
370
+ for interface in introspection.interfaces:
371
+ new_subscriptions.extend(
372
+ await self._process_interface(bus_name, path, introspection, interface)
373
+ )
228
374
 
229
- for subscribed_interface in new_subscriped_interfaces:
375
+ return new_subscriptions
230
376
 
231
- subscription_config = subscribed_interface.subscription_config
232
- if subscription_config.id not in processed_new_subscriptions:
377
+ async def _handle_bus_name_added(self, bus_name: str) -> list[SubscribedInterface]:
233
378
 
234
- # Ensure all schedulers are started
235
- self.flow_scheduler.start_flow_set(subscription_config.flows)
379
+ logger.debug(f"_handle_bus_name_added: bus_name={bus_name}")
236
380
 
237
- # Trigger flows that have a bus_name_added trigger configured
238
- await self._trigger_bus_name_added(subscription_config, subscribed_interface.bus_name, subscribed_interface.path)
381
+ if not self.config.is_bus_name_configured(bus_name):
382
+ return []
239
383
 
240
- processed_new_subscriptions.add(subscription_config.id)
384
+ object_paths = []
385
+ subscription_configs = self.config.get_subscription_configs(bus_name=bus_name)
386
+ for subscription_config in subscription_configs:
241
387
 
242
- return new_subscriped_interfaces
388
+ # if configured path is not a wildcard, use it
389
+ if "*" not in subscription_config.path:
390
+ object_paths.append(subscription_config.path)
391
+ else:
392
+ # if configured path is a wildcard, use introspection to find all paths
393
+ # and filter by subscription_config.path
394
+ introspected_paths = await self._list_bus_name_paths(bus_name, "/")
395
+ logger.debug(f"introspected paths for bus_name: {bus_name}, paths: {introspected_paths}")
396
+ for path in introspected_paths:
397
+ if fnmatch.fnmatchcase(path, subscription_config.path):
398
+ object_paths.append(path)
243
399
 
244
- async def _trigger_bus_name_added(self, subscription_config: SubscriptionConfig, bus_name: str, path: str):
400
+ # dedupe
401
+ object_paths = list(set(object_paths))
245
402
 
246
- for flow in subscription_config.flows:
247
- for trigger in flow.triggers:
248
- if trigger.type == "bus_name_added":
249
- trigger_context = {
250
- "bus_name": bus_name,
251
- "path": path
252
- }
253
- trigger_message = FlowTriggerMessage(
254
- flow,
255
- trigger,
256
- datetime.now(),
257
- trigger_context=trigger_context
258
- )
259
- await self.event_broker.flow_trigger_queue.async_q.put(trigger_message)
403
+ new_subscribed_interfaces = []
404
+
405
+ # for each object path, call _subscribe_dbus_object
406
+ for object_path in object_paths:
407
+ subscribed_object_interfaces = await self._subscribe_dbus_object(bus_name, object_path)
408
+ new_subscribed_interfaces.extend(subscribed_object_interfaces)
409
+
410
+ # start all flows for the new subscriptions
411
+ if len(new_subscribed_interfaces) > 0:
412
+ await self._start_subscription_flows(bus_name, new_subscribed_interfaces)
413
+
414
+ return new_subscribed_interfaces
260
415
 
261
416
  async def _handle_bus_name_removed(self, bus_name: str):
262
417
 
263
- bus_name_subscriptions = self.subscriptions.get(bus_name)
418
+ logger.debug(f"_handle_bus_name_removed: bus_name={bus_name}")
419
+
420
+ bus_name_subscriptions = self.get_bus_name_subscriptions(bus_name)
264
421
 
265
422
  if bus_name_subscriptions:
266
423
  for path, proxy_object in bus_name_subscriptions.path_objects.items():
@@ -268,11 +425,16 @@ class DbusClient:
268
425
  subscription_configs = self.config.get_subscription_configs(bus_name=bus_name, path=path)
269
426
  for subscription_config in subscription_configs:
270
427
 
271
- # Trigger flows that have a bus_name_added trigger configured
428
+ # Stop schedule triggers. Only done once per subscription_config
429
+ # TODO: Dont stop when other bus_names are using the same flowset
430
+ self.flow_scheduler.stop_flow_set(subscription_config.flows)
431
+
432
+ # Trigger flows that have a bus_name_removed trigger configured
272
433
  await self._trigger_bus_name_removed(subscription_config, bus_name, path)
273
434
 
274
- # Stop schedule triggers
275
- self.flow_scheduler.stop_flow_set(subscription_config.flows)
435
+ # Trigger flows that have an object_removed trigger configured
436
+ await self._trigger_object_removed(subscription_config, bus_name, path)
437
+
276
438
 
277
439
  # Wait for completion
278
440
  await self.event_broker.flow_trigger_queue.async_q.join()
@@ -293,39 +455,193 @@ class DbusClient:
293
455
 
294
456
  del self.subscriptions[bus_name]
295
457
 
296
- async def _trigger_bus_name_removed(self, subscription_config: SubscriptionConfig, bus_name: str, path: str):
458
+ async def _handle_interfaces_added(self, bus_name: str, path: str) -> None:
459
+ """
460
+ Handles the addition of new D-Bus interfaces for a given bus name and object path.
461
+
462
+ This method checks if there are subscription configurations for the specified bus name and path.
463
+ If so, it subscribes to the D-Bus object and starts the necessary subscription flows for any new interfaces.
464
+
465
+ Args:
466
+ bus_name (str): The well-known name of the D-Bus service where the interface was added.
467
+ path (str): The object path on the D-Bus where the interface was added.
468
+ """
469
+
470
+ logger.debug(f"_handle_interfaces_added: bus_name={bus_name}, path={path}")
471
+
472
+ if not self.config.get_subscription_configs(bus_name=bus_name, path=path):
473
+ return
474
+
475
+ new_subscribed_interfaces = await self._subscribe_dbus_object(bus_name, path)
476
+
477
+ # start all flows for the new subscriptions
478
+ if len(new_subscribed_interfaces) > 0:
479
+ await self._start_subscription_flows(bus_name, new_subscribed_interfaces)
480
+
481
+ async def _handle_interfaces_removed(self, bus_name: str, path: str) -> None:
482
+
483
+ logger.debug(f"_handle_interfaces_removed: bus_name={bus_name}, path={path}")
484
+
485
+ subscription_configs = self.config.get_subscription_configs(bus_name=bus_name, path=path)
486
+ for subscription_config in subscription_configs:
487
+
488
+ # Stop schedule triggers. Only done once per subscription_config and not per path
489
+ # TODO, only stop if this subscription is not used for any other objects / paths
490
+ self.flow_scheduler.stop_flow_set(subscription_config.flows)
491
+
492
+ # Trigger flows that have an object_removed trigger configured
493
+ await self._trigger_object_removed(subscription_config, bus_name, path)
494
+
495
+ proxy_object = self.get_subscribed_proxy_object(bus_name, path)
496
+ if proxy_object is not None:
497
+
498
+ # Wait for completion
499
+ await self.event_broker.flow_trigger_queue.async_q.join()
500
+
501
+ # clean up all dbus matchrules
502
+ for interface in proxy_object._interfaces.values():
503
+ proxy_interface: dbus_aio.proxy_object.ProxyInterface = interface
504
+
505
+ # officially you should do 'off_...' but the below is easier
506
+ # proxy_interface.off_properties_changed(self.on_properties_changed)
507
+
508
+ # clean lingering interface matchrule from bus
509
+ if proxy_interface._signal_match_rule in self.bus._match_rules.keys():
510
+ self.bus._remove_match_rule(proxy_interface._signal_match_rule)
511
+
512
+ # clean lingering interface messgage handler from bus
513
+ self.bus.remove_message_handler(proxy_interface._message_handler)
514
+
515
+ # For now that InterfacesRemoved signal means the entire object is removed form D-Bus
516
+ del self.subscriptions[bus_name].path_objects[path]
517
+
518
+ # cleanup the entire BusNameSubscriptions if no more objects are subscribed
519
+ bus_name_subscriptions = self.get_bus_name_subscriptions(bus_name)
520
+ if bus_name_subscriptions and len(bus_name_subscriptions.path_objects) == 0:
521
+ del self.subscriptions[bus_name]
522
+
523
+ async def _start_subscription_flows(self, bus_name: str, subscribed_interfaces: list[SubscribedInterface]):
524
+ """Start all flows for the new subscriptions.
525
+ For each matching bus_name-path subscription_config, the following is done:
526
+ 1. Ensure the scheduler is started, at most one scheduler will be active for a subscription_config
527
+ 2. Trigger flows that have a bus_name_added trigger configured (only once per bus_name)
528
+ 3. Trigger flows that have a interfaces_added trigger configured (once for each bus_name-path pair)
529
+ """
530
+
531
+ bus_name_object_paths = {}
532
+ bus_name_object_path_interfaces = {}
533
+ for si in subscribed_interfaces:
534
+ bus_name_object_paths.setdefault(si.bus_name, [])
535
+ bus_name_object_path_interfaces.setdefault(si.bus_name, {}).setdefault(si.path, [])
536
+
537
+ if si.path not in bus_name_object_paths[si.bus_name]:
538
+ bus_name_object_paths[si.bus_name].append(si.path)
539
+
540
+ bus_name_object_path_interfaces[si.bus_name][si.path].append(si.interface_name)
541
+
542
+
543
+ # new_subscribed_bus_names = list(set([si.bus_name for si in subscribed_interfaces]))
544
+ # new_subscribed_bus_names_paths = {
545
+ # bus_name: list(set([si.path for si in subscribed_interfaces if si.bus_name == bus_name]))
546
+ # for bus_name in new_subscribed_bus_names
547
+ # }
548
+
549
+ logger.debug(f"_start_subscription_flows: ew_subscriptions: {list(bus_name_object_paths.keys())}")
550
+ logger.debug(f"_start_subscription_flows: new_bus_name_object_paths: {bus_name_object_paths}")
551
+
552
+ # setup and process triggers for each flow in each subscription
553
+ # just once per subscription_config
554
+ processed_new_subscriptions: set[str] = set()
555
+
556
+ # With all subscriptions in place, we can now ensure schedulers are created
557
+ # create a FlowProcessor per bus_name/path subscription?
558
+ # One global or a per subscription FlowProcessor.flow_processor_task?
559
+ # Start a new timer job, but leverage existing FlowScheduler
560
+ # How does the FlowScheduler now it should invoke the local FlowPocessor?
561
+ # Maybe use queues to communicate from here with the FlowProcessor?
562
+ # e.g.: StartFlows, StopFlows,
563
+
564
+ # for each bus_name
565
+ for bus_name, path_interfaces_map in bus_name_object_path_interfaces.items():
566
+
567
+ paths = list(path_interfaces_map.keys())
568
+
569
+ # for each path in the bus_name
570
+ for object_path in paths:
571
+
572
+ object_interfaces = path_interfaces_map[object_path]
573
+
574
+ # For each subscription_config that matches the bus_name and object_path
575
+ subscription_configs = self.config.get_subscription_configs(bus_name, object_path)
576
+ for subscription_config in subscription_configs:
577
+
578
+ # Only process subscription_config once, no matter how many paths it matches
579
+ if subscription_config.id not in processed_new_subscriptions:
580
+
581
+ # Ensure all schedulers are started
582
+ # If a scheduler is already active for this subscription flow, it will be reused
583
+ self.flow_scheduler.start_flow_set(subscription_config.flows)
584
+
585
+ # Trigger flows that have a bus_name_added trigger configured
586
+
587
+ # TODO: path arg doesn't make sense here, it did work for mpris however where there is only one path
588
+ # leaving it now for backwards compatibility
589
+ await self._trigger_bus_name_added(subscription_config, bus_name, object_path)
590
+
591
+ processed_new_subscriptions.add(subscription_config.id)
592
+
593
+ # Trigger flows that have a object_added trigger configured
594
+ await self._trigger_object_added(subscription_config, bus_name, object_path, object_interfaces)
595
+
596
+ async def _trigger_flows(self, subscription_config: SubscriptionConfig, type: str, context: dict):
297
597
 
298
- # Trigger flows that have a bus_name_removed trigger configured
299
598
  for flow in subscription_config.flows:
300
599
  for trigger in flow.triggers:
301
- if trigger.type == "bus_name_removed":
302
- trigger_context = {
303
- "bus_name": bus_name,
304
- "path": path
305
- }
306
- trigger_message = FlowTriggerMessage(
307
- flow,
308
- trigger,
309
- datetime.now(),
310
- trigger_context=trigger_context
311
- )
600
+ if trigger.type == type:
601
+ trigger_message = FlowTriggerMessage(flow, trigger, datetime.now(), context)
312
602
  await self.event_broker.flow_trigger_queue.async_q.put(trigger_message)
313
603
 
314
- async def _dbus_name_owner_changed_callback(self, name, old_owner, new_owner):
604
+ async def _trigger_bus_name_added(self, subscription_config: SubscriptionConfig, bus_name: str, path: str):
605
+
606
+ # Trigger flows that have a bus_name_added trigger configured
607
+ await self._trigger_flows(subscription_config, "bus_name_added", {
608
+ "bus_name": bus_name,
609
+ "path": path
610
+ })
611
+
612
+ async def _trigger_bus_name_removed(self, subscription_config: SubscriptionConfig, bus_name: str, path: str):
613
+
614
+ # Trigger flows that have a bus_name_removed trigger configured
615
+ await self._trigger_flows(subscription_config, "bus_name_removed", {
616
+ "bus_name": bus_name,
617
+ "path": path
618
+ })
619
+
620
+ async def _trigger_object_added(self, subscription_config: SubscriptionConfig, bus_name: str, object_path: str, object_interfaces: list[str]):
621
+
622
+ # Trigger flows that have a object_added trigger configured
623
+ await self._trigger_flows(subscription_config, "object_added", {
624
+ "bus_name": bus_name,
625
+ "path": object_path
626
+ # "interfaces": object_interfaces
627
+ })
315
628
 
316
- logger.debug(f'NameOwnerChanged: name=q{name}, old_owner={old_owner}, new_owner={new_owner}')
629
+ async def _trigger_object_removed(self, subscription_config: SubscriptionConfig, bus_name: str, path: str):
317
630
 
318
- if new_owner and not old_owner:
319
- logger.debug(f'NameOwnerChanged.new: name={name}')
320
- await self._handle_bus_name_added(name)
321
- if old_owner and not new_owner:
322
- logger.debug(f'NameOwnerChanged.old: name={name}')
323
- await self._handle_bus_name_removed(name)
631
+ # Trigger flows that have a object_removed trigger configured
632
+ await self._trigger_flows(subscription_config, "object_removed", {
633
+ "bus_name": bus_name,
634
+ "path": path
635
+ })
324
636
 
325
637
  async def call_dbus_interface_method(self, interface: dbus_aio.proxy_object.ProxyInterface, method: str, method_args: list[Any]):
326
638
 
327
639
  call_method_name = "call_" + camel_to_snake(method)
328
- res = await interface.__getattribute__(call_method_name)(*method_args)
640
+ try:
641
+ res = await interface.__getattribute__(call_method_name)(*method_args)
642
+ except dbus_errors.DBusError as e:
643
+ logger.warning(f"Error while calling dbus object, bus_name={interface.bus_name}, interface={interface.introspection.name}, method={method}")
644
+ raise e
329
645
 
330
646
  if res:
331
647
  res = unwrap_dbus_object(res)
@@ -367,9 +683,16 @@ class DbusClient:
367
683
  async def dbus_signal_queue_processor_task(self):
368
684
  """Continuously processes messages from the async queue."""
369
685
  while True:
370
- signal = await self.event_broker.dbus_signal_queue.async_q.get() # Wait for a message
686
+ signal = await self._dbus_signal_queue.async_q.get()
371
687
  await self._handle_on_dbus_signal(signal)
372
- self.event_broker.dbus_signal_queue.async_q.task_done()
688
+ self._dbus_signal_queue.async_q.task_done()
689
+
690
+ async def dbus_object_lifecycle_signal_processor_task(self):
691
+ """Continuously processes messages from the async queue."""
692
+ while True:
693
+ message = await self._dbus_object_lifecycle_signal_queue.async_q.get()
694
+ await self._handle_dbus_object_lifecycle_signal(message)
695
+ self._dbus_object_lifecycle_signal_queue.async_q.task_done()
373
696
 
374
697
  async def _handle_on_dbus_signal(self, signal: DbusSignalWithState):
375
698
 
@@ -390,6 +713,7 @@ class DbusClient:
390
713
  "bus_name": signal.bus_name,
391
714
  "path": signal.path,
392
715
  "interface": signal.interface_name,
716
+ "signal": signal.signal_config.signal,
393
717
  "args": signal.args
394
718
  }
395
719
  trigger_message = FlowTriggerMessage(
@@ -403,11 +727,31 @@ class DbusClient:
403
727
  except Exception as e:
404
728
  logger.warning(f"dbus_signal_queue_processor_task: Exception {e}", exc_info=True)
405
729
 
730
+ async def _handle_dbus_object_lifecycle_signal(self, message: dbus_message.Message):
731
+
732
+ if message.member == 'NameOwnerChanged':
733
+ name, old_owner, new_owner = message.body
734
+ if new_owner != '' and old_owner == '':
735
+ await self._handle_bus_name_added(name)
736
+ if old_owner != '' and new_owner == '':
737
+ await self._handle_bus_name_removed(name)
738
+
739
+ if message.interface == 'org.freedesktop.DBus.ObjectManager':
740
+ bus_name = self.get_well_known_bus_name(message.sender)
741
+ if message.member == 'InterfacesAdded':
742
+ path = message.body[0]
743
+ await self._handle_interfaces_added(bus_name, path)
744
+ elif message.member == 'InterfacesRemoved':
745
+ path = message.body[0]
746
+ await self._handle_interfaces_removed(bus_name, path)
747
+
406
748
  async def _on_mqtt_msg(self, msg: MqttMessage):
407
- # self.queue.put({
408
- # "topic": topic,
409
- # "payload": payload
410
- # })
749
+ """Executes dbus method calls or property updates on objects when messages have
750
+ 1. a matching subscription configured
751
+ 2. a matching method
752
+ 3. a matching bus_name (if provided)
753
+ 4. a matching path (if provided)
754
+ """
411
755
 
412
756
  found_matching_topic = False
413
757
  for subscription_configs in self.config.subscriptions:
@@ -423,6 +767,9 @@ class DbusClient:
423
767
  matched_method = False
424
768
  matched_property = False
425
769
 
770
+ payload_bus_name = msg.payload.get("bus_name") or "*"
771
+ payload_path = msg.payload.get("path") or "*"
772
+
426
773
  payload_method = msg.payload.get("method")
427
774
  payload_method_args = msg.payload.get("args") or []
428
775
 
@@ -430,47 +777,44 @@ class DbusClient:
430
777
  payload_value = msg.payload.get("value")
431
778
 
432
779
  if payload_method is None and (payload_property is None or payload_value is None):
433
- logger.info(f"on_mqtt_msg: Unsupported payload, missing 'method' or 'property/value', got method={payload_method}, property={payload_property}, value={payload_value} from {msg.payload}")
780
+ if msg.payload:
781
+ logger.info(f"on_mqtt_msg: Unsupported payload, missing 'method' or 'property/value', got method={payload_method}, property={payload_property}, value={payload_value} from {msg.payload}")
434
782
  return
435
783
 
436
784
  for [bus_name, bus_name_subscription] in self.subscriptions.items():
437
- for [path, proxy_object] in bus_name_subscription.path_objects.items():
438
- for subscription_configs in self.config.get_subscription_configs(bus_name=bus_name, path=path):
439
- for interface_config in subscription_configs.interfaces:
440
-
441
- for method in interface_config.methods:
442
-
443
- # filter configured method, configured topic, ...
444
- if method.method == payload_method:
445
- interface = proxy_object.get_interface(name=interface_config.interface)
446
- matched_method = True
447
-
448
- try:
449
- logger.info(f"on_mqtt_msg: method={method.method}, args={payload_method_args}, bus_name={bus_name}, path={path}, interface={interface_config.interface}")
450
- await self.call_dbus_interface_method(interface, method.method, payload_method_args)
451
- except Exception as e:
452
- logger.warning(f"on_mqtt_msg: method={method.method}, args={payload_method_args}, bus_name={bus_name} failed, exception={e}")
453
-
454
- for property in interface_config.properties:
455
- # filter configured property, configured topic, ...
456
- if property.property == payload_property:
457
- interface = proxy_object.get_interface(name=interface_config.interface)
458
- matched_property = True
459
-
460
- try:
461
- logger.info(f"on_mqtt_msg: property={property.property}, value={payload_value}, bus_name={bus_name}, path={path}, interface={interface_config.interface}")
462
- await self.set_dbus_interface_property(interface, property.property, payload_value)
463
- except Exception as e:
464
- logger.warning(f"on_mqtt_msg: property={property.property}, value={payload_value}, bus_name={bus_name} failed, exception={e}")
785
+ if fnmatch.fnmatchcase(bus_name, payload_bus_name):
786
+ for [path, proxy_object] in bus_name_subscription.path_objects.items():
787
+ if fnmatch.fnmatchcase(path, payload_path):
788
+ for subscription_configs in self.config.get_subscription_configs(bus_name=bus_name, path=path):
789
+ for interface_config in subscription_configs.interfaces:
790
+
791
+ for method in interface_config.methods:
792
+
793
+ # filter configured method, configured topic, ...
794
+ if method.method == payload_method:
795
+ interface = proxy_object.get_interface(name=interface_config.interface)
796
+ matched_method = True
797
+
798
+ try:
799
+ logger.info(f"on_mqtt_msg: method={method.method}, args={payload_method_args}, bus_name={bus_name}, path={path}, interface={interface_config.interface}")
800
+ await self.call_dbus_interface_method(interface, method.method, payload_method_args)
801
+ except Exception as e:
802
+ logger.warning(f"on_mqtt_msg: method={method.method}, args={payload_method_args}, bus_name={bus_name} failed, exception={e}")
803
+
804
+ for property in interface_config.properties:
805
+ # filter configured property, configured topic, ...
806
+ if property.property == payload_property:
807
+ interface = proxy_object.get_interface(name=interface_config.interface)
808
+ matched_property = True
809
+
810
+ try:
811
+ logger.info(f"on_mqtt_msg: property={property.property}, value={payload_value}, bus_name={bus_name}, path={path}, interface={interface_config.interface}")
812
+ await self.set_dbus_interface_property(interface, property.property, payload_value)
813
+ except Exception as e:
814
+ logger.warning(f"on_mqtt_msg: property={property.property}, value={payload_value}, bus_name={bus_name} failed, exception={e}")
465
815
 
466
816
  if not matched_method and not matched_property:
467
817
  if payload_method:
468
- logger.info(f"No configured or active dbus subscriptions for topic={msg.topic}, method={payload_method}, active bus_names={list(self.subscriptions.keys())}")
818
+ logger.info(f"No configured or active dbus subscriptions for topic={msg.topic}, method={payload_method}, bus_name={payload_bus_name}, path={payload_path or '*'}, active bus_names={list(self.subscriptions.keys())}")
469
819
  if payload_property:
470
- logger.info(f"No configured or active dbus subscriptions for topic={msg.topic}, property={payload_property}, active bus_names={list(self.subscriptions.keys())}")
471
-
472
- # raw mode, payload contains: bus_name (specific or wildcard), path, interface_name
473
- # topic: dbus2mqtt/raw (with allowlist check)
474
-
475
- # predefined mode with topic matching from configuration
476
- # topic: dbus2mqtt/MediaPlayer/command
820
+ logger.info(f"No configured or active dbus subscriptions for topic={msg.topic}, property={payload_property}, bus_name={payload_bus_name}, path={payload_path or '*'}, active bus_names={list(self.subscriptions.keys())}")