dbus2mqtt 0.3.1__py3-none-any.whl → 0.4.0__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,5 +1,6 @@
1
1
  import fnmatch
2
2
  import uuid
3
+ import warnings
3
4
 
4
5
  from dataclasses import dataclass, field
5
6
  from typing import Annotated, Any, Literal
@@ -65,15 +66,29 @@ class FlowTriggerDbusSignalConfig:
65
66
  @dataclass
66
67
  class FlowTriggerBusNameAddedConfig:
67
68
  type: Literal["bus_name_added"] = "bus_name_added"
68
- # filter: str | None = None
69
+
70
+ def __post_init__(self):
71
+ warnings.warn(f"{self.type} flow trigger may be removed in a future version.", DeprecationWarning, stacklevel=2)
69
72
 
70
73
  @dataclass
71
74
  class FlowTriggerBusNameRemovedConfig:
72
75
  type: Literal["bus_name_removed"] = "bus_name_removed"
76
+
77
+ def __post_init__(self):
78
+ warnings.warn(f"{self.type} flow trigger may be removed in a future version.", DeprecationWarning, stacklevel=2)
79
+
80
+ @dataclass
81
+ class FlowTriggerObjectAddedConfig:
82
+ type: Literal["object_added"] = "object_added"
83
+ # filter: str | None = None
84
+
85
+ @dataclass
86
+ class FlowTriggerObjectRemovedConfig:
87
+ type: Literal["object_removed"] = "object_removed"
73
88
  # filter: str | None = None
74
89
 
75
90
  FlowTriggerConfig = Annotated[
76
- FlowTriggerMqttConfig | FlowTriggerScheduleConfig | FlowTriggerDbusSignalConfig | FlowTriggerBusNameAddedConfig | FlowTriggerBusNameRemovedConfig,
91
+ FlowTriggerMqttConfig | FlowTriggerScheduleConfig | FlowTriggerDbusSignalConfig | FlowTriggerBusNameAddedConfig | FlowTriggerBusNameRemovedConfig | FlowTriggerObjectAddedConfig | FlowTriggerObjectRemovedConfig,
77
92
  Field(discriminator="type")
78
93
  ]
79
94
 
@@ -117,6 +132,7 @@ class SubscriptionConfig:
117
132
  @dataclass
118
133
  class DbusConfig:
119
134
  subscriptions: list[SubscriptionConfig]
135
+ bus_type: Literal["SESSION", "SYSTEM"] = "SESSION"
120
136
 
121
137
  def is_bus_name_configured(self, bus_name: str) -> bool:
122
138
 
@@ -125,11 +141,14 @@ class DbusConfig:
125
141
  return True
126
142
  return False
127
143
 
128
- def get_subscription_configs(self, bus_name: str, path: str) -> list[SubscriptionConfig]:
144
+ def get_subscription_configs(self, bus_name: str, path: str|None = None) -> list[SubscriptionConfig]:
129
145
  res: list[SubscriptionConfig] = []
130
146
  for subscription in self.subscriptions:
131
- if fnmatch.fnmatchcase(bus_name, subscription.bus_name) and path == subscription.path:
132
- res.append(subscription)
147
+ if fnmatch.fnmatchcase(bus_name, subscription.bus_name):
148
+ if not path or path == subscription.path:
149
+ res.append(subscription)
150
+ elif fnmatch.fnmatchcase(path, subscription.path):
151
+ res.append(subscription)
133
152
  return res
134
153
 
135
154
  @dataclass
@@ -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
62
138
 
63
- logger.info(f"subscriptions on startup: {list(set([si.bus_name for si in new_subscriped_interfaces]))}")
139
+ logger.debug(f'object_lifecycle_signal_handler: interface={message.interface}, member={message.member}, body={message.body}')
64
140
 
