somestuff-ucmstuff 0.1.3__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.
@@ -0,0 +1,112 @@
1
+ """ucmstuff — Grandstream UCM6204 monitor/control package.
2
+
3
+ Central logging setup lives here (loguru), mirroring dnsstuff's setup. This
4
+ package logs via the stdlib ``logging`` module, so :func:`configure_logging`
5
+ installs an intercept handler that funnels those records into loguru instead of
6
+ rewriting every call site.
7
+ """
8
+
9
+ import inspect
10
+ import logging
11
+ import os
12
+ import sys
13
+ from collections.abc import Callable
14
+ from types import FrameType
15
+ from typing import TYPE_CHECKING
16
+
17
+ from loguru import logger as glogger
18
+ from tabulate import tabulate
19
+
20
+ if TYPE_CHECKING:
21
+ from loguru import Record
22
+
23
+ __version__ = "0.1.3"
24
+
25
+ __all__ = ["__version__", "configure_logging", "print_banner"]
26
+
27
+
28
+ def _loguru_skiplog_filter(record: "Record") -> bool:
29
+ """Decide whether a loguru record should be emitted.
30
+
31
+ Args:
32
+ record: The loguru record about to be handled by a sink.
33
+
34
+ Returns:
35
+ ``False`` for records whose ``extra['skiplog']`` flag is truthy (they are
36
+ hidden), ``True`` otherwise.
37
+ """
38
+ return not record.get("extra", {}).get("skiplog", False)
39
+
40
+
41
+ class _InterceptHandler(logging.Handler):
42
+ """Route stdlib ``logging`` records into loguru with correct call-site info.
43
+
44
+ Every module in this package uses ``logging.getLogger(...)``; installing this
45
+ on the root logger lets :func:`configure_logging` govern all of them (format,
46
+ level, colour) without touching the individual call sites.
47
+ """
48
+
49
+ def emit(self, record: logging.LogRecord) -> None:
50
+ """Forward a single stdlib record to loguru.
51
+
52
+ Args:
53
+ record: The stdlib log record to re-emit through loguru, preserving
54
+ its level, message, exception info and originating call site.
55
+ """
56
+ # Map the stdlib level name to a loguru level, falling back to the number.
57
+ level: str | int
58
+ try:
59
+ level = glogger.level(record.levelname).name
60
+ except ValueError:
61
+ level = record.levelno
62
+ # Walk out of the logging module so {module}/{function}/{line} point at the
63
+ # real caller rather than at this handler (canonical loguru intercept recipe).
64
+ frame: FrameType | None = inspect.currentframe()
65
+ depth = 0
66
+ while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
67
+ frame = frame.f_back
68
+ depth += 1
69
+ glogger.bind(classname=record.name).opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
70
+
71
+
72
+ def configure_logging(
73
+ verbose: bool = False, loguru_filter: Callable[["Record"], bool] = _loguru_skiplog_filter
74
+ ) -> None:
75
+ """Configure a default ``loguru`` sink and funnel stdlib logging into it.
76
+
77
+ Args:
78
+ verbose: ``True`` logs at DEBUG, otherwise honours ``LOGURU_LEVEL`` (INFO
79
+ by default).
80
+ loguru_filter: Predicate deciding, per loguru record, whether it is emitted
81
+ by the sink. Defaults to :func:`_loguru_skiplog_filter`.
82
+ """
83
+ level_name: str = "DEBUG" if verbose else os.getenv("LOGURU_LEVEL", "INFO")
84
+ os.environ["LOGURU_LEVEL"] = level_name
85
+ glogger.remove()
86
+ logger_fmt: str = (
87
+ "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{module}</cyan>::<cyan>{extra[classname]}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
88
+ )
89
+ glogger.add(sys.stderr, level=level_name, format=logger_fmt, filter=loguru_filter) # type: ignore[arg-type]
90
+ glogger.configure(extra={"classname": "None", "skiplog": False})
91
+ # Replace any stdlib handlers with the intercept so all ``logging`` output
92
+ # (this package's and third-party libraries') flows through loguru.
93
+ logging.basicConfig(handlers=[_InterceptHandler()], level=logging.getLevelNamesMapping()[level_name], force=True)
94
+
95
+
96
+ def print_banner() -> None:
97
+ """Log the startup banner with version, build time and project links."""
98
+ startup_rows = [
99
+ ["version", __version__],
100
+ ["buildtime", os.getenv("BUILDTIME", "unknown")],
101
+ ["github", "https://github.com/vroomfondel/somestuff/tree/main/somestuff/ucmstuff"],
102
+ ["Docker Hub", "https://hub.docker.com/r/xomoxcc/somestuff"],
103
+ ]
104
+ table_str = tabulate(startup_rows, tablefmt="mixed_grid")
105
+ lines = table_str.split("\n")
106
+ table_width = len(lines[0])
107
+ title = "ucm6204 event monitor starting up"
108
+ title_border = "┍" + "━" * (table_width - 2) + "┑"
109
+ title_row = "│ " + title.center(table_width - 4) + " │"
110
+ separator = lines[0].replace("┍", "┝").replace("┑", "┥").replace("┯", "┿")
111
+
112
+ glogger.opt(raw=True).info(f"\n{title_border}\n{title_row}\n{separator}\n{'\n'.join(lines[1:])}\n")
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Coordinated example: monitor UCM calls over WebSocket and control them via the API.
3
+
4
+ This wires the two coordinated clients together:
5
+
6
+ * :class:`~somestuff.ucmstuff.ucm6204_api.UCMEventClient` — receives real-time call events
7
+ over the WebSocket (authenticated with a *web* user).
8
+ * :class:`~somestuff.ucmstuff.ucm6204_api.UCM6204` — the HTTPS-API control client
9
+ (authenticated with the *API* user), used to act on those events.
10
+
11
+ A :class:`~somestuff.ucmstuff.ucm6204_api.TrunkCallRouter` filters incoming calls to the
12
+ configured trunk(s) and hands each one to ``on_incoming``, where the caller-based
13
+ branching lives. Configuration is read from environment variables — see
14
+ ``ucm.env.example``.
15
+
16
+ Run (from the repo root so ``ucmstuff`` is importable)::
17
+
18
+ set -a; . somestuff/ucmstuff/ucm.local.env; set +a # your filled-in copy
19
+ python3 -m somestuff.ucmstuff.example_router
20
+ """
21
+
22
+ import logging
23
+ import os
24
+ import sys
25
+
26
+ from somestuff.ucmstuff.ucm6204_api import (
27
+ UCM6204,
28
+ IncomingCall,
29
+ TrunkCallRouter,
30
+ UCMAPIError,
31
+ UCMEventClient,
32
+ )
33
+
34
+
35
+ def main() -> None:
36
+ """Wire the event and control clients together and run the event loop.
37
+
38
+ Reads connection settings from environment variables (see
39
+ ``ucm.env.example``): ``UCM_HOST`` (required), ``UCM_PORT`` (defaults to
40
+ ``"8089"``), ``UCM_TRUNKS`` (whitespace-separated inbound trunk names; empty
41
+ means monitoring only, no call routing), ``UCM_API_USER``/``UCM_API_PASSWORD``
42
+ (HTTPS-API control credentials) and ``UCM_WEB_USER``/``UCM_WEB_PASSWORD``
43
+ (WebSocket event credentials).
44
+
45
+ Connects the :class:`~somestuff.ucmstuff.ucm6204_api.UCM6204` control client, wires a
46
+ :class:`~somestuff.ucmstuff.ucm6204_api.TrunkCallRouter` (if trunks are configured) to
47
+ ``on_incoming``, then runs the
48
+ :class:`~somestuff.ucmstuff.ucm6204_api.UCMEventClient` event loop, blocking the calling
49
+ thread until interrupted (``events.run(block=True)`` only returns once the
50
+ loop is stopped).
51
+
52
+ Raises:
53
+ KeyError: If a required environment variable (``UCM_HOST``,
54
+ ``UCM_API_USER``, ``UCM_API_PASSWORD``, ``UCM_WEB_USER`` or
55
+ ``UCM_WEB_PASSWORD``) is not set.
56
+ somestuff.ucmstuff.ucm6204_api.UCMAPIError: If the initial HTTPS-API login fails.
57
+ """
58
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
59
+
60
+ host = os.environ["UCM_HOST"]
61
+ port = int(os.environ.get("UCM_PORT", "8089"))
62
+ trunks = [t for t in os.environ.get("UCM_TRUNKS", "").split() if t]
63
+
64
+ # Control client (HTTPS API, API user) — used to act on calls.
65
+ api = UCM6204(
66
+ host=host, port=port, api_user=os.environ["UCM_API_USER"], api_password=os.environ["UCM_API_PASSWORD"]
67
+ )
68
+ api.connect()
69
+
70
+ # Event client (WebSocket, web user) — receives the call events.
71
+ events = UCMEventClient(
72
+ host=host, ws_port=port, web_user=os.environ["UCM_WEB_USER"], web_password=os.environ["UCM_WEB_PASSWORD"]
73
+ )
74
+
75
+ def on_incoming(call: IncomingCall) -> None:
76
+ """Log the incoming call and branch by caller (demo placeholder).
77
+
78
+ Registered as the ``on_call`` callback of
79
+ :class:`~somestuff.ucmstuff.ucm6204_api.TrunkCallRouter`; fires exactly once per new
80
+ incoming call on the configured trunk(s). Replace the commented-out rules
81
+ below with your own caller-based logic, e.g. auto-accepting allow-listed
82
+ numbers or auto-refusing based on the caller name.
83
+
84
+ Args:
85
+ call: The parsed incoming call (caller number/name, channel, trunk).
86
+ """
87
+ logging.info("incoming %s call from %r <%s> on %s", call.trunk, call.name, call.number, call.channel)
88
+ # --- your caller-based branching goes here, e.g.: ---
89
+ # try:
90
+ # if call.number in {"+491234567"}:
91
+ # api.accept_call(call.channel)
92
+ # elif call.name.upper().startswith("SPAM"):
93
+ # api.refuse_call(call.channel)
94
+ # except UCMAPIError as exc:
95
+ # logging.warning("control action failed: %s", exc)
96
+
97
+ if trunks:
98
+ events.add_event_handler(TrunkCallRouter(trunks, on_call=on_incoming).handle)
99
+ else:
100
+ logging.warning("UCM_TRUNKS is empty — monitoring only, no call routing")
101
+
102
+ logging.info("monitoring %s:%d, trunks=%s", host, port, trunks or "-")
103
+ events.run(block=True)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ try:
108
+ main()
109
+ except KeyboardInterrupt:
110
+ sys.exit(0)
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env python3
2
+ """Bridge UCM6204 WebSocket events onto MQTT — and optionally act on MQTT commands.
3
+
4
+ Keeps transport/parsing (:mod:`somestuff.ucmstuff.ucm6204_api`) separate from messaging
5
+ (:mod:`mqttstuff`): this is the *only* module that imports ``mqttstuff``, so the
6
+ core client stays dependency-light and usable without a broker.
7
+
8
+ Two directions, both optional and independently usable:
9
+
10
+ * **Outbound (monitor → MQTT).** Register :meth:`MqttEventBridge.publish_event`
11
+ as a :data:`~somestuff.ucmstuff.ucm6204_api.EventHandler` (every raw event) and/or
12
+ :meth:`publish_incoming` as a :data:`~somestuff.ucmstuff.ucm6204_api.CallHandler`
13
+ (parsed incoming calls on routed trunks) with the :class:`UCMEventClient`.
14
+ * **Inbound (MQTT → control).** With ``enable_commands=True`` and an
15
+ :class:`~somestuff.ucmstuff.ucm6204_api.UCM6204` control client, subscribe to
16
+ ``<base>/cmd/#`` and dispatch ``accept`` / ``refuse`` / ``hangup`` to the API.
17
+
18
+ Topic layout (``base_topic`` defaults to ``ucm6204``)::
19
+
20
+ ucm6204/events/<eventname> transient full notify event as JSON
21
+ ucm6204/calls/<trunk>/incoming transient parsed IncomingCall as JSON
22
+ ucm6204/calls/<trunk>/ended transient same call once its leg terminates
23
+ ucm6204/cmd/{accept,refuse,hangup} inbound {"channel": "PJSIP/..."}
24
+
25
+ All outbound messages are published with ``retain=False`` — they are point-in-time
26
+ notifications, never retained "last value" state.
27
+
28
+ Incoming calls are published per inbound trunk (``<trunk>`` is the call's
29
+ ``inbound_trunk_name``, MQTT-sanitized); subscribe to ``ucm6204/calls/+/incoming``
30
+ for all trunks or ``ucm6204/calls/<trunk>/incoming`` for one. When the routed
31
+ call leg terminates, the same payload (``state: "Ended"``) is published to
32
+ ``.../ended`` — start and end of a call share one stream, matched via
33
+ ``channel``.
34
+
35
+ Serialization note: mqttstuff's ``publish_one`` only wraps ``dict`` values (via
36
+ ``json.dumps``); a bare ``list`` — e.g. an ``ActiveCallStatus`` ``eventbody`` — is
37
+ passed straight to paho and raises ``TypeError``. This bridge therefore always
38
+ publishes a ``dict`` (the whole event, or a hand-built payload), never a naked
39
+ list.
40
+ """
41
+
42
+ import logging
43
+ from dataclasses import asdict
44
+ from typing import Any, Iterable
45
+
46
+ from mqttstuff import MosquittoClientWrapper, MWMqttMessage
47
+
48
+ from somestuff.ucmstuff.ucm6204_api import UCM6204, IncomingCall, NotifyEvent, UCMAPIError, UCMEventClient
49
+
50
+ # stdlib logger by design: ucm6204_api.configure_logging() routes root-logger
51
+ # records into loguru via its _InterceptHandler, so this output lands there too.
52
+ logger = logging.getLogger(__name__)
53
+
54
+ # MQTT topic-level separator and single-/multi-level wildcards — illegal inside a
55
+ # topic segment, so any of these in a trunk name would split or break the topic.
56
+ _MQTT_RESERVED = str.maketrans({"/": "_", "+": "_", "#": "_"})
57
+
58
+
59
+ def _mqtt_safe(name: str) -> str:
60
+ """Turn a trunk name into a single, publishable MQTT topic segment.
61
+
62
+ Replaces the reserved characters ``/ + #`` with ``_`` and trims surrounding
63
+ whitespace. An empty or whitespace-only name (e.g. a call with no
64
+ ``inbound_trunk_name``) collapses to ``"unknown"`` so the topic never contains
65
+ an empty level.
66
+
67
+ Args:
68
+ name: The raw trunk name (e.g. ``inbound_trunk_name`` from an
69
+ :class:`~somestuff.ucmstuff.ucm6204_api.ActiveCall`).
70
+
71
+ Returns:
72
+ str: A single MQTT topic segment, never empty.
73
+ """
74
+ safe = name.strip().translate(_MQTT_RESERVED)
75
+ return safe or "unknown"
76
+
77
+
78
+ class MqttEventBridge:
79
+ """Publish UCM events to MQTT and optionally dispatch MQTT commands to the API.
80
+
81
+ See the module docstring for the topic layout and the outbound/inbound
82
+ direction split.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ host: str,
88
+ port: int = 1883,
89
+ username: str | None = None,
90
+ password: str | None = None,
91
+ *,
92
+ base_topic: str = "ucm6204",
93
+ api: UCM6204 | None = None,
94
+ tls: bool = False,
95
+ tls_ca: str | None = None,
96
+ tls_cert: str | None = None,
97
+ tls_key: str | None = None,
98
+ tls_insecure: bool = False,
99
+ ) -> None:
100
+ """Initialize the bridge and the underlying MQTT client wrapper.
101
+
102
+ Does not connect yet — call :meth:`start` to connect and begin the
103
+ network loop.
104
+
105
+ Args:
106
+ host: MQTT broker host (e.g. ``mosquitto.mosquitto.svc.cluster.local``).
107
+ port: Broker port. Defaults to ``1883``; TLS brokers usually listen
108
+ on ``8883`` — set that explicitly, it is NOT switched
109
+ automatically.
110
+ username: Broker username, or ``None`` for anonymous.
111
+ password: Broker password, or ``None``.
112
+ base_topic: Root of the topic tree. Defaults to ``"ucm6204"``.
113
+ api: A connected :class:`UCM6204` control client. Required only when the
114
+ inbound command path is enabled (:meth:`start` with
115
+ ``enable_commands=True``); ``None`` disables control.
116
+ tls: Encrypt the connection with TLS.
117
+ tls_ca: Path to the CA certificate (PEM) that signed the broker's
118
+ certificate; ``None`` uses the system CA store.
119
+ tls_cert: Path to a client certificate (PEM) for mutual TLS, or ``None``.
120
+ tls_key: Path to the client certificate's private key, or ``None``.
121
+ tls_insecure: Skip hostname verification (self-signed certificates
122
+ whose CN/SAN does not match the host). The connection is still
123
+ encrypted, but vulnerable to MITM — last resort only.
124
+
125
+ Raises:
126
+ ValueError: TLS options are inconsistent — ``tls_cert`` without
127
+ ``tls_key`` (or vice versa), or ``tls_*`` values given while
128
+ ``tls`` is off (validated by ``mqttstuff``); unreadable
129
+ certificate/key files propagate from ``ssl``.
130
+ """
131
+ self._base = base_topic.strip("/")
132
+ self._host = host
133
+ self._port = port
134
+ self._api = api
135
+ # TLS is native in mqttstuff >= 0.0.6 (ca None -> system CA store).
136
+ self._mq = MosquittoClientWrapper(
137
+ host=host,
138
+ port=port,
139
+ username=username,
140
+ password=password,
141
+ tls=tls,
142
+ tls_ca_certs=tls_ca,
143
+ tls_certfile=tls_cert,
144
+ tls_keyfile=tls_key,
145
+ tls_insecure=tls_insecure,
146
+ )
147
+ if tls and tls_insecure:
148
+ logger.warning("MQTT TLS hostname verification DISABLED (tls_insecure) — encrypted but MITM-able")
149
+
150
+ # ── Outbound: UCM → MQTT ────────────────────────────────────────────────
151
+
152
+ def publish_event(self, event: NotifyEvent) -> None:
153
+ """EventHandler: publish one raw notify event to ``<base>/events/<eventname>``.
154
+
155
+ Register with :meth:`UCMEventClient.add_event_handler`. Publishes the whole
156
+ event dict (``eventname`` + ``eventbody`` + ``transactionid``) as JSON.
157
+ Never raises — a broker hiccup must not kill the WebSocket receive loop.
158
+
159
+ Args:
160
+ event: The notify event delivered by :class:`UCMEventClient`.
161
+ """
162
+ name = str(event.get("eventname") or "unknown")
163
+ self._safe_publish(f"{self._base}/events/{name}", dict(event))
164
+
165
+ def publish_incoming(self, call: IncomingCall) -> None:
166
+ """CallHandler: publish a parsed incoming call to ``<base>/calls/<trunk>/incoming``.
167
+
168
+ Register as the ``on_call`` of a :class:`TrunkCallRouter` (or call it from
169
+ your own router). The inbound trunk becomes a topic segment (MQTT-sanitized,
170
+ see :func:`_mqtt_safe`), so subscribers can filter per trunk. Drops the bulky
171
+ ``raw`` leg dict from the payload.
172
+
173
+ Args:
174
+ call: The parsed incoming call, as produced by :class:`TrunkCallRouter`.
175
+ """
176
+ payload = {k: v for k, v in asdict(call).items() if k != "raw"}
177
+ trunk = _mqtt_safe(call.trunk)
178
+ self._safe_publish(f"{self._base}/calls/{trunk}/incoming", payload)
179
+
180
+ def publish_ended(self, call: IncomingCall) -> None:
181
+ """CallHandler: publish a terminated call to ``<base>/calls/<trunk>/ended``.
182
+
183
+ Register as the ``on_end`` of a :class:`TrunkCallRouter`. The payload has
184
+ the same shape as :meth:`publish_incoming` (``state`` is ``"Ended"``), so
185
+ subscribers can pair start and end of a call via ``channel``.
186
+
187
+ Args:
188
+ call: The ended call, as produced by :class:`TrunkCallRouter`.
189
+ """
190
+ payload = {k: v for k, v in asdict(call).items() if k != "raw"}
191
+ trunk = _mqtt_safe(call.trunk)
192
+ self._safe_publish(f"{self._base}/calls/{trunk}/ended", payload)
193
+
194
+ def _safe_publish(self, topic: str, payload: dict[str, Any]) -> None:
195
+ """Publish ``payload`` (always a dict → JSON), swallowing broker errors.
196
+
197
+ Always publishes with ``retain=False``: these are point-in-time events
198
+ (an event fired, a call arrived), so a retained "last value" left on the
199
+ broker would be replayed as stale state to every late subscriber.
200
+
201
+ Args:
202
+ topic: Full MQTT topic to publish to.
203
+ payload: The JSON-serializable payload (arbitrary JSON-compatible
204
+ values, hence ``Any``); ``mqttstuff.publish_one`` wraps it in
205
+ ``json.dumps``. See the module docstring's serialization note on
206
+ why this must always be a ``dict``, never a bare ``list``.
207
+ """
208
+ try:
209
+ ok = self._mq.publish_one(topic, payload, retain=False)
210
+ if not ok:
211
+ logger.warning("MQTT publish to %s not confirmed", topic)
212
+ except Exception:
213
+ logger.exception("MQTT publish to %s failed", topic)
214
+
215
+ # ── Inbound: MQTT → UCM control ─────────────────────────────────────────
216
+
217
+ def start(self, enable_commands: bool = False) -> None:
218
+ """Connect to the broker and start the network loop (blocking-in-thread).
219
+
220
+ Args:
221
+ enable_commands: Subscribe to ``<base>/cmd/#`` and dispatch commands to
222
+ the API control client. Requires ``api=`` to have been supplied.
223
+
224
+ Raises:
225
+ ValueError: ``enable_commands=True`` without an ``api`` control client.
226
+ """
227
+ if enable_commands:
228
+ if self._api is None:
229
+ raise ValueError("enable_commands=True requires api=UCM6204(...)")
230
+ cmd_topic = f"{self._base}/cmd/#"
231
+ # set_topics() records the filter in the client's userdata, so on_connect
232
+ # issues the actual SUBSCRIBE — on the first connect *and* on every paho
233
+ # auto-reconnect. add_message_callback() alone only registers client-side
234
+ # routing and would never make the broker deliver anything.
235
+ self._mq.set_topics([cmd_topic])
236
+ self._mq.add_message_callback(cmd_topic, self._on_command, rettype="json")
237
+ logger.info("MQTT command path enabled on %s", cmd_topic)
238
+ logger.info("MQTT bridge connecting to host=%s port=%d base=%s", self._host, self._port, self._base)
239
+ connected = self._mq.wait_for_connect_and_start_loop()
240
+ if connected:
241
+ logger.info("MQTT bridge successfully connected to host=%s port=%d", self._host, self._port)
242
+ else:
243
+ logger.warning(
244
+ "MQTT bridge NOT connected to host=%s port=%d within timeout — paho keeps retrying in background",
245
+ self._host,
246
+ self._port,
247
+ )
248
+
249
+ def stop(self) -> None:
250
+ """Disconnect from the broker."""
251
+ try:
252
+ self._mq.disconnect()
253
+ except Exception:
254
+ logger.exception("MQTT disconnect failed")
255
+
256
+ # Command topic suffix → matching :class:`~somestuff.ucmstuff.ucm6204_api.UCM6204` method name.
257
+ _COMMANDS: dict[str, str] = {"accept": "accept_call", "refuse": "refuse_call", "hangup": "hangup"}
258
+
259
+ def _on_command(self, msg: MWMqttMessage, userdata: dict[str, Any]) -> None:
260
+ """Dispatch one ``<base>/cmd/<action>`` message to the API control client.
261
+
262
+ Payload is JSON with at least ``{"channel": "PJSIP/..."}``. Unknown actions,
263
+ missing channels and API errors are logged, never raised (this runs in the
264
+ paho network thread).
265
+
266
+ Registered via :meth:`start` as the ``mqttstuff`` message callback for
267
+ ``<base>/cmd/#`` (``rettype="json"``), so ``mqttstuff`` calls it directly —
268
+ its signature is dictated by :meth:`MosquittoClientWrapper.add_message_callback`.
269
+
270
+ Args:
271
+ msg: The decoded command message; ``msg.value`` is the JSON payload
272
+ (expected to be a ``dict``) and ``msg.topic`` is the full command
273
+ topic (``<base>/cmd/<action>``).
274
+ userdata: ``mqttstuff``'s internal per-client state dict (topic list,
275
+ QoS, connect condition …). Untouched here — accepted only because
276
+ ``mqttstuff`` always passes it to message callbacks; its value
277
+ types are heterogeneous library internals, hence ``Any``.
278
+ """
279
+ action = str(msg.topic).rsplit("/", 1)[-1]
280
+ method = self._COMMANDS.get(action)
281
+ if method is None:
282
+ logger.warning("ignoring MQTT command with unknown action %r (topic=%s)", action, msg.topic)
283
+ return
284
+ body = msg.value if isinstance(msg.value, dict) else {}
285
+ channel = body.get("channel")
286
+ if not isinstance(channel, str) or not channel:
287
+ logger.warning("MQTT command %s without a 'channel' — ignoring (payload=%r)", action, msg.value)
288
+ return
289
+ assert self._api is not None # guaranteed by start(enable_commands=True)
290
+ try:
291
+ getattr(self._api, method)(channel)
292
+ logger.info("MQTT command %s on %s -> ok", action, channel)
293
+ except UCMAPIError as exc:
294
+ logger.warning("MQTT command %s on %s failed: %s", action, channel, exc)
295
+
296
+
297
+ def attach_bridge(
298
+ eventclient: UCMEventClient,
299
+ bridge: MqttEventBridge,
300
+ trunks: Iterable[str] = (),
301
+ *,
302
+ raw_events: bool = True,
303
+ ) -> None:
304
+ """Convenience wiring: hook a :class:`MqttEventBridge` onto a UCMEventClient.
305
+
306
+ Args:
307
+ eventclient: The :class:`~somestuff.ucmstuff.ucm6204_api.UCMEventClient` to attach the
308
+ bridge to.
309
+ bridge: The bridge to attach.
310
+ trunks: Inbound trunk name(s); if given, a
311
+ :class:`~somestuff.ucmstuff.ucm6204_api.TrunkCallRouter` is registered that
312
+ publishes parsed incoming calls via
313
+ :meth:`MqttEventBridge.publish_incoming` and their termination via
314
+ :meth:`MqttEventBridge.publish_ended`. Empty → no call routing.
315
+ raw_events: Also publish every raw event via
316
+ :meth:`MqttEventBridge.publish_event`. Defaults to ``True``.
317
+ """
318
+ # Imported here so the module's import graph doesn't force TrunkCallRouter on
319
+ # callers that only want raw-event publishing.
320
+ from somestuff.ucmstuff.ucm6204_api import TrunkCallRouter
321
+
322
+ if raw_events:
323
+ eventclient.add_event_handler(bridge.publish_event)
324
+ trunk_list = [t for t in trunks if t]
325
+ if trunk_list:
326
+ router = TrunkCallRouter(trunk_list, on_call=bridge.publish_incoming, on_end=bridge.publish_ended)
327
+ eventclient.add_event_handler(router.handle)
File without changes