dbus2mqtt 0.1.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.
- dbus2mqtt/__init__.py +10 -0
- dbus2mqtt/__main__.py +4 -0
- dbus2mqtt/config.py +143 -0
- dbus2mqtt/dbus/dbus_client.py +450 -0
- dbus2mqtt/dbus/dbus_types.py +23 -0
- dbus2mqtt/dbus/dbus_util.py +23 -0
- dbus2mqtt/event_broker.py +70 -0
- dbus2mqtt/flow/__init__.py +32 -0
- dbus2mqtt/flow/actions/context_set.py +26 -0
- dbus2mqtt/flow/actions/mqtt_publish.py +39 -0
- dbus2mqtt/flow/flow_processor.py +197 -0
- dbus2mqtt/main.py +135 -0
- dbus2mqtt/mqtt/mqtt_client.py +101 -0
- dbus2mqtt/template/dbus_template_functions.py +68 -0
- dbus2mqtt/template/templating.py +129 -0
- dbus2mqtt-0.1.0.dist-info/METADATA +220 -0
- dbus2mqtt-0.1.0.dist-info/RECORD +20 -0
- dbus2mqtt-0.1.0.dist-info/WHEEL +4 -0
- dbus2mqtt-0.1.0.dist-info/entry_points.txt +2 -0
- dbus2mqtt-0.1.0.dist-info/licenses/LICENSE +21 -0
dbus2mqtt/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from dbus2mqtt.config import Config
|
|
2
|
+
from dbus2mqtt.event_broker import EventBroker
|
|
3
|
+
from dbus2mqtt.template.templating import TemplateEngine
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AppContext:
|
|
7
|
+
def __init__(self, config: Config, event_broker: EventBroker, templating: TemplateEngine):
|
|
8
|
+
self.config = config
|
|
9
|
+
self.event_broker = event_broker
|
|
10
|
+
self.templating = templating
|
dbus2mqtt/__main__.py
ADDED
dbus2mqtt/config.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Annotated, Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, SecretStr
|
|
8
|
+
|
|
9
|
+
from dbus2mqtt.template.templating import TemplateEngine
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SignalConfig:
|
|
14
|
+
signal: str
|
|
15
|
+
filter: str | None = None
|
|
16
|
+
|
|
17
|
+
def matches_filter(self, template_engine: TemplateEngine, *args) -> bool:
|
|
18
|
+
res = template_engine.render_template(self.filter, str, { "args": args })
|
|
19
|
+
return res == "True"
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class MethodConfig:
|
|
23
|
+
method: str
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PropertyConfig:
|
|
27
|
+
property: str
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class InterfaceConfig:
|
|
31
|
+
interface: str
|
|
32
|
+
mqtt_call_method_topic: str | None = None
|
|
33
|
+
signals: list[SignalConfig] = field(default_factory=list)
|
|
34
|
+
methods: list[MethodConfig] = field(default_factory=list)
|
|
35
|
+
properties: list[PropertyConfig] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def render_mqtt_call_method_topic(self, template_engine: TemplateEngine, context: dict[str, Any]) -> Any:
|
|
38
|
+
return template_engine.render_template(self.mqtt_call_method_topic, str, context)
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class FlowTriggerMqttConfig:
|
|
42
|
+
type: Literal["mqtt"]
|
|
43
|
+
topic: str
|
|
44
|
+
filter: str | None = None
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class FlowTriggerScheduleConfig:
|
|
48
|
+
type: Literal["schedule"] = "schedule"
|
|
49
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
50
|
+
cron: dict[str, Any] | None = None
|
|
51
|
+
interval: dict[str, Any] | None = None
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class FlowTriggerDbusSignalConfig:
|
|
55
|
+
interface: str
|
|
56
|
+
signal: str
|
|
57
|
+
type: Literal["dbus_signal"] = "dbus_signal"
|
|
58
|
+
bus_name: str | None = None
|
|
59
|
+
path: str | None = None
|
|
60
|
+
filter: str | None = None
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class FlowTriggerBusNameAddedConfig:
|
|
64
|
+
type: Literal["bus_name_added"] = "bus_name_added"
|
|
65
|
+
filter: str | None = None
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class FlowTriggerBusNameRemovedConfig:
|
|
69
|
+
type: Literal["bus_name_removed"] = "bus_name_removed"
|
|
70
|
+
filter: str | None = None
|
|
71
|
+
|
|
72
|
+
FlowTriggerConfig = Annotated[
|
|
73
|
+
FlowTriggerMqttConfig | FlowTriggerScheduleConfig | FlowTriggerDbusSignalConfig | FlowTriggerBusNameAddedConfig | FlowTriggerBusNameRemovedConfig,
|
|
74
|
+
Field(discriminator="type")
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class FlowActionContextSetConfig:
|
|
79
|
+
type: Literal["context_set"] = "context_set"
|
|
80
|
+
context: dict[str, Any] | None = None
|
|
81
|
+
global_context: dict[str, Any] | None = None
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class FlowActionMqttPublishConfig:
|
|
85
|
+
topic: str
|
|
86
|
+
payload_template: str | dict[str, Any]
|
|
87
|
+
"""should be a dict if payload_type is json/yaml
|
|
88
|
+
or a string if payload_type is text"""
|
|
89
|
+
type: Literal["mqtt_publish"] = "mqtt_publish"
|
|
90
|
+
payload_type: Literal["json", "yaml", "text"] = "json"
|
|
91
|
+
|
|
92
|
+
FlowActionConfig = Annotated[
|
|
93
|
+
FlowActionMqttPublishConfig | FlowActionContextSetConfig,
|
|
94
|
+
Field(discriminator="type")
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class FlowConfig:
|
|
99
|
+
triggers: list[FlowTriggerConfig]
|
|
100
|
+
actions: list[FlowActionConfig]
|
|
101
|
+
name: str | None = None
|
|
102
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class SubscriptionConfig:
|
|
106
|
+
bus_name: str
|
|
107
|
+
"""bus_name pattern supporting * wildcards"""
|
|
108
|
+
path: str
|
|
109
|
+
"""path pattern supporting * wildcards"""
|
|
110
|
+
interfaces: list[InterfaceConfig] = field(default_factory=list)
|
|
111
|
+
flows: list[FlowConfig] = field(default_factory=list)
|
|
112
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class DbusConfig:
|
|
116
|
+
subscriptions: list[SubscriptionConfig]
|
|
117
|
+
|
|
118
|
+
def is_bus_name_configured(self, bus_name: str) -> bool:
|
|
119
|
+
|
|
120
|
+
for subscription in self.subscriptions:
|
|
121
|
+
if fnmatch.fnmatchcase(bus_name, subscription.bus_name):
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def get_subscription_configs(self, bus_name: str, path: str) -> list[SubscriptionConfig]:
|
|
126
|
+
res: list[SubscriptionConfig] = []
|
|
127
|
+
for subscription in self.subscriptions:
|
|
128
|
+
if fnmatch.fnmatchcase(bus_name, subscription.bus_name) and fnmatch.fnmatchcase(path, subscription.path):
|
|
129
|
+
res.append(subscription)
|
|
130
|
+
return res
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class MqttConfig:
|
|
134
|
+
host: str
|
|
135
|
+
username: str
|
|
136
|
+
password: SecretStr
|
|
137
|
+
port: int = 1883
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class Config:
|
|
141
|
+
mqtt: MqttConfig
|
|
142
|
+
dbus: DbusConfig
|
|
143
|
+
flows: list[FlowConfig] = field(default_factory=list)
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import dbus_next
|
|
9
|
+
import dbus_next.aio as dbus_aio
|
|
10
|
+
import dbus_next.introspection as dbus_introspection
|
|
11
|
+
|
|
12
|
+
from dbus2mqtt import AppContext
|
|
13
|
+
from dbus2mqtt.config import InterfaceConfig, SubscriptionConfig
|
|
14
|
+
from dbus2mqtt.dbus.dbus_types import BusNameSubscriptions, SubscribedInterface
|
|
15
|
+
from dbus2mqtt.dbus.dbus_util import (
|
|
16
|
+
camel_to_snake,
|
|
17
|
+
unwrap_dbus_object,
|
|
18
|
+
unwrap_dbus_objects,
|
|
19
|
+
)
|
|
20
|
+
from dbus2mqtt.event_broker import DbusSignalWithState, MqttMessage
|
|
21
|
+
from dbus2mqtt.flow.flow_processor import FlowScheduler, FlowTriggerMessage
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# dbus_next path to support https://docs.flatpak.org/en/latest/portal-api-reference.html
|
|
26
|
+
# Although not correct, flatpak exposes properties with names containing '-'
|
|
27
|
+
# This is failing assertions in dbus_next
|
|
28
|
+
# original: r'^[A-Za-z_][A-Za-z0-9_]*$'
|
|
29
|
+
# patched: r'^[A-Za-z_][A-Za-z0-9_-]*$'
|
|
30
|
+
dbus_next.validators._element_re = re.compile(r'^[A-Za-z_][A-Za-z0-9_-]*$')
|
|
31
|
+
|
|
32
|
+
class DbusClient:
|
|
33
|
+
|
|
34
|
+
def __init__(self, app_context: AppContext, bus: dbus_aio.message_bus.MessageBus, flow_scheduler: FlowScheduler):
|
|
35
|
+
self.app_context = app_context
|
|
36
|
+
self.config = app_context.config.dbus
|
|
37
|
+
self.event_broker = app_context.event_broker
|
|
38
|
+
self.templating = app_context.templating
|
|
39
|
+
self.bus = bus
|
|
40
|
+
self.flow_scheduler = flow_scheduler
|
|
41
|
+
self.subscriptions: dict[str, BusNameSubscriptions] = {}
|
|
42
|
+
|
|
43
|
+
async def connect(self):
|
|
44
|
+
|
|
45
|
+
if not self.bus.connected:
|
|
46
|
+
await self.bus.connect()
|
|
47
|
+
|
|
48
|
+
if self.bus.connected:
|
|
49
|
+
logger.info(f"Connected to {self.bus._bus_address}")
|
|
50
|
+
else:
|
|
51
|
+
logger.warning(f"Failed to connect to {self.bus._bus_address}")
|
|
52
|
+
|
|
53
|
+
introspection = await self.bus.introspect('org.freedesktop.DBus', '/org/freedesktop/DBus')
|
|
54
|
+
obj = self.bus.get_proxy_object('org.freedesktop.DBus', '/org/freedesktop/DBus', introspection)
|
|
55
|
+
dbus_interface = obj.get_interface('org.freedesktop.DBus')
|
|
56
|
+
|
|
57
|
+
# subscribe to NameOwnerChanged which allows us to detect new bus_names
|
|
58
|
+
dbus_interface.__getattribute__("on_name_owner_changed")(self._dbus_name_owner_changed_callback)
|
|
59
|
+
|
|
60
|
+
# subscribe to existing registered bus_names we are interested in
|
|
61
|
+
connected_bus_names = await dbus_interface.__getattribute__("call_list_names")()
|
|
62
|
+
|
|
63
|
+
new_subscriped_interfaces: list[SubscribedInterface] = []
|
|
64
|
+
for bus_name in connected_bus_names:
|
|
65
|
+
new_subscriped_interfaces.extend(await self._handle_bus_name_added(bus_name))
|
|
66
|
+
|
|
67
|
+
logger.info(f"subscriptions on startup: {list(set([si.bus_name for si in new_subscriped_interfaces]))}")
|
|
68
|
+
|
|
69
|
+
def get_proxy_object(self, bus_name: str, path: str) -> dbus_aio.proxy_object.ProxyObject | None:
|
|
70
|
+
|
|
71
|
+
bus_name_subscriptions = self.subscriptions.get(bus_name)
|
|
72
|
+
if bus_name_subscriptions:
|
|
73
|
+
proxy_object = bus_name_subscriptions.path_objects.get(path)
|
|
74
|
+
if proxy_object:
|
|
75
|
+
return proxy_object
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def _ensure_proxy_object_subscription(self, bus_name: str, path: str, introspection: dbus_introspection.Node):
|
|
80
|
+
|
|
81
|
+
bus_name_subscriptions = self.subscriptions.get(bus_name)
|
|
82
|
+
if not bus_name_subscriptions:
|
|
83
|
+
bus_name_subscriptions = BusNameSubscriptions(bus_name)
|
|
84
|
+
self.subscriptions[bus_name] = bus_name_subscriptions
|
|
85
|
+
|
|
86
|
+
proxy_object = bus_name_subscriptions.path_objects.get(path)
|
|
87
|
+
if not proxy_object:
|
|
88
|
+
proxy_object = self.bus.get_proxy_object(bus_name, path, introspection)
|
|
89
|
+
bus_name_subscriptions.path_objects[path] = proxy_object
|
|
90
|
+
|
|
91
|
+
return proxy_object, bus_name_subscriptions
|
|
92
|
+
|
|
93
|
+
def _dbus_next_signal_publisher(self, dbus_signal_state: dict[str, Any], *args):
|
|
94
|
+
unwrapped_args = unwrap_dbus_objects(args)
|
|
95
|
+
self.event_broker.on_dbus_signal(
|
|
96
|
+
DbusSignalWithState(**dbus_signal_state, args=unwrapped_args)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _dbus_next_signal_handler(self, signal: dbus_introspection.Signal, state: dict[str, Any]) -> Any:
|
|
100
|
+
expected_args = len(signal.args)
|
|
101
|
+
|
|
102
|
+
if expected_args == 1:
|
|
103
|
+
return lambda a: self._dbus_next_signal_publisher(state, a)
|
|
104
|
+
elif expected_args == 2:
|
|
105
|
+
return lambda a, b: self._dbus_next_signal_publisher(state, a, b)
|
|
106
|
+
elif expected_args == 3:
|
|
107
|
+
return lambda a, b, c: self._dbus_next_signal_publisher(state, a, b, c)
|
|
108
|
+
elif expected_args == 4:
|
|
109
|
+
return lambda a, b, c, d: self._dbus_next_signal_publisher(state, a, b, c, d)
|
|
110
|
+
raise ValueError("Unsupported nr of arguments")
|
|
111
|
+
|
|
112
|
+
async def _subscribe_interface(self, bus_name: str, path: str, introspection: dbus_introspection.Node, interface: dbus_introspection.Interface, subscription_config: SubscriptionConfig, si: InterfaceConfig) -> SubscribedInterface:
|
|
113
|
+
|
|
114
|
+
proxy_object, bus_name_subscriptions = self._ensure_proxy_object_subscription(bus_name, path, introspection)
|
|
115
|
+
obj_interface = proxy_object.get_interface(interface.name)
|
|
116
|
+
|
|
117
|
+
interface_signals = dict((s.name, s) for s in interface.signals)
|
|
118
|
+
|
|
119
|
+
logger.debug(f"subscribe: bus_name={bus_name}, path={path}, interface={interface.name}, proxy_interface: signals={list(interface_signals.keys())}")
|
|
120
|
+
|
|
121
|
+
for signal_config in si.signals:
|
|
122
|
+
interface_signal = interface_signals.get(signal_config.signal)
|
|
123
|
+
if interface_signal:
|
|
124
|
+
|
|
125
|
+
on_signal_method_name = "on_" + camel_to_snake(signal_config.signal)
|
|
126
|
+
dbus_signal_state = {
|
|
127
|
+
"bus_name_subscriptions": bus_name_subscriptions,
|
|
128
|
+
"path": path,
|
|
129
|
+
"interface_name": interface.name,
|
|
130
|
+
"subscription_config": subscription_config,
|
|
131
|
+
"signal_config": signal_config,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
handler = self._dbus_next_signal_handler(interface_signal, dbus_signal_state)
|
|
135
|
+
obj_interface.__getattribute__(on_signal_method_name)(handler)
|
|
136
|
+
logger.info(f"subscribed with signal_handler: signal={signal_config.signal}, bus_name={bus_name}, path={path}, interface={interface.name}")
|
|
137
|
+
|
|
138
|
+
else:
|
|
139
|
+
logger.warning(f"Invalid signal: signal={signal_config.signal}, bus_name={bus_name}, path={path}, interface={interface.name}")
|
|
140
|
+
|
|
141
|
+
return SubscribedInterface(
|
|
142
|
+
bus_name=bus_name,
|
|
143
|
+
path=path,
|
|
144
|
+
interface_name=interface.name,
|
|
145
|
+
subscription_config=subscription_config,
|
|
146
|
+
interface_config=si
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def _process_interface(self, bus_name: str, path: str, introspection: dbus_introspection.Node, interface: dbus_introspection.Interface) -> list[SubscribedInterface]:
|
|
150
|
+
|
|
151
|
+
logger.debug(f"process_interface: {bus_name}, {path}, {interface.name}")
|
|
152
|
+
subscription_configs = self.config.get_subscription_configs(bus_name, path)
|
|
153
|
+
new_subscriptions: list[SubscribedInterface] = []
|
|
154
|
+
for subscription in subscription_configs:
|
|
155
|
+
logger.debug(f"processing subscription config: {subscription.bus_name}, {subscription.path}")
|
|
156
|
+
for subscription_interface in subscription.interfaces:
|
|
157
|
+
if subscription_interface.interface == interface.name:
|
|
158
|
+
logger.debug(f"matching config found for bus_name={bus_name}, path={path}, interface={interface.name}")
|
|
159
|
+
subscribed_iterface = await self._subscribe_interface(bus_name, path, introspection, interface, subscription, subscription_interface)
|
|
160
|
+
|
|
161
|
+
new_subscriptions.append(subscribed_iterface)
|
|
162
|
+
|
|
163
|
+
return new_subscriptions
|
|
164
|
+
|
|
165
|
+
async def _visit_bus_name_path(self, bus_name: str, path: str) -> list[SubscribedInterface]:
|
|
166
|
+
|
|
167
|
+
new_subscriptions: list[SubscribedInterface] = []
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
introspection = await self.bus.introspect(bus_name, path)
|
|
171
|
+
except TypeError as e:
|
|
172
|
+
logger.warning(f"bus.introspect failed, bus_name={bus_name}, path={path}: {e}")
|
|
173
|
+
return new_subscriptions
|
|
174
|
+
|
|
175
|
+
if len(introspection.nodes) == 0:
|
|
176
|
+
logger.debug(f"leaf node: bus_name={bus_name}, path={path}, is_root={introspection.is_root}, interfaces={[i.name for i in introspection.interfaces]}")
|
|
177
|
+
|
|
178
|
+
for interface in introspection.interfaces:
|
|
179
|
+
new_subscriptions.extend(
|
|
180
|
+
await self._process_interface(bus_name, path, introspection, interface)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
for node in introspection.nodes:
|
|
184
|
+
path_seperator = "" if path.endswith('/') else "/"
|
|
185
|
+
new_subscriptions.extend(
|
|
186
|
+
await self._visit_bus_name_path(bus_name, f"{path}{path_seperator}{node.name}")
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return new_subscriptions
|
|
190
|
+
|
|
191
|
+
async def _handle_bus_name_added(self, bus_name: str) -> list[SubscribedInterface]:
|
|
192
|
+
|
|
193
|
+
if not self.config.is_bus_name_configured(bus_name):
|
|
194
|
+
return []
|
|
195
|
+
|
|
196
|
+
# sanity checks
|
|
197
|
+
for umh in self.bus._user_message_handlers:
|
|
198
|
+
umh_bus_name = umh.__self__.bus_name
|
|
199
|
+
if umh_bus_name == bus_name:
|
|
200
|
+
logger.warning(f"handle_bus_name_added: {umh_bus_name} already added")
|
|
201
|
+
|
|
202
|
+
new_subscriped_interfaces = await self._visit_bus_name_path(bus_name, "/")
|
|
203
|
+
|
|
204
|
+
logger.info(f"new_subscriptions: {list(set([si.bus_name for si in new_subscriped_interfaces]))}")
|
|
205
|
+
|
|
206
|
+
# setup and process triggers for each flow in each subscription
|
|
207
|
+
processed_new_subscriptions: set[str] = set()
|
|
208
|
+
|
|
209
|
+
# With all subscriptions in place, we can now ensure schedulers are created
|
|
210
|
+
# create a FlowProcessor per bus_name/path subscription?
|
|
211
|
+
# One global or a per subscription FlowProcessor.flow_processor_task?
|
|
212
|
+
# Start a new timer job, but leverage existing FlowScheduler
|
|
213
|
+
# How does the FlowScheduler now it should invoke the local FlowPocessor?
|
|
214
|
+
# Maybe use queues to communicate from here with the FlowProcessor?
|
|
215
|
+
# e.g.: StartFlows, StopFlows,
|
|
216
|
+
|
|
217
|
+
for subscribed_interface in new_subscriped_interfaces:
|
|
218
|
+
|
|
219
|
+
subscription_config = subscribed_interface.subscription_config
|
|
220
|
+
if subscription_config.id not in processed_new_subscriptions:
|
|
221
|
+
|
|
222
|
+
# Ensure all schedulers are started
|
|
223
|
+
self.flow_scheduler.start_flow_set(subscription_config.flows)
|
|
224
|
+
|
|
225
|
+
# Trigger flows that have a bus_name_added trigger configured
|
|
226
|
+
await self._trigger_bus_name_added(subscription_config, subscribed_interface.bus_name)
|
|
227
|
+
|
|
228
|
+
processed_new_subscriptions.add(subscription_config.id)
|
|
229
|
+
|
|
230
|
+
return new_subscriped_interfaces
|
|
231
|
+
|
|
232
|
+
async def _trigger_bus_name_added(self, subscription_config: SubscriptionConfig, bus_name: str):
|
|
233
|
+
|
|
234
|
+
for flow in subscription_config.flows:
|
|
235
|
+
for trigger in flow.triggers:
|
|
236
|
+
if trigger.type == "bus_name_added":
|
|
237
|
+
context = {
|
|
238
|
+
"bus_name": bus_name,
|
|
239
|
+
}
|
|
240
|
+
trigger_message = FlowTriggerMessage(flow, trigger, datetime.now(), context)
|
|
241
|
+
await self.event_broker.flow_trigger_queue.async_q.put(trigger_message)
|
|
242
|
+
|
|
243
|
+
async def _handle_bus_name_removed(self, bus_name: str):
|
|
244
|
+
|
|
245
|
+
bus_name_subscriptions = self.subscriptions.get(bus_name)
|
|
246
|
+
|
|
247
|
+
if bus_name_subscriptions:
|
|
248
|
+
for path, proxy_object in bus_name_subscriptions.path_objects.items():
|
|
249
|
+
|
|
250
|
+
subscription_configs = self.config.get_subscription_configs(bus_name=bus_name, path=path)
|
|
251
|
+
for subscription_config in subscription_configs:
|
|
252
|
+
|
|
253
|
+
# Trigger flows that have a bus_name_added trigger configured
|
|
254
|
+
await self._trigger_bus_name_removed(subscription_config, bus_name)
|
|
255
|
+
|
|
256
|
+
# Stop schedule triggers
|
|
257
|
+
self.flow_scheduler.stop_flow_set(subscription_config.flows)
|
|
258
|
+
|
|
259
|
+
# Wait for completion
|
|
260
|
+
await self.event_broker.flow_trigger_queue.async_q.join()
|
|
261
|
+
|
|
262
|
+
# clean up all dbus matchrules
|
|
263
|
+
for interface in proxy_object._interfaces.values():
|
|
264
|
+
proxy_interface: dbus_aio.proxy_object.ProxyInterface = interface
|
|
265
|
+
|
|
266
|
+
# officially you should do 'off_...' but the below is easier
|
|
267
|
+
# proxy_interface.off_properties_changed(self.on_properties_changed)
|
|
268
|
+
|
|
269
|
+
# clean lingering interface matchrule from bus
|
|
270
|
+
if proxy_interface._signal_match_rule in self.bus._match_rules.keys():
|
|
271
|
+
self.bus._remove_match_rule(proxy_interface._signal_match_rule)
|
|
272
|
+
|
|
273
|
+
# clean lingering interface messgage handler from bus
|
|
274
|
+
self.bus.remove_message_handler(proxy_interface._message_handler)
|
|
275
|
+
|
|
276
|
+
del self.subscriptions[bus_name]
|
|
277
|
+
|
|
278
|
+
async def _trigger_bus_name_removed(self, subscription_config: SubscriptionConfig, bus_name: str):
|
|
279
|
+
|
|
280
|
+
# Trigger flows that have a bus_name_removed trigger configured
|
|
281
|
+
for flow in subscription_config.flows:
|
|
282
|
+
for trigger in flow.triggers:
|
|
283
|
+
if trigger.type == "bus_name_removed":
|
|
284
|
+
context = {
|
|
285
|
+
"bus_name": bus_name,
|
|
286
|
+
}
|
|
287
|
+
trigger_message = FlowTriggerMessage(flow, trigger, datetime.now(), context)
|
|
288
|
+
await self.event_broker.flow_trigger_queue.async_q.put(trigger_message)
|
|
289
|
+
|
|
290
|
+
async def _dbus_name_owner_changed_callback(self, name, old_owner, new_owner):
|
|
291
|
+
|
|
292
|
+
logger.debug(f'NameOwnerChanged: name=q{name}, old_owner={old_owner}, new_owner={new_owner}')
|
|
293
|
+
|
|
294
|
+
if new_owner and not old_owner:
|
|
295
|
+
logger.debug(f'NameOwnerChanged.new: name={name}')
|
|
296
|
+
await self._handle_bus_name_added(name)
|
|
297
|
+
if old_owner and not new_owner:
|
|
298
|
+
logger.debug(f'NameOwnerChanged.old: name={name}')
|
|
299
|
+
await self._handle_bus_name_removed(name)
|
|
300
|
+
|
|
301
|
+
async def call_dbus_interface_method(self, interface: dbus_aio.proxy_object.ProxyInterface, method: str, method_args: list[Any]):
|
|
302
|
+
|
|
303
|
+
call_method_name = "call_" + camel_to_snake(method)
|
|
304
|
+
res = await interface.__getattribute__(call_method_name)(*method_args)
|
|
305
|
+
|
|
306
|
+
if res:
|
|
307
|
+
res = unwrap_dbus_object(res)
|
|
308
|
+
|
|
309
|
+
logger.debug(f"call_dbus_interface_method: bus_name={interface.bus_name}, interface={interface.introspection.name}, method={method}, res={res}")
|
|
310
|
+
|
|
311
|
+
return res
|
|
312
|
+
|
|
313
|
+
async def get_dbus_interface_property(self, interface: dbus_aio.proxy_object.ProxyInterface, property: str):
|
|
314
|
+
|
|
315
|
+
call_method_name = "get_" + camel_to_snake(property)
|
|
316
|
+
res = await interface.__getattribute__(call_method_name)()
|
|
317
|
+
|
|
318
|
+
if res:
|
|
319
|
+
res = unwrap_dbus_object(res)
|
|
320
|
+
|
|
321
|
+
logger.debug(f"get_dbus_interface_property: bus_name={interface.bus_name}, interface={interface.introspection.name}, property={property}, res={res}")
|
|
322
|
+
|
|
323
|
+
return res
|
|
324
|
+
|
|
325
|
+
async def set_dbus_interface_property(self, interface: dbus_aio.proxy_object.ProxyInterface, property: str, value):
|
|
326
|
+
|
|
327
|
+
call_method_name = "set_" + camel_to_snake(property)
|
|
328
|
+
res = await interface.__getattribute__(call_method_name)(value)
|
|
329
|
+
|
|
330
|
+
if res:
|
|
331
|
+
res = unwrap_dbus_object(res)
|
|
332
|
+
|
|
333
|
+
logger.info(f"set_dbus_interface_property: bus_name={interface.bus_name}, interface={interface.introspection.name}, property={property}, res={res}")
|
|
334
|
+
|
|
335
|
+
return res
|
|
336
|
+
|
|
337
|
+
async def mqtt_receive_queue_processor_task(self):
|
|
338
|
+
"""Continuously processes messages from the async queue."""
|
|
339
|
+
while True:
|
|
340
|
+
msg = await self.event_broker.mqtt_receive_queue.async_q.get() # Wait for a message
|
|
341
|
+
try:
|
|
342
|
+
await self._on_mqtt_msg(msg)
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.warning(f"mqtt_receive_queue_processor_task: Exception {e}", exc_info=True)
|
|
345
|
+
finally:
|
|
346
|
+
self.event_broker.mqtt_receive_queue.async_q.task_done()
|
|
347
|
+
|
|
348
|
+
async def dbus_signal_queue_processor_task(self):
|
|
349
|
+
"""Continuously processes messages from the async queue."""
|
|
350
|
+
while True:
|
|
351
|
+
signal = await self.event_broker.dbus_signal_queue.async_q.get() # Wait for a message
|
|
352
|
+
await self._handle_on_dbus_signal(signal)
|
|
353
|
+
self.event_broker.dbus_signal_queue.async_q.task_done()
|
|
354
|
+
|
|
355
|
+
async def _handle_on_dbus_signal(self, signal: DbusSignalWithState):
|
|
356
|
+
|
|
357
|
+
for flow in signal.subscription_config.flows:
|
|
358
|
+
for trigger in flow.triggers:
|
|
359
|
+
if trigger.type == "dbus_signal" and signal.signal_config.signal == trigger.signal:
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
|
|
363
|
+
matches_filter = True
|
|
364
|
+
if signal.signal_config.filter is not None:
|
|
365
|
+
matches_filter = signal.signal_config.matches_filter(self.app_context.templating, *signal.args)
|
|
366
|
+
|
|
367
|
+
if matches_filter:
|
|
368
|
+
context = {
|
|
369
|
+
"bus_name": signal.bus_name_subscriptions.bus_name,
|
|
370
|
+
"path": signal.path,
|
|
371
|
+
"interface": signal.interface_name,
|
|
372
|
+
"args": signal.args
|
|
373
|
+
}
|
|
374
|
+
trigger_message = FlowTriggerMessage(flow, trigger, datetime.now(), context)
|
|
375
|
+
|
|
376
|
+
await self.event_broker.flow_trigger_queue.async_q.put(trigger_message)
|
|
377
|
+
except Exception as e:
|
|
378
|
+
logger.warning(f"dbus_signal_queue_processor_task: Exception {e}", exc_info=True)
|
|
379
|
+
|
|
380
|
+
async def _on_mqtt_msg(self, msg: MqttMessage):
|
|
381
|
+
# self.queue.put({
|
|
382
|
+
# "topic": topic,
|
|
383
|
+
# "payload": payload
|
|
384
|
+
# })
|
|
385
|
+
|
|
386
|
+
found_matching_topic = False
|
|
387
|
+
for subscription_configs in self.config.subscriptions:
|
|
388
|
+
for interface_config in subscription_configs.interfaces:
|
|
389
|
+
# TODO, performance improvement
|
|
390
|
+
mqtt_topic = interface_config.render_mqtt_call_method_topic(self.templating, {})
|
|
391
|
+
found_matching_topic |= mqtt_topic == msg.topic
|
|
392
|
+
|
|
393
|
+
if not found_matching_topic:
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
logger.debug(f"on_mqtt_msg: topic={msg.topic}, payload={json.dumps(msg.payload)}")
|
|
397
|
+
calls_done: list[str] = []
|
|
398
|
+
properties_set: list[str] = []
|
|
399
|
+
|
|
400
|
+
payload_method = msg.payload.get("method")
|
|
401
|
+
payload_method_args = msg.payload.get("args") or []
|
|
402
|
+
|
|
403
|
+
payload_property = msg.payload.get("property")
|
|
404
|
+
payload_value = msg.payload.get("value")
|
|
405
|
+
|
|
406
|
+
if payload_method is None and (payload_property is None or payload_value is None):
|
|
407
|
+
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}")
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
for [bus_name, bus_name_subscription] in self.subscriptions.items():
|
|
411
|
+
for [path, proxy_object] in bus_name_subscription.path_objects.items():
|
|
412
|
+
for subscription_configs in self.config.get_subscription_configs(bus_name=bus_name, path=path):
|
|
413
|
+
for interface_config in subscription_configs.interfaces:
|
|
414
|
+
|
|
415
|
+
for method in interface_config.methods:
|
|
416
|
+
|
|
417
|
+
# filter configured method, configured topic, ...
|
|
418
|
+
if method.method == payload_method:
|
|
419
|
+
interface = proxy_object.get_interface(name=interface_config.interface)
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
logger.info(f"on_mqtt_msg: method={method.method}, args={payload_method_args}, bus_name={bus_name}, path={path}, interface={interface_config.interface}")
|
|
423
|
+
await self.call_dbus_interface_method(interface, method.method, payload_method_args)
|
|
424
|
+
calls_done.append(method.method)
|
|
425
|
+
except Exception as e:
|
|
426
|
+
logger.warning(f"on_mqtt_msg: method={method.method}, bus_name={bus_name} failed, exception={e}")
|
|
427
|
+
|
|
428
|
+
for property in interface_config.properties:
|
|
429
|
+
# filter configured property, configured topic, ...
|
|
430
|
+
if property.property == payload_property:
|
|
431
|
+
interface = proxy_object.get_interface(name=interface_config.interface)
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
logger.info(f"on_mqtt_msg: property={property.property}, value={payload_value}, bus_name={bus_name}, path={path}, interface={interface_config.interface}")
|
|
435
|
+
await self.set_dbus_interface_property(interface, property.property, payload_value)
|
|
436
|
+
properties_set.append(property.property)
|
|
437
|
+
except Exception as e:
|
|
438
|
+
logger.warning(f"on_mqtt_msg: property={property.property}, bus_name={bus_name} failed, exception={e}")
|
|
439
|
+
|
|
440
|
+
if len(calls_done) == 0 and len(properties_set) == 0:
|
|
441
|
+
if payload_method:
|
|
442
|
+
logger.info(f"No configured or active dbus subscriptions for topic={msg.topic}, method={payload_method}, active bus_names={list(self.subscriptions.keys())}")
|
|
443
|
+
if payload_property:
|
|
444
|
+
logger.info(f"No configured or active dbus subscriptions for topic={msg.topic}, property={payload_property}, active bus_names={list(self.subscriptions.keys())}")
|
|
445
|
+
|
|
446
|
+
# raw mode, payload contains: bus_name (specific or wildcard), path, interface_name
|
|
447
|
+
# topic: dbus2mqtt/raw (with allowlist check)
|
|
448
|
+
|
|
449
|
+
# predefined mode with topic matching from configuration
|
|
450
|
+
# topic: dbus2mqtt/MediaPlayer/command
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import dbus_next.aio as dbus_aio
|
|
6
|
+
|
|
7
|
+
from dbus2mqtt.config import InterfaceConfig, SubscriptionConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BusNameSubscriptions:
|
|
11
|
+
|
|
12
|
+
def __init__(self, bus_name: str):
|
|
13
|
+
self.bus_name = bus_name
|
|
14
|
+
self.path_objects: dict[str, dbus_aio.proxy_object.ProxyObject] = {}
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SubscribedInterface:
|
|
18
|
+
|
|
19
|
+
interface_config: InterfaceConfig
|
|
20
|
+
subscription_config: SubscriptionConfig
|
|
21
|
+
bus_name: str
|
|
22
|
+
path: str
|
|
23
|
+
interface_name: str
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
import dbus_next.signature as dbus_signature
|
|
5
|
+
|
|
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_next.signature.Variant types
|
|
14
|
+
res = json.dumps(o, default=_variant_serializer)
|
|
15
|
+
json_obj = json.loads(res)
|
|
16
|
+
return json_obj
|
|
17
|
+
|
|
18
|
+
def unwrap_dbus_objects(*args):
|
|
19
|
+
res = [unwrap_dbus_object(o) for o in args]
|
|
20
|
+
return res
|
|
21
|
+
|
|
22
|
+
def camel_to_snake(name):
|
|
23
|
+
return re.sub(r'([a-z])([A-Z])', r'\1_\2', name).lower()
|