65
- def get_proxy_object(self, bus_name: str, path: str) -> dbus_aio.proxy_object.ProxyObject | None:
141
+ if message.interface in ['org.freedesktop.DBus', 'org.freedesktop.DBus.ObjectManager']:
142
+ self._dbus_object_lifecycle_signal_queue.sync_q.put(message)
66
143
 
67
- bus_name_subscriptions = self.subscriptions.get(bus_name)
144
+ def get_bus_name_subscriptions(self, bus_name: str) -> BusNameSubscriptions | None:
145
+
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)
228
369
 
229
- for subscribed_interface in new_subscriped_interfaces:
370
+ for interface in introspection.interfaces:
371
+ new_subscriptions.extend(
372
+ await self._process_interface(bus_name, path, introspection, interface)
373
+ )
230
374
 
231
- subscription_config = subscribed_interface.subscription_config
232
- if subscription_config.id not in processed_new_subscriptions:
375
+ return new_subscriptions
233
376
 
234
- # Ensure all schedulers are started
235
- self.flow_scheduler.start_flow_set(subscription_config.flows)
377
+ async def _handle_bus_name_added(self, bus_name: str) -> list[SubscribedInterface]:
236
378
 
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)
379
+ logger.debug(f"_handle_bus_name_added: bus_name={bus_name}")
239
380
 
240
- processed_new_subscriptions.add(subscription_config.id)
381
+ if not self.config.is_bus_name_configured(bus_name):
382
+ return []
241
383
 
242
- return new_subscriped_interfaces
384
+ object_paths = []
385
+ subscription_configs = self.config.get_subscription_configs(bus_name=bus_name)
386
+ for subscription_config in subscription_configs:
243
387
 
244
- async def _trigger_bus_name_added(self, subscription_config: SubscriptionConfig, bus_name: str, path: str):
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)
245
399
 
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)
400
+ # dedupe
401
+ object_paths = list(set(object_paths))
402
+
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]):
315
621
 
316
- logger.debug(f'NameOwnerChanged: name=q{name}, old_owner={old_owner}, new_owner={new_owner}')
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
+ })
317
628
 
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)
629
+ async def _trigger_object_removed(self, subscription_config: SubscriptionConfig, bus_name: str, path: str):
630
+
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,6 +727,25 @@ 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
+
748
+
406
749
  async def _on_mqtt_msg(self, msg: MqttMessage):
407
750
  # self.queue.put({
408
751
  # "topic": topic,
@@ -1,23 +1,37 @@
1
1
 
2
2
 
3
3
  from dataclasses import dataclass
4
+ from typing import Any
4
5
 
5
6
  import dbus_fast.aio as dbus_aio
6
7
 
7
- from dbus2mqtt.config import InterfaceConfig, SubscriptionConfig
8
+ from dbus2mqtt.config import (
9
+ SignalConfig,
10
+ SubscriptionConfig,
11
+ )
8
12
 
9
13
 
10
14
  class BusNameSubscriptions:
11
15
 
12
- def __init__(self, bus_name: str):
16
+ def __init__(self, bus_name: str, unique_name: str):
13
17
  self.bus_name = bus_name
18
+ self.unique_name = unique_name
14
19
  self.path_objects: dict[str, dbus_aio.proxy_object.ProxyObject] = {}
15
20
 
16
21
  @dataclass
17
22
  class SubscribedInterface:
18
23
 
19
- interface_config: InterfaceConfig
24
+ # interface_config: InterfaceConfig
20
25
  subscription_config: SubscriptionConfig
21
26
  bus_name: str
22
27
  path: str
23
28
  interface_name: str
29
+
30
+ @dataclass
31
+ class DbusSignalWithState:
32
+ bus_name: str
33
+ path: str
34
+ interface_name: str
35
+ subscription_config: SubscriptionConfig
36
+ signal_config: SignalConfig
37
+ args: list[Any]
@@ -1,19 +1,20 @@
1
- import json
1
+ import base64
2
2
  import re
3
3
 
4
4
  import dbus_fast.signature as dbus_signature
5
5
 
6
6
 
