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.
- somestuff/ucmstuff/__init__.py +112 -0
- somestuff/ucmstuff/example_router.py +110 -0
- somestuff/ucmstuff/mqtt_bridge.py +327 -0
- somestuff/ucmstuff/py.typed +0 -0
- somestuff/ucmstuff/ucm6204_api.py +1505 -0
- somestuff/ucmstuff/ucm6204_api_rest.py +428 -0
- somestuff_ucmstuff-0.1.3.dist-info/METADATA +240 -0
- somestuff_ucmstuff-0.1.3.dist-info/RECORD +11 -0
- somestuff_ucmstuff-0.1.3.dist-info/WHEEL +4 -0
- somestuff_ucmstuff-0.1.3.dist-info/entry_points.txt +2 -0
- somestuff_ucmstuff-0.1.3.dist-info/licenses/LICENSE.md +163 -0
|
@@ -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
|