fix: add last_device_check sensor; fix service status; only post messages on changes

pull/106/head
Jeff Culverhouse 3 months ago
parent 33cd309005
commit 1b111b8a4f

@ -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:

@ -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

@ -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: ...

@ -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

@ -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

@ -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,

@ -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:

Loading…
Cancel
Save