7
- def _variant_serializer(obj):
8
- if isinstance(obj, dbus_signature.Variant):
9
- return obj.value
10
- return obj
11
-
12
- def unwrap_dbus_object(o):
13
- # an easy way to get rid of dbus_fast.signature.Variant types
14
- res = json.dumps(o, default=_variant_serializer)
15
- json_obj = json.loads(res)
16
- return json_obj
7
+ def unwrap_dbus_object(obj):
8
+ if isinstance(obj, dict):
9
+ return {k: unwrap_dbus_object(v) for k, v in obj.items()}
10
+ elif isinstance(obj, list | tuple | set):
11
+ return type(obj)(unwrap_dbus_object(i) for i in obj)
12
+ elif isinstance(obj, dbus_signature.Variant):
13
+ return unwrap_dbus_object(obj.value)
14
+ elif isinstance(obj, bytes):
15
+ return base64.b64encode(obj).decode('utf-8')
16
+ else:
17
+ return obj
17
18
 
18
19
  def unwrap_dbus_objects(args):
19
20
  res = [unwrap_dbus_object(o) for o in args]
dbus2mqtt/event_broker.py CHANGED
@@ -7,12 +7,7 @@ from typing import Any
7
7
 
8
8
  import janus
9
9
 
10
- from dbus2mqtt.config import (
11
- FlowConfig,
12
- FlowTriggerConfig,
13
- SignalConfig,
14
- SubscriptionConfig,
15
- )
10
+ from dbus2mqtt.config import FlowConfig, FlowTriggerConfig
16
11
 
17
12
  logger = logging.getLogger(__name__)
18
13
 
@@ -23,15 +18,6 @@ class MqttMessage:
23
18
  payload: Any
24
19
  payload_serialization_type: str = "json"
25
20
 
26
- @dataclass
27
- class DbusSignalWithState:
28
- bus_name: str
29
- path: str
30
- interface_name: str
31
- subscription_config: SubscriptionConfig
32
- signal_config: SignalConfig
33
- args: list[Any]
34
-
35
21
  @dataclass
36
22
  class FlowTriggerMessage:
37
23
  flow_config: FlowConfig
@@ -43,7 +29,6 @@ class EventBroker:
43
29
  def __init__(self):
44
30
  self.mqtt_receive_queue = janus.Queue[MqttMessage]()
45
31
  self.mqtt_publish_queue = janus.Queue[MqttMessage]()
46
- self.dbus_signal_queue = janus.Queue[DbusSignalWithState]()
47
32
  self.flow_trigger_queue = janus.Queue[FlowTriggerMessage]()
48
33
  # self.dbus_send_queue: janus.Queue
49
34
 
@@ -51,7 +36,6 @@ class EventBroker:
51
36
  await asyncio.gather(
52
37
  self.mqtt_receive_queue.aclose(),
53
38
  self.mqtt_publish_queue.aclose(),
54
- self.dbus_signal_queue.aclose(),
55
39
  self.flow_trigger_queue.aclose(),
56
40
  return_exceptions=True
57
41
  )
@@ -63,7 +47,3 @@ class EventBroker:
63
47
  async def publish_to_mqtt(self, msg: MqttMessage):
64
48
  # logger.debug("publish_to_mqtt")
65
49
  await self.mqtt_publish_queue.async_q.put(msg)
66
-
67
- def on_dbus_signal(self, signal: DbusSignalWithState):
68
- # logger.debug("on_dbus_signal")
69
- self.dbus_signal_queue.sync_q.put(signal)
@@ -7,7 +7,13 @@ from typing import Any
7
7
  from apscheduler.schedulers.asyncio import AsyncIOScheduler
8
8
 
9
9
  from dbus2mqtt import AppContext
