From 1b111b8a4ff6c704d5008e5436180a8a271f0046 Mon Sep 17 00:00:00 2001 From: Jeff Culverhouse Date: Mon, 3 Nov 2025 23:50:18 -0500 Subject: [PATCH] fix: add last_device_check sensor; fix service status; only post messages on changes --- .github/workflows/deploy.yaml | 4 ++-- src/amcrest2mqtt/base.py | 1 - src/amcrest2mqtt/interface.py | 7 ++++--- src/amcrest2mqtt/mixins/amcrest_api.py | 12 ++++++++++++ src/amcrest2mqtt/mixins/helpers.py | 15 +++++++++++---- src/amcrest2mqtt/mixins/publish.py | 26 ++++++++++++++++++++++++-- src/amcrest2mqtt/mixins/refresh.py | 6 ++++-- 7 files changed, 57 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 186f9ce..a0f79b4 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -90,11 +90,11 @@ jobs: - name: Update VERSION file in repo if: steps.semrel.outputs.new_release_published == 'true' run: | - echo "${{ steps.semrel.outputs.new_release_version }}" > VERSION + echo "v${{ steps.semrel.outputs.new_release_version }}" > VERSION git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add VERSION - git diff --cached --quiet || git commit -m "chore: update VERSION to ${{ steps.semrel.outputs.new_release_version }}" + git diff --cached --quiet || git commit -m "chore: update VERSION to v${{ steps.semrel.outputs.new_release_version }}" git push docker: diff --git a/src/amcrest2mqtt/base.py b/src/amcrest2mqtt/base.py index 9603e41..2c8fbe7 100644 --- a/src/amcrest2mqtt/base.py +++ b/src/amcrest2mqtt/base.py @@ -59,7 +59,6 @@ class Base: self.device_interval = self.config["amcrest"].get("device_interval", 30) self.device_list_interval = self.config["amcrest"].get("device_list_interval", 300) - self.last_call_date = "" self.timezone = self.config["timezone"] self.api_calls = 0 diff --git a/src/amcrest2mqtt/interface.py b/src/amcrest2mqtt/interface.py index 50ae9a7..022ed6c 100644 --- a/src/amcrest2mqtt/interface.py +++ b/src/amcrest2mqtt/interface.py @@ -67,7 +67,7 @@ class AmcrestServiceProtocol(Protocol): def assert_no_tuples(self, data: Any, path: str = "root") -> None: ... def b_to_gb(self, total: int) -> float: ... def b_to_mb(self, total: int) -> float: ... - def build_device_states(self, device_id: str) -> None: ... + def build_device_states(self, device_id: str) -> bool: ... def classify_device(self, device: dict) -> str: ... def get_api_calls(self) -> int: ... def get_camera(self, host: str) -> AmcrestCamera: ... @@ -117,7 +117,8 @@ class AmcrestServiceProtocol(Protocol): def read_file(self, file_name: str) -> str: ... def rediscover_all(self) -> None: ... def safe_split_device(self, topic: str, segment: str) -> list[str]: ... + def set_last_call_date(self) -> None: ... def set_motion_detection(self, device_id: str, switch: bool) -> str: ... def set_privacy_mode(self, device_id: str, switch: bool) -> str: ... - def upsert_device(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> None: ... - def upsert_state(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> None: ... + def upsert_device(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> bool: ... + def upsert_state(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> bool: ... diff --git a/src/amcrest2mqtt/mixins/amcrest_api.py b/src/amcrest2mqtt/mixins/amcrest_api.py index e231904..6c804d6 100644 --- a/src/amcrest2mqtt/mixins/amcrest_api.py +++ b/src/amcrest2mqtt/mixins/amcrest_api.py @@ -23,6 +23,9 @@ class AmcrestAPIMixin: def get_last_call_date(self: Amcrest2Mqtt) -> str: return self.last_call_date + def set_last_call_date(self: Amcrest2Mqtt) -> None: + self.last_call_date = datetime.now(timezone.utc).isoformat() + def is_rate_limited(self: Amcrest2Mqtt) -> bool: return self.rate_limited @@ -58,6 +61,7 @@ class AmcrestAPIMixin: host_ip = self.get_ip_address(host) device = self.get_camera(host_ip) camera = device.camera + self.set_last_call_date() except LoginError: self.logger.error(f'invalid username/password to connect to device "{host}", fix in config.yaml') return @@ -122,6 +126,7 @@ class AmcrestAPIMixin: try: storage = device["camera"].storage_all + self.set_last_call_date() except CommError as err: self.logger.error(f"failed to get storage stats from ({self.get_device_name(device_id)}): {err}") return {} @@ -147,6 +152,7 @@ class AmcrestAPIMixin: privacy = device["camera"].privacy_config().split() privacy_mode = True if privacy[0].split("=")[1] == "true" else False device["privacy_mode"] = privacy_mode + self.set_last_call_date() except CommError as err: self.logger.error(f"failed to get privacy mode from ({self.get_device_name(device_id)}): {err}") return False @@ -164,6 +170,7 @@ class AmcrestAPIMixin: try: response = cast(str, device["camera"].set_privacy(switch).strip()) + self.set_last_call_date() except CommError as err: self.logger.error(f"failed to set privacy mode on ({self.get_device_name(device_id)}): {err}") return "" @@ -183,6 +190,7 @@ class AmcrestAPIMixin: try: motion_detection = bool(device["camera"].is_motion_detector_on()) + self.set_last_call_date() except CommError as err: self.logger.error(f"failed to get motion detection switch on ({self.get_device_name(device_id)}): {err}") return False @@ -200,6 +208,7 @@ class AmcrestAPIMixin: try: response = str(device["camera"].set_motion_detection(switch)) + self.set_last_call_date() except CommError: self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to set motion detections") return "" @@ -231,6 +240,7 @@ class AmcrestAPIMixin: for attempt in range(1, SNAPSHOT_MAX_TRIES + 1): try: image_bytes = await asyncio.wait_for(camera.async_snapshot(), timeout=SNAPSHOT_TIMEOUT_S) + self.set_last_call_date() if not image_bytes: self.logger.warning(f"Snapshot: empty image from {self.get_device_name(device_id)}") return None @@ -285,6 +295,7 @@ class AmcrestAPIMixin: while tries < 3: try: data_raw = cast(bytes, device["camera"].download_file(file)) + self.set_last_call_date() if data_raw: if not encode: if len(data_raw) < self.mb_to_b(100): @@ -326,6 +337,7 @@ class AmcrestAPIMixin: try: async for code, payload in device["camera"].async_event_actions("All"): await self.process_device_event(device_id, code, payload) + self.set_last_call_date() return except CommError: tries += 1 diff --git a/src/amcrest2mqtt/mixins/helpers.py b/src/amcrest2mqtt/mixins/helpers.py index a4dbdde..1517a18 100644 --- a/src/amcrest2mqtt/mixins/helpers.py +++ b/src/amcrest2mqtt/mixins/helpers.py @@ -26,12 +26,12 @@ class ConfigError(ValueError): class HelpersMixin: - def build_device_states(self: Amcrest2Mqtt, device_id: str) -> None: + def build_device_states(self: Amcrest2Mqtt, device_id: str) -> bool: storage = self.get_storage_stats(device_id) privacy = self.get_privacy_mode(device_id) motion_detection = self.get_motion_detection(device_id) - self.upsert_state( + changed = self.upsert_state( device_id, switch={ "privacy": "ON" if privacy else "OFF", @@ -43,6 +43,7 @@ class HelpersMixin: "storage_used_pct": storage["used_percent"], }, ) + return changed # send command to Amcrest ----------------------------------------------------------------------- @@ -291,12 +292,13 @@ class HelpersMixin: for idx, value in enumerate(data): self.assert_no_tuples(value, f"{path}[{idx}]") - def upsert_device(self: Amcrest2Mqtt, device_id: str, **kwargs: dict[str, Any] | str | int | bool | None) -> None: + def upsert_device(self: Amcrest2Mqtt, device_id: str, **kwargs: dict[str, Any] | str | int | bool | None) -> bool: MERGER = Merger( [(dict, "merge"), (list, "append_unique"), (set, "union")], ["override"], ["override"], ) + prev = self.devices.get(device_id, {}) for section, data in kwargs.items(): # Pre-merge check self.assert_no_tuples(data, f"device[{device_id}].{section}") @@ -304,15 +306,20 @@ class HelpersMixin: # Post-merge check self.assert_no_tuples(merged, f"device[{device_id}].{section} (post-merge)") self.devices[device_id] = merged + new = self.devices.get(device_id, {}) + return False if prev == new else True - def upsert_state(self: Amcrest2Mqtt, device_id: str, **kwargs: dict[str, Any] | str | int | bool | None) -> None: + def upsert_state(self: Amcrest2Mqtt, device_id: str, **kwargs: dict[str, Any] | str | int | bool | None) -> bool: MERGER = Merger( [(dict, "merge"), (list, "append_unique"), (set, "union")], ["override"], ["override"], ) + prev = self.states.get(device_id, {}) for section, data in kwargs.items(): self.assert_no_tuples(data, f"state[{device_id}].{section}") merged = MERGER.merge(self.states.get(device_id, {}), {section: data}) self.assert_no_tuples(merged, f"state[{device_id}].{section} (post-merge)") self.states[device_id] = merged + new = self.states.get(device_id, {}) + return False if prev == new else True diff --git a/src/amcrest2mqtt/mixins/publish.py b/src/amcrest2mqtt/mixins/publish.py index 95a2d29..eb90bca 100644 --- a/src/amcrest2mqtt/mixins/publish.py +++ b/src/amcrest2mqtt/mixins/publish.py @@ -24,9 +24,12 @@ class PublishMixin: { "name": self.service_name, "uniq_id": self.mqtt_helper.svc_unique_id("service"), - "stat_t": self.mqtt_helper.stat_t("service", "service"), + "stat_t": self.mqtt_helper.avty_t("service"), "avty_t": self.mqtt_helper.avty_t("service"), + "payload_on": "online", + "payload_off": "offline", "device_class": "connectivity", + "entity_category": "diagnostic", "icon": "mdi:server", "device": device_block, "origin": { @@ -49,6 +52,7 @@ class PublishMixin: "stat_t": self.mqtt_helper.stat_t("service", "service", "api_calls"), "avty_t": self.mqtt_helper.avty_t("service"), "unit_of_measurement": "calls", + "entity_category": "diagnostic", "icon": "mdi:api", "state_class": "total_increasing", "device": device_block, @@ -68,6 +72,7 @@ class PublishMixin: "payload_on": "YES", "payload_off": "NO", "device_class": "problem", + "entity_category": "diagnostic", "icon": "mdi:speedometer-slow", "device": device_block, } @@ -75,6 +80,23 @@ class PublishMixin: qos=self.mqtt_config["qos"], retain=True, ) + self.mqtt_helper.safe_publish( + topic=self.mqtt_helper.disc_t("sensor", "last_call_date"), + payload=json.dumps( + { + "name": f"{self.service_name} Last Device Check", + "uniq_id": self.mqtt_helper.svc_unique_id("last_call_date"), + "stat_t": self.mqtt_helper.stat_t("service", "service", "last_call_date"), + "avty_t": self.mqtt_helper.avty_t("service"), + "device_class": "timestamp", + "entity_category": "diagnostic", + "icon": "mdi:clock-outline", + "device": device_block, + } + ), + qos=self.mqtt_config["qos"], + retain=True, + ) self.mqtt_helper.safe_publish( topic=self.mqtt_helper.disc_t("number", "storage_refresh"), payload=json.dumps( @@ -158,7 +180,7 @@ class PublishMixin: def publish_service_state(self: Amcrest2Mqtt) -> None: service = { "api_calls": self.get_api_calls(), - "last_api_call": self.get_last_call_date(), + "last_call_date": self.get_last_call_date(), "rate_limited": "YES" if self.is_rate_limited() else "NO", "storage_refresh": self.device_interval, "device_list_refresh": self.device_list_interval, diff --git a/src/amcrest2mqtt/mixins/refresh.py b/src/amcrest2mqtt/mixins/refresh.py index 3a056eb..07afa55 100644 --- a/src/amcrest2mqtt/mixins/refresh.py +++ b/src/amcrest2mqtt/mixins/refresh.py @@ -9,13 +9,15 @@ if TYPE_CHECKING: class RefreshMixin: async def refresh_all_devices(self: Amcrest2Mqtt) -> None: - self.logger.info(f"Refreshing all devices from Amcrest (every {self.device_interval} sec)") + self.logger.info(f"refreshing device stats (every {self.device_interval} sec)") semaphore = asyncio.Semaphore(5) async def _refresh(device_id: str) -> None: async with semaphore: - await asyncio.to_thread(self.build_device_states, device_id) + changed = await asyncio.to_thread(self.build_device_states, device_id) + if changed: + await asyncio.to_thread(self.publish_device_state, device_id) tasks = [] for device_id in self.devices: