updates2mqtt 1.6.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.
- updates2mqtt/__init__.py +5 -0
- updates2mqtt/__main__.py +6 -0
- updates2mqtt/app.py +233 -0
- updates2mqtt/config.py +176 -0
- updates2mqtt/hass_formatter.py +89 -0
- updates2mqtt/integrations/__init__.py +1 -0
- updates2mqtt/integrations/docker.py +607 -0
- updates2mqtt/integrations/git_utils.py +123 -0
- updates2mqtt/model.py +128 -0
- updates2mqtt/mqtt.py +349 -0
- updates2mqtt/py.typed +0 -0
- updates2mqtt-1.6.0.dist-info/METADATA +211 -0
- updates2mqtt-1.6.0.dist-info/RECORD +15 -0
- updates2mqtt-1.6.0.dist-info/WHEEL +4 -0
- updates2mqtt-1.6.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from re import Match
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
log = structlog.get_logger()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def git_trust(repo_path: Path, git_path: Path) -> bool:
|
|
13
|
+
try:
|
|
14
|
+
subprocess.run(f"{git_path} config --global --add safe.directory {repo_path}", check=True, shell=True, cwd=repo_path)
|
|
15
|
+
return True
|
|
16
|
+
except Exception as e:
|
|
17
|
+
log.warn("GIT Unable to trust repo at %s: %s", repo_path, e, action="git_trust")
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def git_iso_timestamp(repo_path: Path, git_path: Path) -> str | None:
|
|
22
|
+
result = None
|
|
23
|
+
try:
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
str(git_path) + r" log -1 --format=%cI --no-show-signature",
|
|
26
|
+
cwd=repo_path,
|
|
27
|
+
shell=True,
|
|
28
|
+
text=True,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
check=True,
|
|
31
|
+
)
|
|
32
|
+
# round-trip the iso format for pythony consistency
|
|
33
|
+
return datetime.datetime.fromisoformat(result.stdout.strip()).isoformat()
|
|
34
|
+
except subprocess.CalledProcessError as cpe:
|
|
35
|
+
log.warn("GIT No result from git log at %s: %s", repo_path, cpe, action="git_iso_timestamp")
|
|
36
|
+
except Exception as e:
|
|
37
|
+
log.error(
|
|
38
|
+
"GIT Unable to parse timestamp at %s - %s: %s",
|
|
39
|
+
repo_path,
|
|
40
|
+
result.stdout if result else "<NO RESULT>",
|
|
41
|
+
e,
|
|
42
|
+
action="git_iso_timestamp",
|
|
43
|
+
)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def git_local_version(repo_path: Path, git_path: Path) -> str | None:
|
|
48
|
+
result = None
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
f"{git_path} rev-parse HEAD",
|
|
52
|
+
cwd=repo_path,
|
|
53
|
+
shell=True,
|
|
54
|
+
text=True,
|
|
55
|
+
capture_output=True,
|
|
56
|
+
check=True,
|
|
57
|
+
)
|
|
58
|
+
if result.returncode == 0:
|
|
59
|
+
log.debug("Local git rev-parse", action="git_local_version", path=repo_path, version=result.stdout.strip())
|
|
60
|
+
return f"git:{result.stdout.strip()}"[:19]
|
|
61
|
+
except subprocess.CalledProcessError as cpe:
|
|
62
|
+
log.warn("GIT No result from git rev-parse at %s: %s", repo_path, cpe, action="git_local_version")
|
|
63
|
+
except Exception as e:
|
|
64
|
+
log.error(
|
|
65
|
+
"GIT Unable to retrieve version at %s - %s: %s",
|
|
66
|
+
repo_path,
|
|
67
|
+
result.stdout if result else "<NO RESULT>",
|
|
68
|
+
e,
|
|
69
|
+
action="git_local_version",
|
|
70
|
+
)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def git_check_update_available(repo_path: Path, git_path: Path, timeout: int = 120) -> int:
|
|
75
|
+
result = None
|
|
76
|
+
try:
|
|
77
|
+
# check if remote repo ahead
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
f"{git_path} fetch;{git_path} status -uno",
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
shell=True,
|
|
83
|
+
check=True,
|
|
84
|
+
cwd=repo_path,
|
|
85
|
+
timeout=timeout,
|
|
86
|
+
)
|
|
87
|
+
if result.returncode == 0:
|
|
88
|
+
count_match: Match[str] | None = re.search(
|
|
89
|
+
r"Your branch is behind.*by (\d+) commit", result.stdout, flags=re.MULTILINE
|
|
90
|
+
)
|
|
91
|
+
if count_match and count_match.groups():
|
|
92
|
+
log.debug(
|
|
93
|
+
"Local git repo update available: %s (%s)",
|
|
94
|
+
count_match.group(1),
|
|
95
|
+
result.stdout.strip(),
|
|
96
|
+
action="git_check",
|
|
97
|
+
path=repo_path,
|
|
98
|
+
)
|
|
99
|
+
return int(count_match.group(1))
|
|
100
|
+
log.debug("Local git repo no update available", action="git_check", path=repo_path, status=result.stdout.strip())
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
log.debug(
|
|
104
|
+
"No git update available",
|
|
105
|
+
action="git_check",
|
|
106
|
+
path=repo_path,
|
|
107
|
+
returncode=result.returncode,
|
|
108
|
+
stdout=result.stdout,
|
|
109
|
+
stderr=result.stderr,
|
|
110
|
+
)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
log.warn("GIT Unable to check status %s: %s", result.stdout if result else "<NO RESULT>", e, action="git_check")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def git_pull(repo_path: Path, git_path: Path) -> bool:
|
|
117
|
+
log.info("GIT Pulling git at %s", repo_path, action="git_pull")
|
|
118
|
+
proc = subprocess.run(f"{git_path} pull", shell=True, check=False, cwd=repo_path, timeout=300)
|
|
119
|
+
if proc.returncode == 0:
|
|
120
|
+
log.info("GIT pull at %s successful", repo_path, action="git_pull")
|
|
121
|
+
return True
|
|
122
|
+
log.warn("GIT pull at %s failed: %s", repo_path, proc.returncode, action="git_pull", stdout=proc.stdout, stderr=proc.stderr)
|
|
123
|
+
return False
|
updates2mqtt/model.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
from collections.abc import AsyncGenerator, Callable
|
|
4
|
+
from threading import Event
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Discovery:
|
|
11
|
+
"""Discovered component from a scan"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
provider: "ReleaseProvider",
|
|
16
|
+
name: str,
|
|
17
|
+
session: str,
|
|
18
|
+
node: str,
|
|
19
|
+
entity_picture_url: str | None = None,
|
|
20
|
+
current_version: str | None = None,
|
|
21
|
+
latest_version: str | None = None,
|
|
22
|
+
can_update: bool = False,
|
|
23
|
+
can_build: bool = False,
|
|
24
|
+
can_restart: bool = False,
|
|
25
|
+
status: str = "on",
|
|
26
|
+
update_type: str | None = "Update",
|
|
27
|
+
update_policy: str | None = None,
|
|
28
|
+
update_last_attempt: float | None = None,
|
|
29
|
+
release_url: str | None = None,
|
|
30
|
+
release_summary: str | None = None,
|
|
31
|
+
title_template: str = "{discovery.update_type} for {discovery.name} on {discovery.node}",
|
|
32
|
+
device_icon: str | None = None,
|
|
33
|
+
custom: dict[str, Any] | None = None,
|
|
34
|
+
features: list[str] | None = None,
|
|
35
|
+
throttled: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.provider: ReleaseProvider = provider
|
|
38
|
+
self.source_type: str = provider.source_type
|
|
39
|
+
self.session: str = session
|
|
40
|
+
self.name: str = name
|
|
41
|
+
self.node: str = node
|
|
42
|
+
self.entity_picture_url: str | None = entity_picture_url
|
|
43
|
+
self.current_version: str | None = current_version
|
|
44
|
+
self.latest_version: str | None = latest_version
|
|
45
|
+
self.can_update: bool = can_update
|
|
46
|
+
self.can_build: bool = can_build
|
|
47
|
+
self.can_restart: bool = can_restart
|
|
48
|
+
self.release_url: str | None = release_url
|
|
49
|
+
self.release_summary: str | None = release_summary
|
|
50
|
+
self.title_template: str | None = title_template
|
|
51
|
+
self.device_icon: str | None = device_icon
|
|
52
|
+
self.update_type: str | None = update_type
|
|
53
|
+
self.status: str = status
|
|
54
|
+
self.update_policy: str | None = update_policy
|
|
55
|
+
self.update_last_attempt: float | None = update_last_attempt
|
|
56
|
+
self.custom: dict[str, Any] = custom or {}
|
|
57
|
+
self.features: list[str] = features or []
|
|
58
|
+
self.throttled: bool = throttled
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
"""Build a custom string representation"""
|
|
62
|
+
return f"Discovery('{self.name}','{self.source_type}',current={self.current_version},latest={self.latest_version})"
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
"""Dump the attrs"""
|
|
66
|
+
|
|
67
|
+
def stringify(v: Any) -> str | int | float | bool:
|
|
68
|
+
return str(v) if not isinstance(v, (str, int, float, bool)) else v
|
|
69
|
+
|
|
70
|
+
dump = {k: stringify(v) for k, v in self.__dict__.items()}
|
|
71
|
+
return json.dumps(dump)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def title(self) -> str:
|
|
75
|
+
if self.title_template:
|
|
76
|
+
return self.title_template.format(discovery=self)
|
|
77
|
+
return self.name
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ReleaseProvider:
|
|
81
|
+
"""Abstract base class for release providers, such as container scanners or package managers API calls"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, source_type: str = "base") -> None:
|
|
84
|
+
self.source_type: str = source_type
|
|
85
|
+
self.discoveries: dict[str, Discovery] = {}
|
|
86
|
+
self.log: Any = structlog.get_logger().bind(integration=self.source_type)
|
|
87
|
+
self.stopped = Event()
|
|
88
|
+
|
|
89
|
+
def stop(self) -> None:
|
|
90
|
+
"""Stop any loops or background tasks"""
|
|
91
|
+
self.log.info("Asking release provider to stop", source_type=self.source_type)
|
|
92
|
+
self.stopped.set()
|
|
93
|
+
|
|
94
|
+
def __str__(self) -> str:
|
|
95
|
+
"""Stringify"""
|
|
96
|
+
return f"{self.source_type} Discovery"
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def update(self, discovery: Discovery) -> bool:
|
|
100
|
+
"""Attempt to update the component version"""
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def rescan(self, discovery: Discovery) -> Discovery | None:
|
|
104
|
+
"""Rescan a previously discovered component"""
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
async def scan(self, session: str) -> AsyncGenerator[Discovery]:
|
|
108
|
+
"""Scan for components to monitor"""
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
# force recognition as an async generator
|
|
111
|
+
if False: # type: ignore[unreachable]
|
|
112
|
+
yield 0
|
|
113
|
+
|
|
114
|
+
def hass_config_format(self, discovery: Discovery) -> dict:
|
|
115
|
+
_ = discovery
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
def hass_state_format(self, discovery: Discovery) -> dict:
|
|
119
|
+
_ = discovery
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
def command(self, discovery_name: str, command: str, on_update_start: Callable, on_update_end: Callable) -> bool:
|
|
124
|
+
"""Execute a command on a discovered component"""
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def resolve(self, discovery_name: str) -> Discovery | None:
|
|
128
|
+
"""Resolve a discovered component by name"""
|
updates2mqtt/mqtt.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from threading import Event
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import paho.mqtt.client as mqtt
|
|
10
|
+
import paho.mqtt.subscribeoptions
|
|
11
|
+
import structlog
|
|
12
|
+
from paho.mqtt.client import MQTT_CLEAN_START_FIRST_ONLY, MQTTMessage
|
|
13
|
+
from paho.mqtt.enums import CallbackAPIVersion, MQTTErrorCode, MQTTProtocolVersion
|
|
14
|
+
from paho.mqtt.properties import Properties
|
|
15
|
+
from paho.mqtt.reasoncodes import ReasonCode
|
|
16
|
+
|
|
17
|
+
from updates2mqtt.model import Discovery, ReleaseProvider
|
|
18
|
+
|
|
19
|
+
from .config import HomeAssistantConfig, MqttConfig, NodeConfig
|
|
20
|
+
from .hass_formatter import hass_format_config, hass_format_state
|
|
21
|
+
|
|
22
|
+
log = structlog.get_logger()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class LocalMessage:
|
|
27
|
+
topic: str | None = field(default=None)
|
|
28
|
+
payload: str | None = field(default=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MqttPublisher:
|
|
32
|
+
def __init__(self, cfg: MqttConfig, node_cfg: NodeConfig, hass_cfg: HomeAssistantConfig) -> None:
|
|
33
|
+
self.cfg: MqttConfig = cfg
|
|
34
|
+
self.node_cfg: NodeConfig = node_cfg
|
|
35
|
+
self.hass_cfg: HomeAssistantConfig = hass_cfg
|
|
36
|
+
self.providers_by_topic: dict[str, ReleaseProvider] = {}
|
|
37
|
+
self.event_loop: asyncio.AbstractEventLoop | None = None
|
|
38
|
+
self.client: mqtt.Client | None = None
|
|
39
|
+
self.fatal_failure = Event()
|
|
40
|
+
self.log = structlog.get_logger().bind(host=cfg.host, integration="mqtt")
|
|
41
|
+
|
|
42
|
+
def start(self, event_loop: asyncio.AbstractEventLoop | None = None) -> None:
|
|
43
|
+
logger = self.log.bind(action="start")
|
|
44
|
+
try:
|
|
45
|
+
protocol: MQTTProtocolVersion
|
|
46
|
+
if self.cfg.protocol in ("3", "3.11"):
|
|
47
|
+
protocol = MQTTProtocolVersion.MQTTv311
|
|
48
|
+
elif self.cfg.protocol == "3.1":
|
|
49
|
+
protocol = MQTTProtocolVersion.MQTTv31
|
|
50
|
+
elif self.cfg.protocol in ("5", "5.0"):
|
|
51
|
+
protocol = MQTTProtocolVersion.MQTTv5
|
|
52
|
+
else:
|
|
53
|
+
logger.info("No valid MQTT protocol version found (%s), setting to default v3.11", self.cfg.protocol)
|
|
54
|
+
protocol = MQTTProtocolVersion.MQTTv311
|
|
55
|
+
logger.debug("MQTT protocol set to %r", protocol)
|
|
56
|
+
|
|
57
|
+
self.event_loop = event_loop or asyncio.get_event_loop()
|
|
58
|
+
self.client = mqtt.Client(
|
|
59
|
+
callback_api_version=CallbackAPIVersion.VERSION2,
|
|
60
|
+
client_id=f"updates2mqtt_{self.node_cfg.name}",
|
|
61
|
+
clean_session=True if protocol != MQTTProtocolVersion.MQTTv5 else None,
|
|
62
|
+
protocol=protocol,
|
|
63
|
+
)
|
|
64
|
+
self.client.username_pw_set(self.cfg.user, password=self.cfg.password)
|
|
65
|
+
rc: MQTTErrorCode = self.client.connect(
|
|
66
|
+
host=self.cfg.host,
|
|
67
|
+
port=self.cfg.port,
|
|
68
|
+
keepalive=60,
|
|
69
|
+
clean_start=MQTT_CLEAN_START_FIRST_ONLY,
|
|
70
|
+
)
|
|
71
|
+
logger.info("Client connection requested", result_code=rc)
|
|
72
|
+
|
|
73
|
+
self.client.on_connect = self.on_connect
|
|
74
|
+
self.client.on_disconnect = self.on_disconnect
|
|
75
|
+
self.client.on_message = self.on_message
|
|
76
|
+
self.client.on_subscribe = self.on_subscribe
|
|
77
|
+
self.client.on_unsubscribe = self.on_unsubscribe
|
|
78
|
+
|
|
79
|
+
self.client.loop_start()
|
|
80
|
+
|
|
81
|
+
logger.debug("MQTT Publisher loop started", host=self.cfg.host, port=self.cfg.port)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error("Failed to connect to broker", host=self.cfg.host, port=self.cfg.port, error=str(e))
|
|
84
|
+
raise OSError(f"Connection Failure to {self.cfg.host}:{self.cfg.port} as {self.cfg.user} -- {e}") from e
|
|
85
|
+
|
|
86
|
+
def stop(self) -> None:
|
|
87
|
+
if self.client:
|
|
88
|
+
self.client.loop_stop()
|
|
89
|
+
self.client.disconnect()
|
|
90
|
+
self.client = None
|
|
91
|
+
|
|
92
|
+
def is_available(self) -> bool:
|
|
93
|
+
return self.client is not None and not self.fatal_failure.is_set()
|
|
94
|
+
|
|
95
|
+
def on_connect(
|
|
96
|
+
self, _client: mqtt.Client, _userdata: Any, _flags: mqtt.ConnectFlags, rc: ReasonCode, _props: Properties | None
|
|
97
|
+
) -> None:
|
|
98
|
+
if not self.client or self.fatal_failure.is_set():
|
|
99
|
+
self.log.warn("No client, check if started and authorized")
|
|
100
|
+
return
|
|
101
|
+
if rc.getName() == "Not authorized":
|
|
102
|
+
self.fatal_failure.set()
|
|
103
|
+
log.error("Invalid MQTT credentials", result_code=rc)
|
|
104
|
+
return
|
|
105
|
+
if rc != 0:
|
|
106
|
+
self.log.warning("Connection failed to broker", result_code=rc)
|
|
107
|
+
else:
|
|
108
|
+
self.log.debug("Connected to broker", result_code=rc)
|
|
109
|
+
for topic, provider in self.providers_by_topic.items():
|
|
110
|
+
self.log.debug("(Re)subscribing", topic=topic, provider=provider.source_type)
|
|
111
|
+
self.client.subscribe(topic)
|
|
112
|
+
|
|
113
|
+
def on_disconnect(
|
|
114
|
+
self,
|
|
115
|
+
_client: mqtt.Client,
|
|
116
|
+
_userdata: Any,
|
|
117
|
+
_disconnect_flags: mqtt.DisconnectFlags,
|
|
118
|
+
rc: ReasonCode,
|
|
119
|
+
_props: Properties | None,
|
|
120
|
+
) -> None:
|
|
121
|
+
if rc == 0:
|
|
122
|
+
self.log.debug("Disconnected from broker", result_code=rc)
|
|
123
|
+
else:
|
|
124
|
+
self.log.warning("Disconnect failure from broker", result_code=rc)
|
|
125
|
+
|
|
126
|
+
async def clean_topics(
|
|
127
|
+
self, provider: ReleaseProvider, last_scan_session: str | None, wait_time: int = 5, force: bool = False
|
|
128
|
+
) -> None:
|
|
129
|
+
logger = self.log.bind(action="clean")
|
|
130
|
+
if self.fatal_failure.is_set():
|
|
131
|
+
return
|
|
132
|
+
logger.info("Starting clean cycle")
|
|
133
|
+
cleaner = mqtt.Client(
|
|
134
|
+
callback_api_version=CallbackAPIVersion.VERSION1,
|
|
135
|
+
client_id=f"updates2mqtt_clean_{self.node_cfg.name}",
|
|
136
|
+
clean_session=True,
|
|
137
|
+
)
|
|
138
|
+
results = {"cleaned": 0, "handled": 0, "discovered": 0, "last_timestamp": time.time()}
|
|
139
|
+
cleaner.username_pw_set(self.cfg.user, password=self.cfg.password)
|
|
140
|
+
cleaner.connect(host=self.cfg.host, port=self.cfg.port, keepalive=60)
|
|
141
|
+
prefixes = [
|
|
142
|
+
f"{self.hass_cfg.discovery.prefix}/update/{self.node_cfg.name}_{provider.source_type}_",
|
|
143
|
+
f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}/",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
def cleanup(_client: mqtt.Client, _userdata: Any, msg: mqtt.MQTTMessage) -> None:
|
|
147
|
+
if msg.retain and any(msg.topic.startswith(prefix) for prefix in prefixes):
|
|
148
|
+
session = None
|
|
149
|
+
results["discovered"] += 1
|
|
150
|
+
try:
|
|
151
|
+
payload = self.safe_json_decode(msg.payload)
|
|
152
|
+
session = payload.get("source_session")
|
|
153
|
+
except Exception as e:
|
|
154
|
+
log.warn(
|
|
155
|
+
"Unable to handle payload for %s: %s",
|
|
156
|
+
msg.topic,
|
|
157
|
+
e,
|
|
158
|
+
exc_info=1,
|
|
159
|
+
)
|
|
160
|
+
results["handled"] += 1
|
|
161
|
+
results["last_timestamp"] = time.time()
|
|
162
|
+
if session is not None and last_scan_session is not None and session != last_scan_session:
|
|
163
|
+
log.debug("Removing stale msg", topic=msg.topic, session=session)
|
|
164
|
+
cleaner.publish(msg.topic, "", retain=True)
|
|
165
|
+
results["cleaned"] += 1
|
|
166
|
+
elif session is None and force:
|
|
167
|
+
log.debug("Removing untrackable msg", topic=msg.topic)
|
|
168
|
+
cleaner.publish(msg.topic, "", retain=True)
|
|
169
|
+
results["cleaned"] += 1
|
|
170
|
+
else:
|
|
171
|
+
log.debug(
|
|
172
|
+
"Retaining topic with current session: %s",
|
|
173
|
+
msg.topic,
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
log.debug("Skipping clean of %s", msg.topic)
|
|
177
|
+
|
|
178
|
+
cleaner.on_message = cleanup
|
|
179
|
+
options = paho.mqtt.subscribeoptions.SubscribeOptions(noLocal=True)
|
|
180
|
+
cleaner.subscribe(f"{self.hass_cfg.discovery.prefix}/update/#", options=options)
|
|
181
|
+
cleaner.subscribe(f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}/#", options=options)
|
|
182
|
+
|
|
183
|
+
while time.time() - results["last_timestamp"] <= wait_time:
|
|
184
|
+
cleaner.loop(0.5)
|
|
185
|
+
|
|
186
|
+
log.info(
|
|
187
|
+
f"Clean completed, discovered:{results['discovered']}, handled:{results['handled']}, cleaned:{results['cleaned']}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def safe_json_decode(self, jsonish: str | bytes | None) -> dict:
|
|
191
|
+
if jsonish is None:
|
|
192
|
+
return {}
|
|
193
|
+
try:
|
|
194
|
+
return json.loads(jsonish)
|
|
195
|
+
except Exception:
|
|
196
|
+
log.exception("JSON decode fail (%s)", jsonish)
|
|
197
|
+
try:
|
|
198
|
+
return json.loads(jsonish[1:-1])
|
|
199
|
+
except Exception:
|
|
200
|
+
log.exception("JSON decode fail (%s)", jsonish[1:-1])
|
|
201
|
+
return {}
|
|
202
|
+
|
|
203
|
+
async def execute_command(
|
|
204
|
+
self, msg: MQTTMessage | LocalMessage, on_update_start: Callable, on_update_end: Callable
|
|
205
|
+
) -> None:
|
|
206
|
+
logger = self.log.bind(topic=msg.topic, payload=msg.payload)
|
|
207
|
+
comp_name: str | None = None
|
|
208
|
+
command: str | None = None
|
|
209
|
+
try:
|
|
210
|
+
logger.info("Execution starting")
|
|
211
|
+
source_type: str | None = None
|
|
212
|
+
|
|
213
|
+
payload: str | None = None
|
|
214
|
+
if isinstance(msg.payload, bytes):
|
|
215
|
+
payload = msg.payload.decode("utf-8")
|
|
216
|
+
elif isinstance(msg.payload, str):
|
|
217
|
+
payload = msg.payload
|
|
218
|
+
if payload and "|" in payload:
|
|
219
|
+
source_type, comp_name, command = payload.split("|")
|
|
220
|
+
|
|
221
|
+
provider: ReleaseProvider | None = self.providers_by_topic.get(msg.topic) if msg.topic else None
|
|
222
|
+
if not provider:
|
|
223
|
+
logger.warn("Unexpected provider type %s", msg.topic)
|
|
224
|
+
elif provider.source_type != source_type:
|
|
225
|
+
logger.warn("Unexpected source type %s", source_type)
|
|
226
|
+
elif command != "install" or not comp_name:
|
|
227
|
+
logger.warn("Invalid payload in command message: %s", msg.payload)
|
|
228
|
+
else:
|
|
229
|
+
logger.info(
|
|
230
|
+
"Passing %s command to %s scanner for %s",
|
|
231
|
+
command,
|
|
232
|
+
source_type,
|
|
233
|
+
comp_name,
|
|
234
|
+
)
|
|
235
|
+
updated = provider.command(comp_name, command, on_update_start, on_update_end)
|
|
236
|
+
discovery = provider.resolve(comp_name)
|
|
237
|
+
if updated and discovery:
|
|
238
|
+
self.publish_hass_state(discovery)
|
|
239
|
+
else:
|
|
240
|
+
logger.debug("No change to republish after execution")
|
|
241
|
+
logger.info("Execution ended")
|
|
242
|
+
except Exception:
|
|
243
|
+
logger.exception("Execution failed", component=comp_name, command=command)
|
|
244
|
+
|
|
245
|
+
def local_message(self, discovery: Discovery, command: str) -> None:
|
|
246
|
+
"""Simulate an incoming MQTT message for local commands"""
|
|
247
|
+
msg = LocalMessage(
|
|
248
|
+
topic=self.command_topic(discovery.provider), payload="|".join([discovery.source_type, discovery.name, command])
|
|
249
|
+
)
|
|
250
|
+
self.handle_message(msg)
|
|
251
|
+
|
|
252
|
+
def on_subscribe(
|
|
253
|
+
self,
|
|
254
|
+
_client: mqtt.Client,
|
|
255
|
+
userdata: Any,
|
|
256
|
+
mid: int,
|
|
257
|
+
reason_code_list: list[ReasonCode],
|
|
258
|
+
properties: Properties | None = None,
|
|
259
|
+
) -> None:
|
|
260
|
+
self.log.debug(
|
|
261
|
+
"on_subscribe, userdata=%s, mid=%s, reasons=%s, properties=%s", userdata, mid, reason_code_list, properties
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def on_unsubscribe(
|
|
265
|
+
self,
|
|
266
|
+
_client: mqtt.Client,
|
|
267
|
+
userdata: Any,
|
|
268
|
+
mid: int,
|
|
269
|
+
reason_code_list: list[ReasonCode],
|
|
270
|
+
properties: Properties | None = None,
|
|
271
|
+
) -> None:
|
|
272
|
+
self.log.debug(
|
|
273
|
+
"on_unsubscribe, userdata=%s, mid=%s, reasons=%s, properties=%s", userdata, mid, reason_code_list, properties
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def on_message(self, _client: mqtt.Client, _userdata: Any, msg: mqtt.MQTTMessage) -> None:
|
|
277
|
+
"""Callback for incoming MQTT messages""" # noqa: D401
|
|
278
|
+
if msg.topic in self.providers_by_topic:
|
|
279
|
+
self.handle_message(msg)
|
|
280
|
+
else:
|
|
281
|
+
# apparently the root non-wildcard sub sometimes brings in child topics
|
|
282
|
+
self.log.debug("Unhandled message #%s on %s:%s", msg.mid, msg.topic, msg.payload)
|
|
283
|
+
|
|
284
|
+
def handle_message(self, msg: mqtt.MQTTMessage | LocalMessage) -> None:
|
|
285
|
+
def update_start(discovery: Discovery) -> None:
|
|
286
|
+
self.publish_hass_state(discovery, in_progress=True)
|
|
287
|
+
|
|
288
|
+
def update_end(discovery: Discovery) -> None:
|
|
289
|
+
self.publish_hass_state(discovery, in_progress=False)
|
|
290
|
+
|
|
291
|
+
if self.event_loop is not None:
|
|
292
|
+
asyncio.run_coroutine_threadsafe(self.execute_command(msg, update_start, update_end), self.event_loop)
|
|
293
|
+
else:
|
|
294
|
+
self.log.error("No event loop to handle message", topic=msg.topic)
|
|
295
|
+
|
|
296
|
+
def config_topic(self, discovery: Discovery) -> str:
|
|
297
|
+
prefix = self.hass_cfg.discovery.prefix
|
|
298
|
+
return f"{prefix}/update/{self.node_cfg.name}_{discovery.source_type}_{discovery.name}/update/config"
|
|
299
|
+
|
|
300
|
+
def state_topic(self, discovery: Discovery) -> str:
|
|
301
|
+
return f"{self.cfg.topic_root}/{self.node_cfg.name}/{discovery.source_type}/{discovery.name}"
|
|
302
|
+
|
|
303
|
+
def command_topic(self, provider: ReleaseProvider) -> str:
|
|
304
|
+
return f"{self.cfg.topic_root}/{self.node_cfg.name}/{provider.source_type}"
|
|
305
|
+
|
|
306
|
+
def publish_hass_state(self, discovery: Discovery, in_progress: bool = False) -> None:
|
|
307
|
+
self.log.debug("HASS State update, in progress: %s, discovery: %s", in_progress, discovery)
|
|
308
|
+
self.publish(
|
|
309
|
+
self.state_topic(discovery),
|
|
310
|
+
hass_format_state(
|
|
311
|
+
discovery,
|
|
312
|
+
discovery.session,
|
|
313
|
+
in_progress=in_progress,
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def publish_hass_config(self, discovery: Discovery) -> None:
|
|
318
|
+
object_id = f"{discovery.source_type}_{self.node_cfg.name}_{discovery.name}"
|
|
319
|
+
self.publish(
|
|
320
|
+
self.config_topic(discovery),
|
|
321
|
+
hass_format_config(
|
|
322
|
+
discovery=discovery,
|
|
323
|
+
object_id=object_id,
|
|
324
|
+
area=self.hass_cfg.area,
|
|
325
|
+
state_topic=self.state_topic(discovery),
|
|
326
|
+
command_topic=self.command_topic(discovery.provider),
|
|
327
|
+
force_command_topic=self.hass_cfg.force_command_topic,
|
|
328
|
+
device_creation=self.hass_cfg.device_creation,
|
|
329
|
+
session=discovery.session,
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def subscribe_hass_command(self, provider: ReleaseProvider): # noqa: ANN201
|
|
334
|
+
topic = self.command_topic(provider)
|
|
335
|
+
if topic in self.providers_by_topic or self.client is None:
|
|
336
|
+
self.log.debug("Skipping subscription", topic=topic)
|
|
337
|
+
else:
|
|
338
|
+
self.log.info("Handler subscribing", topic=topic)
|
|
339
|
+
self.providers_by_topic[topic] = provider
|
|
340
|
+
self.client.subscribe(topic)
|
|
341
|
+
return topic
|
|
342
|
+
|
|
343
|
+
def loop_once(self) -> None:
|
|
344
|
+
if self.client:
|
|
345
|
+
self.client.loop()
|
|
346
|
+
|
|
347
|
+
def publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = True) -> None:
|
|
348
|
+
if self.client:
|
|
349
|
+
self.client.publish(topic, payload=json.dumps(payload), qos=qos, retain=retain)
|
updates2mqtt/py.typed
ADDED
|
File without changes
|