10
- from dbus2mqtt.config import FlowConfig, FlowTriggerConfig, FlowTriggerDbusSignalConfig
10
+ from dbus2mqtt.config import (
11
+ FlowConfig,
12
+ FlowTriggerConfig,
13
+ FlowTriggerDbusSignalConfig,
14
+ FlowTriggerObjectAddedConfig,
15
+ FlowTriggerObjectRemovedConfig,
16
+ )
11
17
  from dbus2mqtt.event_broker import FlowTriggerMessage
12
18
  from dbus2mqtt.flow import FlowAction, FlowExecutionContext
13
19
  from dbus2mqtt.flow.actions.context_set import ContextSetAction
@@ -103,7 +109,7 @@ class FlowActionContext:
103
109
 
104
110
  return res
105
111
 
106
- async def execute_actions(self, trigger_context: dict[str, Any] | None):
112
+ async def execute_actions(self, trigger_type: str, trigger_context: dict[str, Any] | None):
107
113
 
108
114
  # per flow execution context
109
115
  context = FlowExecutionContext(
@@ -111,6 +117,8 @@ class FlowActionContext:
111
117
  global_flows_context=self.global_flows_context,
112
118
  flow_context=self.flow_context)
113
119
 
120
+ context.context["trigger_type"] = trigger_type
121
+
114
122
  if trigger_context:
115
123
  context.context.update(trigger_context)
116
124
 
@@ -176,14 +184,20 @@ class FlowProcessor:
176
184
  finally:
177
185
  self.event_broker.flow_trigger_queue.async_q.task_done()
178
186
 
179
- def _trigger_config_to_str(self, config: FlowTriggerConfig) -> str:
187
+ def _trigger_config_to_str(self, msg: FlowTriggerMessage) -> str:
188
+ config = msg.flow_trigger_config
180
189
  if isinstance(config, FlowTriggerDbusSignalConfig):
181
190
  return f"{config.type}({config.signal})"
191
+ elif isinstance(config, FlowTriggerObjectAddedConfig) or isinstance(config, FlowTriggerObjectRemovedConfig):
192
+ path = msg.trigger_context.get('path') if msg.trigger_context else None
193
+ if path:
194
+ return f"{config.type}({path})"
182
195
  return config.type
183
196
 
184
197
  async def _process_flow_trigger(self, flow_trigger_message: FlowTriggerMessage):
185
198
 
186
- trigger_str = self._trigger_config_to_str(flow_trigger_message.flow_trigger_config)
199
+ trigger_type = flow_trigger_message.flow_trigger_config.type
200
+ trigger_str = self._trigger_config_to_str(flow_trigger_message)
187
201
  flow_str = flow_trigger_message.flow_config.name or flow_trigger_message.flow_config.id
188
202
 
189
203
  log_message = f"on_trigger: {trigger_str}, flow={flow_str}, time={flow_trigger_message.timestamp.isoformat()}"
@@ -196,4 +210,4 @@ class FlowProcessor:
196
210
  flow_id = flow_trigger_message.flow_config.id
197
211
 
198
212
  flow = self._flows[flow_id]
199
- await flow.execute_actions(trigger_context=flow_trigger_message.trigger_context)
213
+ await flow.execute_actions(trigger_type, trigger_context=flow_trigger_message.trigger_context)
dbus2mqtt/main.py CHANGED
@@ -8,6 +8,8 @@ import colorlog
8
8
  import dbus_fast.aio as dbus_aio
9
9
  import dotenv
10
10
 
11
+ from dbus_fast import BusType
12
+
11
13
  from dbus2mqtt import AppContext
12
14
  from dbus2mqtt.config import Config
13
15
  from dbus2mqtt.config.jsonarparse import new_argument_parser
@@ -23,7 +25,8 @@ logger = logging.getLogger(__name__)
23
25
 
24
26
  async def dbus_processor_task(app_context: AppContext, flow_scheduler: FlowScheduler):
25
27
 
26
- bus = dbus_aio.message_bus.MessageBus()
28
+ bus_type = BusType.SYSTEM if app_context.config.dbus.bus_type == "SYSTEM" else BusType.SESSION
29
+ bus = dbus_aio.message_bus.MessageBus(bus_type=bus_type)
27
30
 
