diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..024d0d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +ARG BUILD_FROM=ghcr.io/hassio-addons/base:15.0.9 +# hadolint ignore=DL3006 +FROM ${BUILD_FROM} + +# Set shell +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Copy Python requirements file +COPY requirements.txt /tmp/ + +# Setup base +# hadolint ignore=DL3003,DL3042 +RUN \ + apk add --no-cache --virtual .build-dependencies \ + build-base=0.5-r3 \ + python3-dev=3.11.10-r0 \ + \ + && apk add --no-cache \ + py3-pip=23.3.1-r0 \ + py3-wheel=0.42.0-r0 \ + python3=3.11.10-r0 \ + \ + && pip install -r /tmp/requirements.txt \ + \ + && cd /usr/lib/python3.11/site-packages/ \ + \ + && find /usr \ + \( -type d -a -name test -o -name tests -o -name '__pycache__' \) \ + -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) \ + -exec rm -rf '{}' + \ + \ + && apk del --no-cache --purge .build-dependencies + +# Copy root filesystem +COPY rootfs / +# Copy addon code and entrypoint +COPY ad-mqtt /root/ +COPY run-addon.py /root/run-addon.py + +# Build arguments +ARG BUILD_ARCH +ARG BUILD_DATE +ARG BUILD_DESCRIPTION +ARG BUILD_NAME +ARG BUILD_REF +ARG BUILD_REPOSITORY +ARG BUILD_VERSION + +# Labels +LABEL \ + io.hass.name="${BUILD_NAME}" \ + io.hass.description="${BUILD_DESCRIPTION}" \ + io.hass.arch="${BUILD_ARCH}" \ + io.hass.type="addon" \ + io.hass.version=${BUILD_VERSION} \ + org.opencontainers.image.title="${BUILD_NAME}" \ + org.opencontainers.image.description="${BUILD_DESCRIPTION}" \ + org.opencontainers.image.source="https://github.com/${BUILD_REPOSITORY}" \ + org.opencontainers.image.created=${BUILD_DATE} \ + org.opencontainers.image.revision=${BUILD_REF} \ + org.opencontainers.image.version=${BUILD_VERSION} diff --git a/README.md b/README.md index fc580b1..1a62681 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,8 @@ Designed to work with Home Assistant. Uses MQTT discovery to create sensors for all zones and the alarm panel automatically. Times are passed in the MQTT messages and retained so they can be used to create real "last changed" time sensors if desired in HASS. + +If you use virtual zones, you need to enable the corresponding zone expander +emulation in the Alarm Decoder setup. Then, you can include `is_virtual=True` +in a Zone configuration, and it will get a switch that can be used to trigger +the emulated zone fault. diff --git a/ad_mqtt/Bridge.py b/ad_mqtt/Bridge.py index b04dae3..8ca4894 100644 --- a/ad_mqtt/Bridge.py +++ b/ad_mqtt/Bridge.py @@ -1,6 +1,7 @@ import datetime as DT import json import logging +import functools LOG = logging.getLogger(__name__) @@ -37,6 +38,8 @@ def __init__(self, mqtt, decoder, code, zones, rf_devices): self.sensor_state_topic = "alarm/sensor/{entity}/state" self.sensor_battery_topic = "alarm/sensor/{entity}/battery" + self.virtual_zone_state_topic = "alarm/sensor/{entity}/virtual/state" + self.virtual_zone_set_topic = "alarm/sensor/{entity}/virtual/set" mqtt.signal_connected.connect(self.mqtt_connected) @@ -52,6 +55,30 @@ def mqtt_connected(self, device, connected): self.mqtt.subscribe(self.panel_set_topic, qos, self.cb_panel_set) self.mqtt.subscribe(self.chime_set_topic, qos, self.cb_chime_set) self.mqtt.subscribe(self.bypass_set_topic, qos, self.cb_bypass_set) + for z in self.zones.values(): + if z.is_virtual: + fault_entity = z.entity + '_fault' + fault_unique_id = z.unique_id + "_fault" + set_topic = self.virtual_zone_set_topic.format( + unique_id=fault_entity, entity=fault_unique_id) + self.mqtt.subscribe(set_topic, qos, functools.partial(self.cb_virtual_fault_set, zone=z)) + + def cb_virtual_fault_set(self, client, user_data, message, zone): + topic = message.topic + msg = message.payload.decode("utf-8").strip() + is_faulted = (msg.lower() == "on") + LOG.info("Read virtual fault set '%s': '%s', will mark zone %s as %s", topic, msg, zone.zone, is_faulted) + + payload = {"status" : "ON" if is_faulted else "OFF"} + fault_entity = zone.entity + '_fault' + fault_unique_id = zone.unique_id + "_fault" + state_topic = self.virtual_zone_state_topic.format( + unique_id=fault_entity, entity=fault_unique_id) + self.publish(state_topic, {}, payload) + if is_faulted: + self.ad.fault_zone(zone.zone) + else: + self.ad.clear_zone(zone.zone) def cb_panel_set(self, client, user_data, message): msg = message.payload.decode("utf-8") diff --git a/ad_mqtt/Devices.py b/ad_mqtt/Devices.py index 5f9d484..63d1a85 100644 --- a/ad_mqtt/Devices.py +++ b/ad_mqtt/Devices.py @@ -15,13 +15,14 @@ def init_devices(devices): class Zone: - def __init__(self, zone, entity, label, device_class=None): + def __init__(self, zone, entity, label, device_class=None, is_virtual=False): self.zone = zone # int zone number self.entity = entity self.label = label self.faulted = None # None=Unknown, True=faulted, False=clear self.has_battery = False self.unique_id = None + self.is_virtual = is_virtual self.device_class = device_class if device_class else \ guess_class((entity, label)) @@ -47,6 +48,7 @@ def __init__(self, id, zone, entity, label): "fire" : "smoke", "door" : "door", "window" : "window", + "heat": "heat", } diff --git a/ad_mqtt/Discovery.py b/ad_mqtt/Discovery.py index 8265eed..1da6b37 100644 --- a/ad_mqtt/Discovery.py +++ b/ad_mqtt/Discovery.py @@ -181,6 +181,29 @@ def __init__(self, mqtt, bridge, zones): } self.messages.append((topic, payload)) + if z.is_virtual: + fault_entity = z.entity + '_fault' + fault_unique_id = z.unique_id + "_fault" + topic = f'homeassistant/switch/{fault_unique_id}/config' + state_topic = bridge.virtual_zone_state_topic.format( + unique_id=fault_entity, entity=fault_unique_id) + set_topic = bridge.virtual_zone_set_topic.format( + unique_id=fault_entity, entity=fault_unique_id) + payload = { + 'name' : z.label + ' Fault', + 'object_id' : fault_entity, + 'unique_id' : fault_unique_id, + 'device' : Discovery.device, + 'state_topic' : state_topic, + 'value_template' : '{{value_json.status}}', + 'json_attributes_topic' : state_topic, + 'json_attributes_template' : attr_templ, + 'command_topic' : set_topic, + 'qos' : 1, + 'retain' : True, + } + self.messages.append((topic, payload)) + def mqtt_connected(self, device, connected): if not self.messages: return diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..0524091 --- /dev/null +++ b/config.yaml @@ -0,0 +1,26 @@ +--- +name: ad-mqtt +version: dev +slug: ad-mqtt +description: MQTT Bridge for AlarmDecoder +url: https://github.com/dgrnbrg/ad-mqtt +arch: + - aarch64 + - amd64 + - armv7 +init: false +startup: application +services: + - mqtt:need +options: + system_packages: [] + python_packages: [] + init_commands: [] +schema: + log_level: list(trace|debug|info|notice|warning|error|fatal)? + system_packages: + - str + python_packages: + - str + init_commands: + - str diff --git a/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/dependencies.d/init-ad-mqtt b/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/dependencies.d/init-ad-mqtt new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/finish b/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/finish new file mode 100755 index 0000000..ddcd090 --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/finish @@ -0,0 +1,27 @@ +#!/command/with-contenv bashio +# shellcheck shell=bash +# ============================================================================== +# Home Assistant Community Add-on: ad-mqtt +# Take down the S6 supervision tree when ad-mqtt fails +# ============================================================================== +declare exit_code +readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode + fi + [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt +elif [[ "${exit_code_service}" -ne 0 ]]; then + if [[ "${exit_code_container}" -eq 0 ]]; then + echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode + fi + exec /run/s6/basedir/bin/halt +fi diff --git a/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/run b/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/run new file mode 100755 index 0000000..4f1b594 --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/run @@ -0,0 +1,39 @@ +#!/command/with-contenv bashio +# shellcheck shell=bash +# ============================================================================== +# Home Assistant Community Add-on: ad-mqtt +# Runs the ad-mqtt +# ============================================================================== +declare log_level + +bashio::log.info "Starting ad-mqtt..." + +# Find the matching Tor log level +log_level="INFO" +if bashio::config.has_value 'log_level'; then + case "$(bashio::string.lower "$(bashio::config 'log_level')")" in + all|trace|debug) + log_level="DEBUG" + ;; + info|notice) + log_level="INFO" + ;; + warning) + log_level="WARNING" + ;; + error) + log_level="ERROR" + ;; + fatal|off) + log_level="FATAL" + ;; + esac +fi + +export MQTT_HOST=$(bashio::services mqtt "host") +export MQTT_PORT=$(bashio::services mqtt "port") +export MQTT_USER=$(bashio::services mqtt "username") +export MQTT_PASSWORD=$(bashio::services mqtt "password") + +# Run the ad-mqtt +exec python /root/run.py diff --git a/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/type b/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/type new file mode 100644 index 0000000..5883cff --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/ad-mqtt/type @@ -0,0 +1 @@ +longrun diff --git a/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/dependencies.d/base b/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/dependencies.d/base new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/run b/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/run new file mode 100755 index 0000000..52aa0cb --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/run @@ -0,0 +1,21 @@ +#!/command/with-contenv bashio +# shellcheck shell=bash +# ============================================================================== +# Home Assistant Community Add-on: ad-mqtt +# Configures ad-mqtt +# ============================================================================== + +# Migrate add-on data from the Home Assistant config folder, +# to the add-on configuration folder. +if ! bashio::fs.file_exists '/config/appdaemon.yaml' \ + && bashio::fs.file_exists '/homeassistant/appdaemon/appdaemon.yaml'; then + shopt -s dotglob + mv /homeassistant/appdaemon/* /config/ \ + || bashio::exit.nok "Failed to migrate AppDaemon configuration" +fi + +# Creates initial AppDaemon configuration in case it is non-existing +if ! bashio::fs.file_exists '/config/appdaemon.yaml'; then + cp -R /root/appdaemon/* /config/ \ + || bashio::exit.nok 'Failed to create initial AppDaemon configuration' +fi diff --git a/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/type b/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/type new file mode 100644 index 0000000..bdd22a1 --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/type @@ -0,0 +1 @@ +oneshot diff --git a/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/up b/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/up new file mode 100644 index 0000000..039762e --- /dev/null +++ b/rootfs/etc/s6-overlay/s6-rc.d/init-ad-mqtt/up @@ -0,0 +1 @@ +/etc/s6-overlay/s6-rc.d/init-ad-mqtt/run diff --git a/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/ad-mqtt b/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/ad-mqtt new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-ad-mqtt b/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-ad-mqtt new file mode 100644 index 0000000..e69de29 diff --git a/rootfs/root/appdaemon/appdaemon.yaml b/rootfs/root/appdaemon/appdaemon.yaml new file mode 100644 index 0000000..f0ad6a7 --- /dev/null +++ b/rootfs/root/appdaemon/appdaemon.yaml @@ -0,0 +1,14 @@ +--- +appdaemon: + latitude: 52.379189 + longitude: 4.899431 + elevation: 2 + time_zone: Europe/Amsterdam + plugins: + HASS: + type: hass +http: + url: http://127.0.0.1:5050 +admin: +api: +hadashboard: diff --git a/rootfs/root/appdaemon/apps/apps.yaml b/rootfs/root/appdaemon/apps/apps.yaml new file mode 100644 index 0000000..bcd0b39 --- /dev/null +++ b/rootfs/root/appdaemon/apps/apps.yaml @@ -0,0 +1,4 @@ +--- +hello_world: + module: hello + class: HelloWorld diff --git a/rootfs/root/appdaemon/apps/hello.py b/rootfs/root/appdaemon/apps/hello.py new file mode 100644 index 0000000..6e8f1a3 --- /dev/null +++ b/rootfs/root/appdaemon/apps/hello.py @@ -0,0 +1,13 @@ +import appdaemon.plugins.hass.hassapi as hass + +# +# Hellow World App +# +# Args: +# + +class HelloWorld(hass.Hass): + + def initialize(self): + self.log("Hello from AppDaemon") + self.log("You are now ready to run Apps!") diff --git a/rootfs/root/appdaemon/dashboards/Hello.dash b/rootfs/root/appdaemon/dashboards/Hello.dash new file mode 100644 index 0000000..cead237 --- /dev/null +++ b/rootfs/root/appdaemon/dashboards/Hello.dash @@ -0,0 +1,14 @@ +# +# Main arguments, all optional +# +title: Hello Panel +widget_dimensions: [120, 120] +widget_margins: [5, 5] +columns: 8 + +label: + widget_type: label + text: Hello World + +layout: + - label(2x2) diff --git a/run-addon.py b/run-addon.py new file mode 100644 index 0000000..7f9f66a --- /dev/null +++ b/run-addon.py @@ -0,0 +1,48 @@ +import logging +import sys +import os +import json +sys.path.insert( 0, "." ) +import ad_mqtt as AD + +with open('/data/options.json') as f: + options = json.load(f) + +cfg = AD.Config() + +# Alarm Decoder ser2sock server location. +cfg.alarm.host = options['alarm']['host'] +cfg.alarm.port = options['alarm'].get('port', 10000) +# To reset all zones to closed (not faulted) on startup, set this to True +cfg.alarm.restore_on_startup = options['alarm'].get('restore_on_startup', True) + +# MQTT Broker connection +cfg.mqtt.broker = os.env['MQTT_HOST'] +cfg.mqtt.port = os.env['MQTT_PORT'] +cfg.mqtt.username = os.env['MQTT_USERNAME'] +cfg.mqtt.password = os.env['MQTT_PASSWORD'] +# Optional encryption settings for the broker. +cfg.mqtt.encryption.ca_cert = None +cfg.mqtt.encryption.certfile = None +cfg.mqtt.encryption.keyfile = None + +# Debugging information +cfg.log.level = options['log_level'] +cfg.log.screen = True +cfg.log.modules = ["ad_mqtt", "insteon_mqtt"] + +# For possible device class values, see: +# https://www.home-assistant.io/integrations/binary_sensor/#device-class +alarm_code = options['alarm']['code'] +devices = [] +for d in options['devices']: + devices.append(AD.Zone( + zone = d['zone'], + entity = d['entity'], + label = d['label'], + device_class = d.get('device_class'), + is_virtual = d.get('is_virtual', False), + )) + +AD.run.run(cfg, alarm_code, devices) + diff --git a/run.py b/run.py index dedd436..e65f560 100755 --- a/run.py +++ b/run.py @@ -39,6 +39,7 @@ # Zone( zone_number, HAS_entity_name, description, device_class ) AD.Zone(1, "fire", "Fire Alarm", "smoke"), AD.Zone(2, "basement_door", "Basement Door"), + AD.Zone(9, "virtual_zone", "Make sure you enabled the corresponding expander emulation in the alarm decoder", is_virtual=True), # RF zone ( serial_number, zone_number, HAS_entity_name, description ) AD.RfZone( 12345, 25, "front_door", "Front Door"), AD.RfZone( 12345, 27, "dining_room_door", "Dining Room Door"),