# This software is licensed under the MIT License, which allows you to use, # copy, modify, merge, publish, distribute, and sell copies of the software, # with the requirement to include the original copyright notice and this # permission notice in all copies or substantial portions of the software. # # The software is provided 'as is', without any warranty. import asyncio from datetime import datetime import amcrest_api import json import logging import paho.mqtt.client as mqtt import random import signal import ssl import string import time from util import * from zoneinfo import ZoneInfo class AmcrestMqtt(object): def __init__(self, config): self.running = False self.paused = False self.logger = logging.getLogger(__name__) self.mqttc = None self.mqtt_connect_time = None self.config = config self.mqtt_config = config['mqtt'] self.amcrest_config = config['amcrest'] self.timezone = config['timezone'] self.version = config['version'] self.storage_update_interval = config['amcrest'].get('storage_update_interval', 900) self.snapshot_update_interval = config['amcrest'].get('snapshot_update_interval', 300) self.discovery_complete = False self.client_id = self.get_new_client_id() self.service_name = self.mqtt_config['prefix'] + ' service' self.service_slug = self.mqtt_config['prefix'] + '-service' self.configs = {} self.states = {} def __enter__(self): self.mqttc_create() self.amcrestc = amcrest_api.AmcrestAPI(self.config) self.running = True return self def __exit__(self, exc_type, exc_val, exc_tb): self.running = False self.logger.info('Exiting gracefully') if self.mqttc is not None and self.mqttc.is_connected(): self.mqttc.disconnect() else: self.logger.info('Lost connection to MQTT') # MQTT Functions ------------------------------------------------------------------------------ def mqtt_on_connect(self, client, userdata, flags, reason_code, properties): if reason_code.value != 0: self.logger.error(f'MQTT connection issue ({reason_code.getName()})') self.running = False return self.logger.info(f'MQTT connected as {self.client_id}') client.subscribe("homeassistant/status") client.subscribe(self.get_device_sub_topic()) client.subscribe(self.get_attribute_sub_topic()) def mqtt_on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties): self.logger.warning(f'MQTT disconnected: {reason_code.getName()} (flags={disconnect_flags})') self.mqttc.loop_stop() if self.running and time.time() > self.mqtt_connect_time + 10: self.paused = True self.logger.info('Sleeping for 30 seconds to give MQTT time to relax') time.sleep(30) # lets use a new client_id for a reconnect self.client_id = self.get_new_client_id() self.mqttc_create() self.paused = False else: self.running = False def mqtt_on_log(self, client, userdata, paho_log_level, msg): if paho_log_level == mqtt.LogLevel.MQTT_LOG_ERR: self.logger.error(f'MQTT LOG: {msg}') elif paho_log_level == mqtt.LogLevel.MQTT_LOG_WARNING: self.logger.warn(f'MQTT LOG: {msg}') def mqtt_on_message(self, client, userdata, msg): try: topic = msg.topic payload = json.loads(msg.payload) except json.JSONDecodeError: payload = msg.payload.decode('utf-8') except: self.logger.error('Failed to understand MQTT message, ignoring') return # we might get: # homeassistant/status # or one of ours: # */service/set # */service/set/attribute # */device/component/set # */device/component/set/attribute components = topic.split('/') if topic == "homeassistant/status": if payload == "online": self.rediscover_all() self.logger.info('HomeAssistant just came online, so resent all discovery messages') return # handle this message if it's for us, otherwise pass along to amcrest API if components[-2] == self.get_component_slug('service'): self.handle_service_message(None, payload) elif components[-3] == self.get_component_slug('service'): self.handle_service_message(components[-1], payload) else: if components[-1] == 'set': vendor, device_id = components[-2].split('-') elif components[-2] == 'set': vendor, device_id = components[-3].split('-') attribute = components[-1] # of course, we only care about our 'amcrest-' messages if not vendor or vendor != 'amcrest': return # ok, it's for us, lets announce it self.logger.debug(f'Incoming MQTT message for {topic} - {payload}') # if we only got back a scalar value, lets turn it into a dict with # the attribute name after `/set/` in the command topic if not isinstance(payload, dict) and attribute: payload = { attribute: payload } # if we just started, we might get messages immediately, lets # wait up to 3 min for devices to show up before we ignore the message checks = 0 while device_id not in self.states: checks += 1 # we'll try for 3 min, and then give up if checks > 36: self.logger.warn(f"Got MQTT message for a device we don't know: {device_id}") return time.sleep(5) self.logger.info(f'Got MQTT message for: {self.get_device_name(device_id)} - {payload}') # ok, lets format the device_id (not needed) and send to amcrest self.send_command(device_id, payload) def mqtt_on_subscribe(self, client, userdata, mid, reason_code_list, properties): rc_list = map(lambda x: x.getName(), reason_code_list) self.logger.debug(f'MQTT SUBSCRIBED: reason_codes - {'; '.join(rc_list)}') # MQTT Helpers -------------------------------------------------------------------------------- def mqttc_create(self): self.mqttc = mqtt.Client( client_id=self.client_id, callback_api_version=mqtt.CallbackAPIVersion.VERSION2, reconnect_on_failure=False, protocol=mqtt.MQTTv5, ) if self.mqtt_config.get('tls_enabled'): self.mqttc.tls_set( ca_certs=self.mqtt_config.get('tls_ca_cert'), certfile=self.mqtt_config.get('tls_cert'), keyfile=self.mqtt_config.get('tls_key'), cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS, ) self.mqttc.tls_insecure_set(self.mqtt_config.get("tls_insecure", False)) if self.mqtt_config.get('username'): self.mqttc.username_pw_set( username=self.mqtt_config.get('username'), password=self.mqtt_config.get('password'), ) self.mqttc.on_connect = self.mqtt_on_connect self.mqttc.on_disconnect = self.mqtt_on_disconnect self.mqttc.on_message = self.mqtt_on_message self.mqttc.on_subscribe = self.mqtt_on_subscribe self.mqttc.on_log = self.mqtt_on_log # will_set for service device self.mqttc.will_set(self.get_discovery_topic('service', 'availability'), payload="offline", qos=self.mqtt_config['qos'], retain=True) try: self.logger.info( f"Connecting to MQTT broker at {self.mqtt_config.get('host')}:{self.mqtt_config.get('port')} " f"as {self.client_id}" ) self.mqttc.connect( host=self.mqtt_config.get('host'), port=self.mqtt_config.get('port'), keepalive=60, ) self.mqtt_connect_time = time.time() self.mqttc.loop_start() except Exception as error: self.logger.error( f"Failed to connect to MQTT broker {self.mqtt_config.get('host')}:{self.mqtt_config.get('port')} " f"({type(error).__name__}: {error})", exc_info=True, ) self.running = False # MQTT Topics --------------------------------------------------------------------------------- def get_new_client_id(self): return self.mqtt_config['prefix'] + '-' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=8)) def get_device_name(self, device_id): if device_id not in self.configs or 'device' not in self.configs[device_id] or 'name' not in self.configs[device_id]['device']: return f'<{device_id}>' return self.configs[device_id]['device']['name'] def get_slug(self, device_id, type): return f"amcrest_{device_id.replace(':','')}_{type}" def get_device_sub_topic(self): if 'homeassistant' not in self.mqtt_config or self.mqtt_config['homeassistant'] == False: return f"{self.mqtt_config['prefix']}/+/set" return f"{self.mqtt_config['discovery_prefix']}/device/+/set" def get_attribute_sub_topic(self): if 'homeassistant' not in self.mqtt_config or self.mqtt_config['homeassistant'] == False: return f"{self.mqtt_config['prefix']}/+/set" return f"{self.mqtt_config['discovery_prefix']}/device/+/set/+" def get_component_slug(self, device_id): return f"amcrest-{device_id.replace(':','')}" def get_command_topic(self, device_id, attribute_name): if attribute_name: if 'homeassistant' not in self.mqtt_config or self.mqtt_config['homeassistant'] == False: return f"{self.mqtt_config['prefix']}/{self.get_component_slug(device_id)}/set/{attribute_name}" return f"{self.mqtt_config['discovery_prefix']}/device/{self.get_component_slug(device_id)}/set/{attribute_name}" else: if 'homeassistant' not in self.mqtt_config or self.mqtt_config['homeassistant'] == False: return f"{self.mqtt_config['prefix']}/{self.get_component_slug(device_id)}/set" return f"{self.mqtt_config['discovery_prefix']}/device/{self.get_component_slug(device_id)}/set" def get_discovery_topic(self, device_id, topic): if 'homeassistant' not in self.mqtt_config or self.mqtt_config['homeassistant'] == False: return f"{self.mqtt_config['prefix']}/{self.get_component_slug(device_id)}/{topic}" return f"{self.mqtt_config['discovery_prefix']}/device/{self.get_component_slug(device_id)}/{topic}" def get_discovery_topic(self, device_id, topic): if 'homeassistant' not in self.mqtt_config or self.mqtt_config['homeassistant'] == False: return f"{self.mqtt_config['prefix']}/{self.get_component_slug(device_id)}/{topic}" return f"{self.mqtt_config['discovery_prefix']}/device/{self.get_component_slug(device_id)}/{topic}" def get_discovery_subtopic(self, device_id, topic, subtopic): if 'homeassistant' not in self.mqtt_config or self.mqtt_config['homeassistant'] == False: return f"{self.mqtt_config['prefix']}/{self.get_component_slug(device_id)}/{topic}/{subtopic}" return f"{self.mqtt_config['discovery_prefix']}/device/{self.get_component_slug(device_id)}/{topic}/{subtopic}" def ha_cfg_topic(self, domain: str, object_id: str) -> str: dp = self.mqtt_config.get('discovery_prefix', 'homeassistant') return f"{dp}/{domain}/{object_id}/config" def service_oid(self, suffix: str) -> str: return f"{self.service_slug}_{suffix}" def svc_topic(self, sub: str) -> str: # Runtime topics (your own prefix), e.g. govee2mqtt/govee-service/state pfx = self.mqtt_config.get('prefix', 'govee2mqtt') return f"{pfx}/amcrest-service/{sub}" # Service Device ------------------------------------------------------------------------------ def publish_service_state(self): if 'service' not in self.states: self.states['service'] = { 'availability': 'online', 'state': { 'state': 'ON' }, 'intervals': {}, } service_states = self.states['service'] # update states service_states['state'] = { 'state': 'ON', } service_states['intervals'] = { 'storage_refresh': self.storage_update_interval, 'snapshot_refresh': self.snapshot_update_interval, } for topic in ['state','availability','intervals']: if topic in service_states: payload = json.dumps(service_states[topic]) if isinstance(service_states[topic], dict) else service_states[topic] self.mqttc.publish(self.get_discovery_topic('service', topic), payload, qos=self.mqtt_config['qos'], retain=True) def publish_service_device(self): state_topic = self.get_discovery_topic('service', 'state') availability_topic = self.get_discovery_topic('service', 'availability') self.mqttc.publish( self.get_discovery_topic('service','config'), json.dumps({ 'qos': self.mqtt_config['qos'], 'state_topic': state_topic, 'availability_topic': availability_topic, 'device': { 'name': self.service_name, 'ids': self.service_slug, 'suggested_area': 'House', 'manufacturer': 'weirdTangent', 'model': self.version, }, 'origin': { 'name': self.service_name, 'sw_version': self.version, 'support_url': 'https://github.com/weirdtangent/amcrest2mqtt', }, 'components': { self.service_slug + '_status': { 'name': 'Service', 'platform': 'binary_sensor', 'schema': 'json', 'payload_on': 'ON', 'payload_off': 'OFF', 'icon': 'mdi:language-python', 'state_topic': state_topic, 'value_template': '{{ value_json.state }}', 'unique_id': 'amcrest_service_status', }, self.service_slug + '_storage_refresh': { 'name': 'Storage Refresh Interval', 'platform': 'number', 'schema': 'json', 'icon': 'mdi:numeric', 'min': 10, 'max': 3600, 'state_topic': self.get_discovery_topic('service', 'intervals'), 'command_topic': self.get_command_topic('service', 'storage_refresh'), 'value_template': '{{ value_json.storage_refresh }}', 'unique_id': 'amcrest_service_storage_refresh', }, self.service_slug + '_snapshot_refresh': { 'name': 'Snapshot Refresh Interval', 'platform': 'number', 'schema': 'json', 'icon': 'mdi:numeric', 'min': 10, 'max': 3600, 'state_topic': self.get_discovery_topic('service', 'intervals'), 'command_topic': self.get_command_topic('service', 'snapshot_refresh'), 'value_template': '{{ value_json.snapshot_refresh }}', 'unique_id': 'amcrest_service_snapshot_refresh', }, self.service_slug + '_rediscover': { 'name': 'Rediscover Devices', 'platform': 'button', 'icon': 'mdi:refresh', 'command_topic': self.get_command_topic('service', 'rediscover'), 'payload_press': 'PRESS', 'unique_id': f'{self.service_slug}_rediscover_button', }, }, }), qos=self.mqtt_config['qos'], retain=True ) # Amcrest Helpers ----------------------------------------------------------------------------- # setup devices ------------------------------------------------------------------------------- async def setup_devices(self): self.logger.info(f'Setup devices') first_time_through = True if len(self.configs) == 0 else False devices = await self.amcrestc.connect_to_devices() self.publish_service_device() for device_id in devices: config = devices[device_id] if 'device_type' in config: first = False if device_id not in self.configs: first = True self.configs[device_id] = {} self.states[device_id] = config self.configs[device_id]['qos'] = self.mqtt_config['qos'] self.configs[device_id]['state_topic'] = self.get_discovery_topic(device_id, 'state') self.configs[device_id]['availability_topic'] = self.get_discovery_topic('service', 'availability') self.configs[device_id]['command_topic'] = self.get_discovery_topic(device_id, 'set') self.configs[device_id]['device'] = { 'name': config['device_name'], 'manufacturer': config['vendor'], 'model': config['device_type'], 'ids': device_id, 'sw_version': config['software_version'], 'hw_version': config['hardware_version'], 'connections': [ ['host', config['host']], ['mac', config['network']['mac']], ['ip address', config['network']['ip_address']], ], 'configuration_url': 'http://' + config['host'] + '/', 'via_device': self.service_slug, } self.configs[device_id]['origin'] = { 'name': self.service_name, 'sw_version': self.version, 'support_url': 'https://github.com/weirdtangent/amcrest2mqtt', } # setup initial satte self.states[device_id]['state'] = { 'state': 'ON', 'last_update': str(datetime.now(ZoneInfo(self.timezone))), 'host': config['host'], 'serial_number': config['serial_number'], 'sw_version': config['software_version'], } self.add_components_to_device(device_id) if first: self.logger.info(f'Adding device: "{config['device_name']}" [Amcrest {config["device_type"]}] ({device_id})') self.publish_device_discovery(device_id) else: self.logger.debug(f'Updated device: {self.configs[device_id]['device']['name']}') else: if first_time_through: self.logger.info(f'Saw device, but not supported yet: "{config["device_name"]}" [amcrest {config["device_type"]}] ({device_id})') # lets log our first time through and then release the hounds if not self.discovery_complete: self.logger.info('Device setup and discovery is done') self.discovery_complete = True # add amcrest components to devices def add_components_to_device(self, device_id): device_config = self.configs[device_id] device_states = self.states[device_id] components = {} if device_states['is_doorbell']: doorbell_name = 'Doorbell' if device_states['device_name'] == 'Doorbell' else f'{device_states["device_name"]} Doorbell' components[self.get_slug(device_id, 'doorbell')] = { 'name': doorbell_name, 'platform': 'binary_sensor', 'payload_on': 'on', 'payload_off': 'off', 'device_class': '', 'icon': 'mdi:doorbell', 'state_topic': self.get_discovery_topic(device_id, 'doorbell'), 'value_template': '{{ value_json.doorbell }}', 'unique_id': self.get_slug(device_id, 'doorbell'), } device_states['doorbell'] = {} if device_states['is_ad410']: components[self.get_slug(device_id, 'human')] = { 'name': 'Human', 'platform': 'binary_sensor', 'payload_on': 'on', 'payload_off': 'off', 'device_class': 'motion', 'state_topic': self.get_discovery_topic(device_id, 'human'), 'value_template': '{{ value_json.human }}', 'unique_id': self.get_slug(device_id, 'human'), } device_states['human'] = 'off' components[self.get_slug(device_id, 'snapshot_camera')] = { 'name': 'Latest snapshot', 'platform': 'camera', 'topic': self.get_discovery_subtopic(device_id, 'camera','snapshot'), 'image_encoding': 'b64', 'state_topic': device_config['state_topic'], 'value_template': '{{ value_json.state }}', 'unique_id': self.get_slug(device_id, 'snapshot_camera'), } # --- Safe WebRTC config handling ---------------------------------------- webrtc_config = self.amcrest_config.get("webrtc") # Handle missing, boolean, or incomplete configs gracefully if isinstance(webrtc_config, bool) or not webrtc_config: self.logger.debug("No valid WebRTC config found; skipping WebRTC setup.") else: try: rtc_host = webrtc_config.get("host") rtc_port = webrtc_config.get("port") rtc_link = webrtc_config.get("link") rtc_sources = webrtc_config.get("sources", []) rtc_source = rtc_sources[0] if rtc_sources else None if rtc_host and rtc_port and rtc_link and rtc_source: rtc_url = f"http://{rtc_host}:{rtc_port}/{rtc_link}?src={rtc_source}" device_config["device"]["configuration_url"] = rtc_url self.logger.debug(f"Added WebRTC config URL for {device_id}: {rtc_url}") else: self.logger.warning( f"Incomplete WebRTC config for {device_id}: {webrtc_config}" ) except Exception as e: self.logger.warning( f"Failed to apply WebRTC config for {device_id}: {e}", exc_info=True ) # copy the snapshot camera for the eventshot camera, with a couple of changes components[self.get_slug(device_id, 'event_camera')] = { 'name': 'Motion capture', 'platform': 'image', 'image_encoding': 'b64', 'state_topic': device_config['state_topic'], 'value_template': '{{ value_json.state }}', 'image_topic': self.get_discovery_subtopic(device_id, 'camera','eventshot'), 'unique_id': self.get_slug(device_id, 'eventshot_camera'), } device_states['camera'] = {'snapshot': None, 'eventshot': None} components[self.get_slug(device_id, 'privacy_mode')] = { 'name': 'Privacy mode', 'platform': 'switch', 'payload_on': 'on', 'payload_off': 'off', 'device_class': 'switch', 'icon': 'mdi:camera-off', 'state_topic': self.get_discovery_topic(device_id, 'privacy_mode'), 'command_topic': self.get_command_topic(device_id, 'privacy_mode'), 'unique_id': self.get_slug(device_id, 'privacy_mode'), } device_states['privacy_mode'] = None components[self.get_slug(device_id, 'motion_detection')] = { 'name': 'Motion detection', 'platform': 'switch', 'payload_on': 'on', 'payload_off': 'off', 'device_class': 'switch', 'icon': 'mdi:motion-sensor', 'state_topic': self.get_discovery_topic(device_id, 'motion_detection'), 'command_topic': self.get_command_topic(device_id, 'motion_detection'), 'unique_id': self.get_slug(device_id, 'motion_detection'), } device_states['motion_detection'] = None components[self.get_slug(device_id, 'motion')] = { 'name': 'Motion', 'platform': 'binary_sensor', 'payload_on': 'on', 'payload_off': 'off', 'device_class': 'motion', 'state_topic': self.get_discovery_topic(device_id, 'motion'), 'json_attributes_topic': self.get_discovery_topic(device_id, 'motion'), 'value_template': '{{ value_json.state }}', 'unique_id': self.get_slug(device_id, 'motion'), } components[self.get_slug(device_id, 'motion_region')] = { 'name': 'Motion region', 'platform': 'sensor', 'state_topic': self.get_discovery_topic(device_id, 'motion'), 'value_template': '{{ value_json.region }}', 'unique_id': self.get_slug(device_id, 'motion_region'), } device_states['motion'] = { 'state': 'off', 'region': None } components[self.get_slug(device_id, 'version')] = { 'name': 'Version', 'platform': 'sensor', 'icon': 'mdi:package-up', 'state_topic': device_config['state_topic'], 'value_template': '{{ value_json.sw_version }}', 'entity_category': 'diagnostic', 'unique_id': self.get_slug(device_id, 'sw_version'), } components[self.get_slug(device_id, 'serial_number')] = { 'name': 'Serial Number', 'platform': 'sensor', 'icon': 'mdi:identifier', 'state_topic': device_config['state_topic'], 'value_template': '{{ value_json.serial_number }}', 'entity_category': 'diagnostic', 'unique_id': self.get_slug(device_id, 'serial_number'), } components[self.get_slug(device_id, 'host')] = { 'name': 'Host', 'platform': 'sensor', 'icon': 'mdi:ip-network', 'state_topic': device_config['state_topic'], 'value_template': '{{ value_json.host }}', 'entity_category': 'diagnostic', 'unique_id': self.get_slug(device_id, 'host'), } components[self.get_slug(device_id, 'event')] = { 'name': 'Last event', 'platform': 'sensor', 'state_topic': self.get_discovery_topic(device_id, 'event'), 'unique_id': self.get_slug(device_id, 'event'), } device_states['event'] = None device_states['recording'] = {} components[self.get_slug(device_id, 'storage_used_percent')] = { 'name': 'Storage used %', 'platform': 'sensor', 'icon': 'mdi:micro-sd', 'unit_of_measurement': '%', 'state_topic': self.get_discovery_topic(device_id, 'storage'), 'value_template': '{{ value_json.used_percent }}', 'unique_id': self.get_slug(device_id, 'storage_used_percent'), } components[self.get_slug(device_id, 'storage_total')] = { 'name': 'Storage total', 'platform': 'sensor', 'icon': 'mdi:micro-sd', 'unit_of_measurement': 'GB', 'state_topic': self.get_discovery_topic(device_id, 'storage'), 'value_template': '{{ value_json.total }}', 'unique_id': self.get_slug(device_id, 'storage_total'), } components[self.get_slug(device_id, 'storage_used')] = { 'name': 'Storage used', 'platform': 'sensor', 'icon': 'mdi:micro-sd', 'unit_of_measurement': 'GB', 'state_topic': self.get_discovery_topic(device_id, 'storage'), 'value_template': '{{ value_json.used }}', 'unique_id': self.get_slug(device_id, 'storage_used'), } device_states['storage'] = {} components[self.get_slug(device_id, 'last_update')] = { 'name': 'Last Update', 'platform': 'sensor', 'device_class': 'timestamp', 'entity_category': 'diagnostic', 'state_topic': device_config['state_topic'], 'value_template': '{{ value_json.last_update }}', 'unique_id': self.get_slug(device_id, 'last_update'), } device_config['components'] = components def publish_device_state(self, device_id): device_states = self.states[device_id] device_states['state']['last_update'] = str(datetime.now(ZoneInfo(self.timezone))) for topic in ['state','storage','motion','human','doorbell','event','recording','privacy_mode','motion_detection']: if topic in device_states: publish_topic = self.get_discovery_topic(device_id, topic) payload = json.dumps(device_states[topic]) if isinstance(device_states[topic], dict) else device_states[topic] self.mqttc.publish(publish_topic, payload, qos=self.mqtt_config['qos'], retain=True) for image_type in ['snapshot','eventshot']: if image_type in device_states['camera'] and device_states['camera'][image_type] is not None: publish_topic = self.get_discovery_subtopic(device_id, 'camera',image_type) payload = device_states['camera'][image_type] self.mqttc.publish(publish_topic, payload, qos=self.mqtt_config['qos'], retain=True) def publish_device_discovery(self, device_id): device_config = self.configs[device_id] payload = json.dumps(device_config) self.mqttc.publish(self.get_discovery_topic(device_id, 'config'), payload, qos=self.mqtt_config['qos'], retain=True) # refresh * all devices ----------------------------------------------------------------------- def refresh_storage_all_devices(self): self.logger.info(f'Refreshing storage info for all devices (every {self.storage_update_interval} sec)') for device_id in self.configs: if not self.running: break device_states = self.states[device_id] # update the privacy mode switch # we don't need to verify this often since events should let us know privacy_mode = self.amcrestc.get_privacy_mode(device_id) if privacy_mode is not None: device_states['privacy_mode'] = 'on' if privacy_mode == True else 'off' # update the motion detection switch motion_detection = self.amcrestc.get_motion_detection(device_id) device_states['motion_detection'] = 'on' if motion_detection == True else 'off' storage = self.amcrestc.get_storage_stats(device_id) if storage is not None: device_states['storage'] = storage self.publish_service_state() self.publish_device_state(device_id) def refresh_snapshot_all_devices(self): self.logger.info(f'Collecting snapshots for all devices (every {self.snapshot_update_interval} sec)') for device_id in self.configs: if not self.running: break self.refresh_snapshot(device_id,'snapshot') # type is 'snapshot' for normal, or 'eventshot' for capturing an image immediately after a "movement" event def refresh_snapshot(self, device_id, type): device_states = self.states[device_id] image = self.amcrestc.get_snapshot(device_id) if image is None: return # only store and send to MQTT if the image has changed if device_states['camera'][type] is None or device_states['camera'][type] != image: device_states['camera'][type] = image self.publish_service_state() self.publish_device_state(device_id) def get_recorded_file(self, device_id, file, type): device_states = self.states[device_id] self.logger.info(f'Getting recorded file "{file}"') image = self.amcrestc.get_recorded_file(device_id, file) if image is None: self.logger.info(f'Failed to get recorded file from {self.get_device_name(device_id)}') return # only store and send to MQTT if the image has changed if device_states['camera'][type] is None or device_states['camera'][type] != image: device_states['camera'][type] = image self.publish_service_state() self.publish_device_state(device_id) # send command to Amcrest -------------------------------------------------------------------- def send_command(self, device_id, data): device_states = self.states[device_id] if data == 'PRESS': self.logger.info(f'We got a PRESS command for {self.get_device_name(device_id)}') pass elif 'privacy_mode' in data: set_privacy_to = False if data['privacy_mode'] == 'off' else True self.logger.info(f'Setting PRIVACY_MODE to {set_privacy_to} for {self.get_device_name(device_id)}') response = self.amcrestc.set_privacy_mode(device_id, set_privacy_to) # if Amcrest device was good with that command, lets update state and then MQTT if response == 'OK': device_states['privacy_mode'] = data['privacy_mode'] self.publish_device_state(device_id) else: self.logger.error(f'Setting PRIVACY_MODE failed: {repr(response)}') elif 'motion_detection' in data: set_motion_detection_to = False if data['motion_detection'] == 'off' else True self.logger.info(f'Setting MOTION_DETECTION to {set_motion_detection_to} for {self.get_device_name(device_id)}') response = self.amcrestc.set_motion_detection(device_id, set_motion_detection_to) # if Amcrest device was good with that command, lets update state and then MQTT if response == True: device_states['motion_detection'] = data['motion_detection'] self.publish_device_state(device_id) else: self.logger.error(f'Setting MOTION_DETECTION failed: {repr(response)}') else: self.logger.error(f'We got a command ({data}), but do not know what to do') def handle_service_message(self, attribute, message): match attribute: case "storage_refresh": self.storage_update_interval = message self.logger.info(f"Updated STORAGE_REFRESH_INTERVAL to be {message}") case 'snapshot_refresh': self.snapshot_update_interval = message self.logger.info(f'Updated SNAPSHOT_REFRESH_INTERVAL to be {message}') case 'rediscover': self.rediscover_all() self.logger.info('REDISCOVER button pressed - resent all discovery messages') case _: self.logger.info(f'IGNORED UNRECOGNIZED amcrest-service MESSAGE for {attribute}: {message}') return self.publish_service_state() # collect events and then check queue of events ----------------------------------------------- async def collect_all_device_events(self): await self.amcrestc.collect_all_device_events() def check_for_events(self): try: while device_event := self.amcrestc.get_next_event(): if device_event is None: break if 'device_id' not in device_event: self.logger(f'Got event, but missing device_id: {device_event}') continue device_id = device_event['device_id'] event = device_event['event'] payload = device_event['payload'] device_states = self.states[device_id] # if one of our known sensors if event in ['motion','human','doorbell','recording','privacy_mode']: if event == 'recording' and payload['file'].endswith('.jpg'): self.get_recorded_file(device_id, payload['file'], 'eventshot') else: # only log details if not a recording if event != 'recording': self.logger.info(f'Got event for {self.get_device_name(device_id)}: {event} - {payload}') device_states[event] = payload # other ways to infer "privacy mode" is off and needs updating if event in ['motion','human','doorbell'] and device_states['privacy_mode'] == 'on': device_states['privacy_mode'] = 'off' else: self.logger.info(f'Got {{{event}: {payload}}} for "{self.get_device_name(device_id)}"') device_states['event'] = f'{event}: {payload}' self.publish_device_state(device_id) except Exception as err: self.logger.error(err, exc_info=True) def rediscover_all(self): self.publish_service_state() for device_id in self.configs: if device_id == 'service': continue self.publish_device_state(device_id) self.publish_device_discovery(device_id) # async loops and main loop ------------------------------------------------------------------- async def _handle_signals(self, signame, loop): self.running = False self.logger.warn(f'{signame} received, waiting for tasks to cancel...') for task in asyncio.all_tasks(): if not task.done(): task.cancel(f'{signame} received') async def collect_storage_info(self): while self.running == True: if not self.paused: self.refresh_storage_all_devices() if self.running: await asyncio.sleep(self.storage_update_interval) async def collect_events(self): while self.running == True: if not self.paused: await self.collect_all_device_events() if self.running: await asyncio.sleep(1) async def check_event_queue(self): while self.running == True: if not self.paused: self.check_for_events() if self.running: await asyncio.sleep(1) async def collect_snapshots(self): while self.running == True: await self.amcrestc.collect_all_device_snapshots() if not self.paused: self.refresh_snapshot_all_devices() if self.running: await asyncio.sleep(self.snapshot_update_interval) # main loop async def main_loop(self): """Main event loop for Amcrest MQTT service.""" await self.setup_devices() loop = asyncio.get_running_loop() # Create async tasks with descriptive names tasks = [ asyncio.create_task( self.collect_storage_info(), name="collect_storage_info" ), asyncio.create_task(self.collect_events(), name="collect_events"), asyncio.create_task(self.check_event_queue(), name="check_event_queue"), asyncio.create_task(self.collect_snapshots(), name="collect_snapshots"), ] # Graceful signal handler def _signal_handler(signame): """Immediate, aggressive shutdown handler for Ctrl+C or SIGTERM.""" self.logger.warning(f"{signame} received — initiating shutdown NOW...") self.running = False # Cancel *all* asyncio tasks, even those not tracked manually loop = asyncio.get_event_loop() for task in asyncio.all_tasks(loop): if not task.done(): task.cancel(f"{signame} received") # Force-stop ProcessPoolExecutor if present try: if hasattr(self, "api") and hasattr(self.api, "executor"): self.logger.debug("Force-shutting down process pool...") self.api.executor.shutdown(wait=False, cancel_futures=True) except Exception as e: self.logger.debug(f"Error force-stopping process pool: {e}") # Stop the loop immediately after a short delay loop.call_later(0.05, loop.stop) for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, _signal_handler, sig.name) except NotImplementedError: # Windows compatibility self.logger.debug(f"Signal handling not supported on this platform.") try: results = await asyncio.gather(*tasks, return_exceptions=True) # Handle task exceptions individually for t, result in zip(tasks, results): if isinstance(result, asyncio.CancelledError): self.logger.info(f"Task '{t.get_name()}' cancelled.") elif isinstance(result, Exception): self.logger.error( f"Task '{t.get_name()}' raised an exception: {result}", exc_info=True, ) self.running = False except asyncio.CancelledError: self.logger.info("Main loop cancelled; shutting down...") except Exception as err: self.logger.exception(f"Unhandled exception in main loop: {err}") self.running = False finally: self.logger.info("All loops terminated, performing final cleanup...") try: # Save final state or cleanup hooks if needed if hasattr(self, "save_state"): self.save_state() except Exception as e: self.logger.warning(f"Error during save_state: {e}") # Disconnect MQTT cleanly if self.mqttc and self.mqttc.is_connected(): try: self.mqttc.disconnect() except Exception as e: self.logger.warning(f"Error during MQTT disconnect: {e}") self.logger.info("Main loop complete.")