28
31
  dbus_client = DbusClient(app_context, bus, flow_scheduler)
29
32
  app_context.templating.add_functions(jinja_custom_dbus_functions(dbus_client))
@@ -36,7 +39,8 @@ async def dbus_processor_task(app_context: AppContext, flow_scheduler: FlowSched
36
39
  await asyncio.gather(
37
40
  dbus_client_run_future,
38
41
  asyncio.create_task(dbus_client.dbus_signal_queue_processor_task()),
39
- asyncio.create_task(dbus_client.mqtt_receive_queue_processor_task())
42
+ asyncio.create_task(dbus_client.mqtt_receive_queue_processor_task()),
43
+ asyncio.create_task(dbus_client.dbus_object_lifecycle_signal_processor_task())
40
44
  )
41
45
 
42
46
  async def mqtt_processor_task(app_context: AppContext):
@@ -103,9 +107,19 @@ def main():
103
107
 
104
108
  config: Config = cast(Config, parser.instantiate_classes(cfg))
105
109
 
110
+ class NamePartsFilter(logging.Filter):
111
+ def filter(self, record):
112
+ record.name_last = record.name.rsplit('.', 1)[-1]
113
+ # record.name_first = record.name.split('.', 1)[0]
114
+ # record.name_short = record.name
115
+ # if record.name.startswith("dbus2mqtt"):
116
+ # record.name_short = record.name.split('.', 1)[-1]
117
+ return True
118
+
106
119
  handler = colorlog.StreamHandler(stream=sys.stdout)
120
+ handler.addFilter(NamePartsFilter())
107
121
  handler.setFormatter(colorlog.ColoredFormatter(
108
- '%(log_color)s%(levelname)s:%(name)s:%(message)s',
122
+ '%(log_color)s%(levelname)s:%(name_last)s:%(message)s',
109
123
  log_colors={
110
124
  "DEBUG": "light_black",
111
125
  "WARNING": "yellow",
@@ -2,6 +2,8 @@
2
2
  import asyncio
3
3
  import json
4
4
  import logging
5
+ import random
6
+ import string
5
7
 
6
8
  from typing import Any
7
9
  from urllib.parse import ParseResult
@@ -24,7 +26,9 @@ class MqttClient:
24
26
  self.config = app_context.config.mqtt
25
27
  self.event_broker = app_context.event_broker
26
28
 
29
+ unique_client_id_postfix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
27
30
  self.client = mqtt.Client(
31
+ client_id=f"dbus2mqtt-client-{unique_client_id_postfix}",
28
32
  protocol=mqtt.MQTTv5,
29
33
  callback_api_version=CallbackAPIVersion.VERSION2
30
34
  )
@@ -42,10 +46,10 @@ class MqttClient:
42
46
 
43
47
  def connect(self):
44
48
 
45
- # mqtt_client.on_message = lambda client, userdata, message: asyncio.create_task(mqtt_on_message(client, userdata, message))
46
49
  self.client.connect_async(
47
50
  host=self.config.host,
48
- port=self.config.port
51
+ port=self.config.port,
52
+ clean_start=mqtt.MQTT_CLEAN_START_FIRST_ONLY
49
53
  )
50
54
 
51
55
  async def mqtt_publish_queue_processor_task(self):
@@ -98,12 +102,15 @@ class MqttClient:
98
102
  logger.info(f"on_connect: Connected to {self.config.host}:{self.config.port}")
99
103
  # Subscribing in on_connect() means that if we lose the connection and
100
104
  # reconnect then subscriptions will be renewed.
105
+ # TODO: Determine topics based on config
101
106
  client.subscribe("dbus2mqtt/#", options=SubscribeOptions(noLocal=True))
102
107
 
103
108
  self.loop.call_soon_threadsafe(self.connected_event.set)
104
109
 
105
110
  def on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
106
111
 
112
+ # TODO: Skip messages being sent by other dbus2mqtt clients
113
+
107
114
  payload = msg.payload.decode()
108
115
  if msg.retain:
109
116
  logger.info(f"on_message: skipping msg with retain=True, topic={msg.topic}, payload={payload}")
@@ -32,7 +32,7 @@ class DbusContext:
32
32
  # Pylance will mentiod this line is unreachable. It is not as jinja2 can pass in any type
33
33
  raise ValueError("method_args must be a list")
34
34
 
35
- proxy_object = self.dbus_client.get_proxy_object(bus_name, path)
35
+ proxy_object = self.dbus_client.get_subscribed_proxy_object(bus_name, path)
36
36
  if not proxy_object:
37
37
  raise ValueError(f"No matching subscription found for bus_name: {bus_name}, path: {path}")
38
38
 
@@ -42,7 +42,7 @@ class DbusContext:
42
42
 
43
43
  async def async_dbus_property_get_fn(self, bus_name: str, path: str, interface: str, property:str, default_unsupported: Any = None):
44
44
 
45
- proxy_object = self.dbus_client.get_proxy_object(bus_name, path)
45
+ proxy_object = self.dbus_client.get_subscribed_proxy_object(bus_name, path)
46
46
  if not proxy_object:
47
47
  raise ValueError(f"No matching subscription found for bus_name: {bus_name}, path: {path}")
48
48
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbus2mqtt
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: A Python tool to expose Linux D-Bus signals, methods and properties over MQTT - featuring templating, payload enrichment and Home Assistant-ready examples
5
5
  Project-URL: Repository, https://github.com/jwnmulder/dbus2mqtt.git
6
6
  Project-URL: Issues, https://github.com/jwnmulder/dbus2mqtt/issues
@@ -53,7 +53,7 @@ Initial testing has focused on MPRIS integration. A table of tested MPRIS player
53
53
 
54
54
  ## Getting started with dbus2mqtt
55
55
 
56
- Create a `config.yaml` file with the contents shown below. This configuration will expose all bus properties from the `org.mpris.MediaPlayer2.Player` interface to MQTT on the `dbus2mqtt/org.mpris.MediaPlayer2/state` topic. Have a look at [docs/examples](docs/examples.md) for more examples
56
+ Create a `config.yaml` file with the contents shown below. This configuration will expose all bus properties from the `org.mpris.MediaPlayer2.Player` interface to MQTT on the `dbus2mqtt/org.mpris.MediaPlayer2/state` topic. Have a look at [docs/examples](https://github.com/jwnmulder/dbus2mqtt/blob/main/docs/examples.md) for more examples
57
57
 
58
58
  ```yaml
59
59
  dbus:
@@ -68,7 +68,7 @@ dbus:
68
68
  flows:
69
69
  - name: "Publish MPRIS state"
70
70
  triggers:
71
- - type: bus_name_added
71
+ - type: object_added
72
72
  - type: schedule
73
73
  interval: {seconds: 5}
74
74
  actions:
@@ -111,8 +111,8 @@ cp docs/examples/home_assistant_media_player.yaml $HOME/.config/dbus2mqtt/config
111
111
  cp .env.example $HOME/.config/dbus2mqtt/.env
112
112
 
113
113
  # run image and automatically start on reboot
114
- docker pull jwnmulder/dbus2mqtt
115
- docker run --detach --name dbus2mqtt \
114
+ sudo docker pull jwnmulder/dbus2mqtt
115
+ sudo docker run --detach --name dbus2mqtt \
116
116
  --volume "$HOME"/.config/dbus2mqtt:"$HOME"/.config/dbus2mqtt \
117
117
  --volume /run/user:/run/user \
118
118
  --env DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS" \
@@ -0,0 +1,23 @@
1
+ dbus2mqtt/__init__.py,sha256=VunubKEH4_lne9Ttd2YRpyXjZMIAzyD2eiJ2sfvvTFU,362
2
+ dbus2mqtt/__main__.py,sha256=NAoa3nwgBSQI22eWzzRx61SIDThDwXmUofWWZU3_4-Q,71
3
+ dbus2mqtt/event_broker.py,sha256=Hp5yurhP8FkAO-y0l2grygHxR2e37ZQ7QScjcQDA2UU,1334
4
+ dbus2mqtt/main.py,sha256=Kr2LRVxWcPDtNwCj8Eqz9TxtGLAVrV4q0nizKh1pLXc,4539
5
+ dbus2mqtt/config/__init__.py,sha256=LXQg2cZ_vMvhAl7_cC6G3gxCaLCq9uaTfZqw7OEXkPQ,5104
6
+ dbus2mqtt/config/jsonarparse.py,sha256=VJGFeyQJcOE6bRXlSRr3FClvN_HnmiIlEGmOgNM0oCc,1214
7
+ dbus2mqtt/dbus/dbus_client.py,sha256=0XneV3wulj6d_l4Jq9KIwROC19DLF853u6weCdyxpwA,39222
8
+ dbus2mqtt/dbus/dbus_types.py,sha256=NmPD9um499e49Pk8DWH4IrIPQh1BinHYQgoXllCNiDw,777
9
+ dbus2mqtt/dbus/dbus_util.py,sha256=h-1Y8Mvz9bj9X7mPZ8LghkvXDrujdJHK0__AOW373hE,697
10
+ dbus2mqtt/dbus/introspection_patches/mpris_playerctl.py,sha256=q93d_Yp93u3Y-9q0dRdKW5hrij9GK3CFqKhUWVE8uw4,5883
11
+ dbus2mqtt/dbus/introspection_patches/mpris_vlc.py,sha256=Cf-o-05W6gUoKpcYR7n0dRi-CrbeASPTwkyEzZGnU3Y,4241
12
+ dbus2mqtt/flow/__init__.py,sha256=tAL-CjXQHq_tGTKctIdOZ5teVKBtcJs6Astq_RdruV8,1540
13
+ dbus2mqtt/flow/flow_processor.py,sha256=-fJ5JPFcFqz1RokKaqjdHoU-EPg5McAERKYFwIkMzgU,8749
14
+ dbus2mqtt/flow/actions/context_set.py,sha256=dIT39MJJVb0wuRI_ZM3ssnXYfa-iyB4o_UZD-1BZL2g,1087
15
+ dbus2mqtt/flow/actions/mqtt_publish.py,sha256=psNkTvaR3JZwAwpM4AqiZTDnA5UQX9r4CUZ1LA7iRW4,2366
16
+ dbus2mqtt/mqtt/mqtt_client.py,sha256=0d8XSe4-WxrUBxcUprukwxgzIhnCn1G0fCPnLqPp9ko,5148
17
+ dbus2mqtt/template/dbus_template_functions.py,sha256=UEoXK2PqDKF6jR4vTFHQwq58f5APnOJr7B1_I1zW8yM,2449
18
+ dbus2mqtt/template/templating.py,sha256=ZLp1A8PFAs_-Bndx4yGqyppaDfh8marHlK7P3bFInu4,4144
19
+ dbus2mqtt-0.4.0.dist-info/METADATA,sha256=enb7xSlQVzVTVxCLshavr5HoV8tNamfd2AOJzgel_Sg,7887
20
+ dbus2mqtt-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ dbus2mqtt-0.4.0.dist-info/entry_points.txt,sha256=pmmacoHCsvTTUB5dIPaY4i0H9gX7BQlpd3rBU-Jbv3A,50
22
+ dbus2mqtt-0.4.0.dist-info/licenses/LICENSE,sha256=a4bIEgyA9rrnAfUN90CgbgZ6BQIFHeABkk0JihiBaxM,1074
23
+ dbus2mqtt-0.4.0.dist-info/RECORD,,
@@ -1,23 +0,0 @@
1
- dbus2mqtt/__init__.py,sha256=VunubKEH4_lne9Ttd2YRpyXjZMIAzyD2eiJ2sfvvTFU,362
2
- dbus2mqtt/__main__.py,sha256=NAoa3nwgBSQI22eWzzRx61SIDThDwXmUofWWZU3_4-Q,71
3
- dbus2mqtt/event_broker.py,sha256=WNup2Xywt2MYs1yDyKAytApJn_YOPnxQag3kOkOajX0,1844
4
- dbus2mqtt/main.py,sha256=pHsRbjjvQSAOWhmfazixKteAdfrB-YnC4LQAZovdkEQ,3863
5
- dbus2mqtt/config/__init__.py,sha256=lx94zQsejGMRf90h594jId_CKXY0CyL7n6hk3lwAVvE,4307
6
- dbus2mqtt/config/jsonarparse.py,sha256=VJGFeyQJcOE6bRXlSRr3FClvN_HnmiIlEGmOgNM0oCc,1214
7
- dbus2mqtt/dbus/dbus_client.py,sha256=LAP6QkjPvDHIXOKncHiNYrTnVEV2NRYc1Mymzp-miMY,23217
8
- dbus2mqtt/dbus/dbus_types.py,sha256=EejiLlh3ALAnB4OFvNRL4ILBbxSKgFNNwifBrfap1Gc,494
9
- dbus2mqtt/dbus/dbus_util.py,sha256=Q50Tr1qjqZHpVirzvT9gXYwiCLwPe6BpgVmAcrqdHKE,569
10
- dbus2mqtt/dbus/introspection_patches/mpris_playerctl.py,sha256=q93d_Yp93u3Y-9q0dRdKW5hrij9GK3CFqKhUWVE8uw4,5883
11
- dbus2mqtt/dbus/introspection_patches/mpris_vlc.py,sha256=Cf-o-05W6gUoKpcYR7n0dRi-CrbeASPTwkyEzZGnU3Y,4241
12
- dbus2mqtt/flow/__init__.py,sha256=tAL-CjXQHq_tGTKctIdOZ5teVKBtcJs6Astq_RdruV8,1540
13
- dbus2mqtt/flow/flow_processor.py,sha256=TR30RpN0DoKWgx3Il-wyIzGwRqTjXyEUlqar3S3E3SI,8215
14
- dbus2mqtt/flow/actions/context_set.py,sha256=dIT39MJJVb0wuRI_ZM3ssnXYfa-iyB4o_UZD-1BZL2g,1087
15
- dbus2mqtt/flow/actions/mqtt_publish.py,sha256=psNkTvaR3JZwAwpM4AqiZTDnA5UQX9r4CUZ1LA7iRW4,2366
16
- dbus2mqtt/mqtt/mqtt_client.py,sha256=9Y0AEuquq4gEQ8j9JD1wauU22wSHZFSyQBNr905lwxA,4899
17
- dbus2mqtt/template/dbus_template_functions.py,sha256=0WXH-X3Si5D8iKP1WrSK9XmbKcaTGQo92SJsfHG0JEE,2427
18
- dbus2mqtt/template/templating.py,sha256=ZLp1A8PFAs_-Bndx4yGqyppaDfh8marHlK7P3bFInu4,4144
19
- dbus2mqtt-0.3.1.dist-info/METADATA,sha256=5xbhQusP8EUrvvMuN6rK2MUWBNKMgWOu1XkfADaGSd8,7830
20
- dbus2mqtt-0.3.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
- dbus2mqtt-0.3.1.dist-info/entry_points.txt,sha256=pmmacoHCsvTTUB5dIPaY4i0H9gX7BQlpd3rBU-Jbv3A,50
22
- dbus2mqtt-0.3.1.dist-info/licenses/LICENSE,sha256=a4bIEgyA9rrnAfUN90CgbgZ6BQIFHeABkk0JihiBaxM,1074
23
- dbus2mqtt-0.3.1.dist-info/RECORD,,