diff --git a/Dockerfile b/Dockerfile index d0d2acc..1b42ab9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ FROM python:3.8-alpine COPY requirements.txt /app/ -COPY aaisp-to-mqtt.py /app/ +COPY aaisp2mqtt.py /app/ WORKDIR /app RUN pip install -r requirements.txt -CMD ["python", "/app/aaisp-to-mqtt.py"] +CMD ["python", "/app/aaisp2mqtt.py"] diff --git a/LICENSE b/LICENSE index a00808c..4d4eaa6 100644 --- a/LICENSE +++ b/LICENSE @@ -2,6 +2,7 @@ The MIT License (MIT) Copyright (c) 2016 Nat Morris +Copyright (c) 2020 Andrew Williams Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3094814..ac9a689 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # AAISP to MQTT Service # -A script to publish [Andrews & Arnold / AAISP](http://aa.net.uk) broadband quota and sync rates to [MQTT](http://mqtt.org/). +A script to publish [Andrews & Arnold / AAISP](http://aa.net.uk) broadband quota and sync rates to [MQTT](http://mqtt.org/). It uses version 2 of AAISPs [CHAOS](https://support.aa.net.uk/CHAOS) API. Useful for integrating and displaying AAISP line properties in home automation applications, such as [Home Assistant](https://home-assistant.io/) or [openHAB](http://www.openhab.org/). -It uses version 2 of AAISPs [CHAOS](https://support.aa.net.uk/CHAOS) API. +This is a fork of the original [aaisp2mqtt project](https://github.com/natm/aaisp-to-mqtt) by [natm](http://github.com/natm) with the aim to increase integration with Home Assistant and fixing a few minor issues. -Useful for integrating and displaying AAISP line properties in home automation applications, such as [Home Assistant](https://home-assistant.io/) or [openHAB](http://www.openhab.org/). +## Features ## + +* Home Assistant auto discovery -![Workflow](https://raw.github.com/natm/aaisp-to-mqtt/master/docs/workflow.png) ## Use cases ## @@ -15,19 +16,13 @@ Useful for integrating and displaying AAISP line properties in home automation a * Flashing a light in the office when the downstream sync rate drops * Sending line info to [Crouton](https://github.com/edfungus/Crouton) -Example showing lines in Home Assistant... - -![Screenshot](https://raw.github.com/natm/aaisp-to-mqtt/master/docs/home-assistant-panel.png) - -![Screenshot](https://raw.github.com/natm/aaisp-to-mqtt/master/docs/home-assistant-quota-graph.png) - ## Configuration ## Create a config file, for example in /etc/aaisp-mqtt.conf, minimal viable with no MQTT authentication: ``` [aaisp] -username = aa@1 +username = aa000@x.a password = LongAccountPassword [mqtt] @@ -40,7 +35,7 @@ You can also optionally specify MQTT username and password: ``` [aaisp] -username = aa@1 +username = aa000@x.a password = LongAccountPassword [mqtt] @@ -60,21 +55,21 @@ $ pip install -r requirements.txt Run the service: ``` -$ aaisp-to-mqtt.py /etc/aaisp-mqtt.conf +$ aaisp2mqtt.py /etc/aaisp-mqtt.conf ``` It will display debug output similar to: ``` -INFO [2016-11-16 01:24:07,069] Connecting to AAISP CHAOSv2 endpoint -INFO [2016-11-16 01:24:07,338] Got 3 circuits -INFO [2016-11-16 01:24:07,338] * Lines: 32891, 37835, 37964 -INFO [2016-11-16 01:24:07,338] * Logins: gb12@a.1, el6@a.1, el6@a.2 -INFO [2016-11-16 01:24:07,339] Connecting to MQTT broker mqtt.gorras.hw.esgob.com:1883 -INFO [2016-11-16 01:24:07,345] Connected OK to MQTT -INFO [2016-11-16 01:24:07,346] Published version and index messages -INFO [2016-11-16 01:24:07,350] Published details for 3 circuits -INFO [2016-11-16 01:24:07,350] Disconnecting from MQTT +INFO [2020-05-16 14:49:05,142] Connecting to AAISP CHAOSv2 endpoint as xx000@x.a +INFO [2020-05-16 14:49:06,002] Got 1 circuits +INFO [2020-05-16 14:49:06,003] * Lines: 41429 +INFO [2020-05-16 14:49:06,004] * Logins: xx000@x.0 +INFO [2020-05-16 14:49:06,005] Connecting to MQTT broker 127.0.0.1:1883 +INFO [2020-05-16 14:49:06,016] Connected to MQTT Server 127.0.0.1 +INFO [2020-05-16 14:49:06,023] Published version and index messages +INFO [2020-05-16 14:49:06,031] Published details for 1 circuits +INFO [2020-05-16 14:49:06,033] Disconnecting from MQTT ``` Schedule the script via a crontab to run every hour or 30 minutes. @@ -158,6 +153,8 @@ Or you can pass the configuration values as environment variables: * MQTT_USERNAME * MQTT_PASSWORD * MQTT_TOPIC_PREFIX +* HOMEASSISTANT_ENABLED +* HOMEASSISTANT_DISCOVERY_PREFIX ## Setup ## @@ -174,4 +171,4 @@ MIT * Make your changes * Open a pull request back from your branch to master in this repo -Found a bug? open an [issue](https://github.com/natm/aaisp-to-mqtt/issues). +Found a bug? open an [issue](https://github.com/nikdoof/aaisp2mqtt/issues). diff --git a/aaisp-to-mqtt.py b/aaisp-to-mqtt.py deleted file mode 100755 index ac0478b..0000000 --- a/aaisp-to-mqtt.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python - -import os -import sys -import logging -import json -import time -import configparser -import paho.mqtt.client as mqtt -import humanfriendly -import requests -import argparse - -LOG = logging.getLogger(__name__) -VERSION = '0.2.3' - -AAISP_INFO_URL = 'https://chaos2.aa.net.uk/broadband/info' - - -def main(): - logging.basicConfig(level=logging.INFO, - format='%(levelname)8s [%(asctime)s] %(message)s') - - if len(sys.argv) > 1: - cfgfile = sys.argv[1] - # load the config - config = configparser.ConfigParser() - config.read(cfgfile) - - # check it has the correct sections - for section in ['aaisp', 'mqtt']: - if section not in config.sections(): - LOG.fatal('%s section not found in config file %s', - section, cfgfile) - - aaisp_username = config.get('aaisp', 'username') - aaisp_password = config.get('aaisp', 'password') - mqtt_broker = config.get('mqtt', 'broker') - mqtt_port = int(config.get('mqtt', 'port', fallback='1883')) - mqtt_username = config.get('mqtt', 'username', fallback=None) - mqtt_password = config.get('mqtt', 'password', fallback=None) - mqtt_topic_prefix = config.get('mqtt', 'topic_prefix', fallback='aaisp') - else: - # Use the environment - aaisp_username = os.environ.get('AAISP_USERNAME') - aaisp_password = os.environ.get('AAISP_PASSWORD') - mqtt_broker = os.environ.get('MQTT_BROKER') or 'localhost' - mqtt_port = int(os.environ.get('MQTT_PORT') or '1883') - mqtt_username = os.environ.get('MQTT_USERNAME') - mqtt_password = os.environ.get('MQTT_PASSWORD') - mqtt_topic_prefix = os.environ.get('MQTT_TOPIC_PREFIX') or 'aaisp' - - if aaisp_username is None or aaisp_password is None: - LOG.fatal('Username or Password missing for AAISP') - return 1 - - # attempt to get details from aaisp - LOG.info('Connecting to AAISP CHAOSv2 endpoint as %s/%s', aaisp_username, '*' * len(aaisp_password)) - response = requests.get(AAISP_INFO_URL, params={ - 'control_login': aaisp_username.encode('ascii'), - 'control_password': aaisp_password.encode('ascii') - }) - if not response.status_code == requests.codes.ok: - LOG.error('Error connecting to AAISP CHAOSv2 endpoint: %s' % response.body) - return 1 - - data = response.json() - - # Check for response errors - if 'info' not in data: - if 'error' in data: - LOG.fatal('Error encounted: %s' % data['error']) - else: - LOG.fatal('info section not found in AAISP CHAOSv2 response') - return 1 - - circuits = data['info'] - LOG.info('Got %s circuits', len(circuits)) - if len(circuits) == 0: - LOG.fatal('No circuits returned from AAISP CHAOSv2') - - # work out unique line IDs and logins - logins = set(c['login'] for c in circuits) - lines = set(c['ID'] for c in circuits) - LOG.info('* Lines: %s', ', '.join(lines)) - LOG.info('* Logins: %s', ', '.join(logins)) - - # connect to the broker - LOG.info('Connecting to MQTT broker %s:%s', mqtt_broker, mqtt_port) - client = mqtt.Client() - client.max_inflight_messages_set(100) - - # do auth? - if mqtt_username is not None and mqtt_password is not None: - client.username_pw_set(mqtt_username, mqtt_password) - - try: - client.connect(mqtt_broker, mqtt_port, 60) - except Exception: - LOG.exception('Error connecting to MQTT') - return 1 - else: - LOG.info('Connected to MQTT Server %s', mqtt_broker) - - # version and indexes - publish(client=client, topic='%s/$version' % - (mqtt_topic_prefix), payload=VERSION) - publish(client=client, topic='%s/$lines' % - (mqtt_topic_prefix), payload=','.join(lines)) - publish(client=client, topic='%s/$logins' % - (mqtt_topic_prefix), payload=','.join(logins)) - LOG.info('Published version and index messages') - - # publish per circuit - for circuit in circuits: - publish_per_circuit(client=client, circuit=circuit, - mqtt_topic_prefix=mqtt_topic_prefix) - LOG.info('Published details for %s circuits', len(circuits)) - - # disconnect - LOG.info('Disconnecting from MQTT') - client.disconnect() - - -def publish_per_circuit(client, circuit, mqtt_topic_prefix): - quota_remaining = int(circuit['quota_remaining']) - quota_remaining_gb = quota_remaining / 1000000000 - quota_monthly = int(circuit['quota_monthly']) - quota_monthly_gb = quota_monthly / 1000000000 - up = float(circuit['rx_rate']) - up_mb = round(up / 1000000, 2) - down = float(circuit['tx_rate']) - down_mb = round(down / 1000000, 2) - - # line_prefix = '%s/line/%s' % (mqtt_topic_prefix, circuit['ID']) - login_prefix = '%s/login/%s' % (mqtt_topic_prefix, circuit['login']) - for prefix in [login_prefix]: # , line_prefix]: - for metric in [ - ('quota/remaining', quota_remaining), - ('quota/remaining/gb', quota_remaining_gb), - ('quota/remaining/human', humanfriendly.format_size(quota_remaining)), - ('quota/monthly', quota_monthly), - ('quota/monthly/gb', quota_monthly_gb), - ('quota/monthly/human', humanfriendly.format_size(quota_monthly)), - ('syncrate/up', up), - ('syncrate/up/mb', up_mb), - ('syncrate/up/human', humanfriendly.format_size(up)), - ('syncrate/down', down), - ('syncrate/down/mb', down_mb), - ('syncrate/down/human', humanfriendly.format_size(down)), - ('postcode', str(circuit['postcode'].strip())) - ]: - topic = '%s/%s' % (prefix, metric[0]) - publish(client=client, topic=topic, payload=metric[1]) - return - - -def publish(client, topic, payload): - result = client.publish(topic=topic, payload=payload, qos=1) - if result[0] != 0: - LOG.fail('MQTT publish failure: %s %s', topic, payload) - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/aaisp.cfg-example b/aaisp.cfg-example new file mode 100644 index 0000000..3438034 --- /dev/null +++ b/aaisp.cfg-example @@ -0,0 +1,12 @@ +[aaisp] +username = xx000@x +password = PasswordPassword + +[mqtt] +broker = localhost +port = 1883 +topic_prefix = aaisp + +[homeassistant] +enabled = false +discovery_prefix = homeassistant \ No newline at end of file diff --git a/aaisp2mqtt.py b/aaisp2mqtt.py new file mode 100644 index 0000000..79d87a1 --- /dev/null +++ b/aaisp2mqtt.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python + +import os +import sys +import logging +import json +from datetime import datetime +import configparser + +import paho.mqtt.client as mqtt +import humanfriendly +import requests + + + +LOG = logging.getLogger(__name__) +VERSION = '0.3.0' + +AAISP_INFO_URL = 'https://chaos2.aa.net.uk/broadband/info' + + +def b_to_gb(value): + """Bytes to Gibibytes""" + return round(int(value) / (1000**3), 3) + + +def bps_to_mbps(value): + """Bits per second to Mbit/sec""" + return round(int(value) / (1000**2), 3) + +def to_human(value): + """Human readable value""" + return humanfriendly.format_size(int(value)) + + +# Name, Topic, Key, Formatter, HA Unit Type +VALUES_MAP = [ + ('quota_remaining', 'quota/remaining', 'quota_remaining', int, 'B', 'mdi:gauge'), + ('quota_remaining_gb', 'quota/remaining/gb', 'quota_remaining', b_to_gb, 'GB', 'mdi:gauge'), + ('quota_remaining_human', 'quota/remaining/human', 'quota_remaining', to_human, '', 'mdi:gauge'), + ('quota_monthly', 'quota/monthly', 'quota_monthly', int, 'B', 'mdi:gauge'), + ('quota_monthly_gb', 'quota/monthly/gb', 'quota_monthly', b_to_gb, 'GB', 'mdi:gauge'), + ('quota_monthly_human', 'quota/monthly/human', 'quota_monthly', to_human, '', 'mdi:gauge'), + ('syncrate_up', 'syncrate/up', 'rx_rate', float, 'bit/s', 'mdi:speedometer'), + ('syncrate_up_mbps', 'syncrate/up/mb', 'rx_rate', bps_to_mbps, 'Mbit/s', 'mdi:speedometer'), + ('syncrate_up_human', 'syncrate/up/human', 'rx_rate', to_human, '', 'mdi:speedometer'), + ('syncrate_down', 'syncrate/down', 'tx_rate', float, 'bit/s', 'mdi:speedometer'), + ('syncrate_down_mbps', 'syncrate/down/mb', 'tx_rate', bps_to_mbps, 'Mbit/s', 'mdi:speedometer'), + ('syncrate_down_human', 'syncrate/down/human', 'tx_rate', to_human, '', 'mdi:speedometer'), + ('postcode', 'postcode', 'postcode', str, '', 'mdi:tag-text'), +] + + +def main(): + logging.basicConfig(level=logging.INFO, + format='%(levelname)8s [%(asctime)s] %(message)s') + + if len(sys.argv) > 1: + filename = os.path.abspath(os.path.expandvars(sys.argv[1])) + # load the config + if os.path.exists(filename): + config = configparser.ConfigParser() + config.read(filename) + else: + LOG.fatal('Configuration file %s does not exist', filename) + + # check it has the correct sections + diff = set(['aaisp', 'mqtt']) - set(config.sections()) + if len(diff) > 0: + LOG.fatal('Sections are missing from the configuration file: %s', ','.join(diff)) + + aaisp_username = config.get('aaisp', 'username') + aaisp_password = config.get('aaisp', 'password') + mqtt_broker = config.get('mqtt', 'broker') + mqtt_port = int(config.get('mqtt', 'port', fallback='1883')) + mqtt_username = config.get('mqtt', 'username', fallback=None) + mqtt_password = config.get('mqtt', 'password', fallback=None) + mqtt_topic_prefix = config.get('mqtt', 'topic_prefix', fallback='aaisp') + homeassistant_enabled = config.get('homeassistant', 'enabled', fallback='false') == 'true' + homeassistant_discovery_prefix = config.get('homeassistant', 'discovery_prefix', fallback='homeassistant') + else: + # Use the environment + aaisp_username = os.environ.get('AAISP_USERNAME') + aaisp_password = os.environ.get('AAISP_PASSWORD') + mqtt_broker = os.environ.get('MQTT_BROKER') or 'localhost' + mqtt_port = int(os.environ.get('MQTT_PORT') or '1883') + mqtt_username = os.environ.get('MQTT_USERNAME') + mqtt_password = os.environ.get('MQTT_PASSWORD') + mqtt_topic_prefix = os.environ.get('MQTT_TOPIC_PREFIX') or 'aaisp' + homeassistant_enabled = (os.environ.get('HOMEASSISTANT_ENABLED') or 'false') == 'true' + homeassistant_discovery_prefix = os.environ.get('HOMEASSISTANT_DISCOVERY_PREFIX') or 'homeassistant' + + if aaisp_username is None or aaisp_password is None: + LOG.fatal('Username or Password missing for AAISP') + return 1 + + # attempt to get details from aaisp + LOG.info('Connecting to AAISP CHAOSv2 endpoint as %s', aaisp_username) + response = requests.get(AAISP_INFO_URL, params={ + 'control_login': aaisp_username, + 'control_password': aaisp_password + }) + if not response.status_code == requests.codes.ok: + LOG.error('Error connecting to AAISP CHAOSv2 endpoint: %s' % response.body) + return 1 + + data = response.json() + + # Check for response errors + if 'info' not in data: + if 'error' in data: + LOG.fatal('Error encounted: %s' % data['error']) + else: + LOG.fatal('info section not found in AAISP CHAOSv2 response') + return 1 + + circuits = data['info'] + LOG.info('Got %s circuits', len(circuits)) + if len(circuits) == 0: + LOG.fatal('No circuits returned from AAISP CHAOSv2') + + # work out unique line IDs and logins + logins = set(c['login'] for c in circuits) + lines = set(c['ID'] for c in circuits) + LOG.info('* Lines: %s', ', '.join(lines)) + LOG.info('* Logins: %s', ', '.join(logins)) + + # connect to the broker + LOG.info('Connecting to MQTT broker %s:%s', mqtt_broker, mqtt_port) + client = mqtt.Client() + client.max_inflight_messages_set(100) + + # do auth? + if mqtt_username is not None and mqtt_password is not None: + client.username_pw_set(mqtt_username, mqtt_password) + + try: + client.connect(mqtt_broker, mqtt_port, 60) + except Exception: + LOG.exception('Error connecting to MQTT') + return 1 + else: + LOG.info('Connected to MQTT Server %s', mqtt_broker) + + # version and indexes + publish(client=client, topic='%s/$version' % (mqtt_topic_prefix), payload=VERSION) + publish(client=client, topic='%s/$lines' % (mqtt_topic_prefix), payload=','.join(lines)) + publish(client=client, topic='%s/$logins' % (mqtt_topic_prefix), payload=','.join(logins)) + publish(client=client, topic='%s/last_update' % (mqtt_topic_prefix), payload=datetime.now().timestamp()) + LOG.info('Published version and index messages') + + # publish per circuit + for circuit in circuits: + # If homeassistant is enabled, publish the sensor configs + if homeassistant_enabled: + LOG.debug('Publishing Homeassistant configuration.') + publish_circuit_config(client, circuit, mqtt_topic_prefix, homeassistant_discovery_prefix) + + # Publish the states + publish_circuit_state(client, circuit, mqtt_topic_prefix) + LOG.info('Published details for %s circuits', len(circuits)) + + # disconnect + LOG.info('Disconnecting from MQTT') + client.disconnect() + + +def publish_circuit_state(client, circuit, mqtt_topic_prefix): + prefix = '%s/login/%s' % (mqtt_topic_prefix, circuit['login']) + for _, topic, key, formatter, _, _ in VALUES_MAP: + topic = '%s/%s' % (prefix, topic) + publish(client=client, topic=topic, payload=formatter(circuit[key])) + + +def publish_circuit_config(client, circuit, mqtt_topic_prefix, mqtt_discovery_prefix): + for name, topic, _, _, unit, icon in VALUES_MAP: + login = circuit['login'].replace('@', '_').replace('.', '_') + config_topic = '%s/sensor/%s/%s/config' % (mqtt_discovery_prefix, login, name) + unique_id = '%s_%s' % (login, name) + + data = { + 'name': '%s %s' % (circuit['login'], name), + 'icon': icon, + 'state_topic': '%s/login/%s/%s' % (mqtt_topic_prefix, circuit['login'], topic), + 'unique_id': unique_id, + 'unit_of_measurement': unit, + 'device': { + 'identifiers': circuit['login'], + 'name': 'AAISP Circuit %s (%s)' % (circuit['login'], circuit['postcode']), + 'sw_version': VERSION, + } + } + publish(client, config_topic, payload=json.dumps(data), retain=True) + + +def publish(client, topic, payload, retain=False): + result = client.publish(topic=topic, payload=payload, qos=0, retain=retain) + if result[0] != 0: + LOG.fail('MQTT publish failure: %s %s', topic, payload) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/docs/home-assistant-panel.png b/docs/home-assistant-panel.png deleted file mode 100644 index 31c38ea..0000000 Binary files a/docs/home-assistant-panel.png and /dev/null differ diff --git a/docs/home-assistant-quota-graph.png b/docs/home-assistant-quota-graph.png deleted file mode 100644 index 80760d5..0000000 Binary files a/docs/home-assistant-quota-graph.png and /dev/null differ diff --git a/docs/workflow.png b/docs/workflow.png deleted file mode 100644 index fe0854c..0000000 Binary files a/docs/workflow.png and /dev/null differ diff --git a/requirements.txt b/requirements.txt index 6c7ff1e..b223278 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ paho-mqtt>=1.2 -configparser>=3.5.0 humanfriendly>=2.1 requests>=2.23.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..056b8fd --- /dev/null +++ b/setup.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +from setuptools import setup, find_packages + +with open("README.md", "r") as fh: + long_description = fh.read() + +setup( + name='aaisp2mqtt', + version='0.3.0', + description='A script to publish Andrews & Arnold / AAISP broadband quota and sync rates to MQTT', + long_description=long_description, + long_description_content_type='text/markdown', + license='MIT', + author='Nat Morris, Andrew Williams', + author_email='nat@nuqe.net, andy@tensixtyone.com', + url='https://github.com/nikdoof/aaisp2mqtt', + packages=find_packages(), + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + ], + python_requires='>=3.7', + entry_points={ + 'console_scripts': [ + 'aaisp2mqtt = aaisp2mqtt:main', + ], + }, + install_requires=[ + 'paho-mqtt>=1.2', + 'humanfriendly>=2.1', + 'requests>=2.23.0', + ] +) \ No newline at end of file