├── Pipfile ├── Dockerfile ├── README.md ├── app.py └── Pipfile.lock /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | autopep8 = "*" 8 | 9 | [packages] 10 | prometheus-client = "*" 11 | requests = "*" 12 | Flask = "*" 13 | 14 | [requires] 15 | python_version = "3.9" 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-alpine 2 | RUN pip install pipenv 3 | 4 | RUN addgroup -S exporter && adduser -h /home/exporter -S exporter -G exporter 5 | 6 | WORKDIR /app 7 | ADD . . 8 | 9 | RUN pipenv install --system 10 | 11 | EXPOSE 5000 12 | USER exporter 13 | CMD python3 app.py 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prometheus exporter for Amazon Smart Air Quality Monitor 2 | 3 | This exporter uses private Amazon Alexa API to collect air quality measurements 4 | reported by Amazon Smart Air Quality Monitor 5 | 6 | ## Running 7 | 8 | ### Getting Appliance ID 9 | 10 | Visit https://alexa.amazon.co.uk/api/phoenix/group and identify the appliance 11 | ID of your air monitor. It probably begins with "AAA_SonarCloudService_". 12 | 13 | ### Getting the Amazon session cookies 14 | 15 | Since there is no public API available for collecting this data, we use a set 16 | of browser session cookies to authenticate with Alexa API. Please treat these 17 | cookies as secrets, since they give access to your Amazon account. 18 | 19 | Visit https://alexa.amazon.co.uk/api/phoenix/group with developer tools enabled 20 | in your browser and record the value of the following cookies: 21 | 22 | - at-acbuk 23 | - ubid-acbuk 24 | 25 | ### Installing dependencies 26 | 27 | ```bash 28 | pip install pipenv 29 | pipenv install 30 | ``` 31 | 32 | ### Running the exporter 33 | 34 | ```bash 35 | AT_ACBUK='at-acbuk-cookie-value' UBID_ACBUK='ubid-acbuk-cookie-value' pipenv run python app.py 36 | ``` 37 | 38 | ### Testing 39 | 40 | Before configuring the exporter as a Prometheus target, verify that metrics get 41 | collected successfully by sending a request with your monitor's appliance ID 42 | as an `id` URL parameter: 43 | 44 | ```bash 45 | curl '127.0.0.1:5000/air_monitor?id=AAA_SonarCloudService_...' 46 | ``` 47 | 48 | ### Configuring Prometheus 49 | 50 | After you get the exporter running, you can add it as a Prometheus target. 51 | For example: 52 | 53 | ```yaml 54 | - job_name: alexa_exporter 55 | metrics_path: /air_monitor 56 | static_configs: 57 | - targets: 58 | - living_room,AAA_SonarCloudService_... 59 | relabel_configs: 60 | - source_labels: [__address__] 61 | regex: (.*),.* 62 | target_label: instance 63 | replacement: ${1} 64 | - source_labels: [__address__] 65 | regex: .*,(.*) 66 | target_label: __param_id 67 | replacement: ${1} 68 | - source_labels: [] 69 | regex: .* 70 | target_label: __address__ 71 | replacement: 127.0.0.1:5000 72 | ``` 73 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, abort 2 | from prometheus_client import start_http_server, Summary, CollectorRegistry, generate_latest 3 | from prometheus_client.core import GaugeMetricFamily, CounterMetricFamily, REGISTRY 4 | import json 5 | import os 6 | import requests 7 | 8 | app = Flask(__name__) 9 | 10 | STATE_URL = 'https://alexa.amazon.co.uk/api/phoenix/state' 11 | 12 | HEADERS = { 13 | # Pretend we are an Alexa iOS app. 14 | "User-Agent": "AppleWebKit PitanguiBridge/2.2.454039.0-[HARDWARE=iPhone8_1][SOFTWARE=14.4][DEVICE=iPhone]", 15 | } 16 | 17 | # Metric name prefix. 18 | PREFIX = 'amazon_air_monitor' 19 | 20 | 21 | def cookies(): 22 | at_acbuk = os.getenv('AT_ACBUK') 23 | ubid_acbuk = os.getenv('UBID_ACBUK') 24 | if not at_acbuk or not ubid_acbuk: 25 | raise RuntimeError( 26 | "please set AT_ACBUK and UBID_ACBUK environment variables") 27 | return {'at-acbuk': at_acbuk, 'ubid-acbuk': ubid_acbuk} 28 | 29 | 30 | def get_air_monitor_state(id): 31 | resp = requests.post(STATE_URL, headers=HEADERS, cookies=cookies(), json={ 32 | "stateRequests": [{"entityId": id, "entityType": "APPLIANCE"}] 33 | }) 34 | if resp.status_code != 200: 35 | raise RuntimeError("unexpected response from amazon, code %d: %s" % ( 36 | resp.status_code, resp.text)) 37 | j = resp.json() 38 | 39 | if 'errors' in j and j['errors']: 40 | raise RuntimeError("got an error from amazon: %s" % j['errors']) 41 | if 'error' in j['deviceStates'][0] and j['deviceStates'][0]['error']: 42 | raise RuntimeError("got error in deviceStates: %s" % resp.text) 43 | if 'capabilityStates' not in j['deviceStates'][0]: 44 | raise RuntimeError("expected capabilityStates, got %s" % resp.text) 45 | 46 | capabilities = {} 47 | for cap in j['deviceStates'][0]['capabilityStates']: 48 | capj = json.loads(cap) 49 | capabilities[capj['instance']] = capj['value'] 50 | return capabilities 51 | 52 | 53 | class AirMonitorCollector(object): 54 | def __init__(self, id): 55 | self.id = id 56 | 57 | def collect(self): 58 | caps = get_air_monitor_state(self.id) 59 | if '3' in caps: 60 | units = caps['3']['scale'].lower() 61 | m = GaugeMetricFamily( 62 | "%s_temperature_%s" % (PREFIX, units), "Temperature") 63 | m.add_metric([], caps['3']['value']) 64 | yield m 65 | if '4' in caps: 66 | m = GaugeMetricFamily("%s_humidity_percent" % PREFIX, "Humidity") 67 | m.add_metric([], caps['4']) 68 | yield m 69 | if '5' in caps: 70 | m = GaugeMetricFamily( 71 | "%s_voc_score" % PREFIX, "Volatile Organic Compound score") 72 | m.add_metric([], caps['5']) 73 | yield m 74 | if '6' in caps: 75 | m = GaugeMetricFamily( 76 | "%s_particulate_matter_ug_m3" % PREFIX, 77 | "Particulate Matter in micrograms per cubic meter") 78 | m.add_metric([], caps['6']) 79 | yield m 80 | if '8' in caps: 81 | m = GaugeMetricFamily( 82 | "%s_carbon_monoxide_ppm" % PREFIX, 83 | "Carbon Monoxide parts per million") 84 | m.add_metric([], caps['8']) 85 | yield m 86 | if '9' in caps: 87 | m = GaugeMetricFamily( 88 | "%s_quality_score" % PREFIX, "Air quality score") 89 | m.add_metric([], caps['9']) 90 | yield m 91 | 92 | 93 | @app.route("/air_monitor") 94 | def air_monitor(): 95 | if "id" not in request.args: 96 | return "Expected `id` parameter with device id", 400 97 | try: 98 | registry = CollectorRegistry() 99 | registry.register(AirMonitorCollector(request.args['id'])) 100 | return generate_latest(registry) 101 | except RuntimeError as e: 102 | app.logger.error(str(e)) 103 | return str(e), 500 104 | 105 | 106 | if __name__ == "__main__": 107 | # check that environment variables are set. 108 | cookies() 109 | app.run(host="0.0.0.0") 110 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "39175a728a6be999d9669c03a90ecfa1f5d5913ac37cd2646b05f16cf28f952a" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 22 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 23 | ], 24 | "version": "==2021.10.8" 25 | }, 26 | "charset-normalizer": { 27 | "hashes": [ 28 | "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd", 29 | "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455" 30 | ], 31 | "markers": "python_version >= '3'", 32 | "version": "==2.0.10" 33 | }, 34 | "click": { 35 | "hashes": [ 36 | "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", 37 | "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b" 38 | ], 39 | "markers": "python_version >= '3.6'", 40 | "version": "==8.0.3" 41 | }, 42 | "flask": { 43 | "hashes": [ 44 | "sha256:7b2fb8e934ddd50731893bdcdb00fc8c0315916f9fcd50d22c7cc1a95ab634e2", 45 | "sha256:cb90f62f1d8e4dc4621f52106613488b5ba826b2e1e10a33eac92f723093ab6a" 46 | ], 47 | "index": "pypi", 48 | "version": "==2.0.2" 49 | }, 50 | "idna": { 51 | "hashes": [ 52 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 53 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 54 | ], 55 | "markers": "python_version >= '3'", 56 | "version": "==3.3" 57 | }, 58 | "itsdangerous": { 59 | "hashes": [ 60 | "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c", 61 | "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0" 62 | ], 63 | "markers": "python_version >= '3.6'", 64 | "version": "==2.0.1" 65 | }, 66 | "jinja2": { 67 | "hashes": [ 68 | "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", 69 | "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7" 70 | ], 71 | "markers": "python_version >= '3.6'", 72 | "version": "==3.0.3" 73 | }, 74 | "markupsafe": { 75 | "hashes": [ 76 | "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", 77 | "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", 78 | "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", 79 | "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", 80 | "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", 81 | "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", 82 | "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", 83 | "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", 84 | "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", 85 | "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", 86 | "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", 87 | "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", 88 | "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", 89 | "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", 90 | "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", 91 | "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", 92 | "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", 93 | "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", 94 | "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", 95 | "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", 96 | "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", 97 | "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", 98 | "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", 99 | "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", 100 | "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", 101 | "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", 102 | "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", 103 | "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", 104 | "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", 105 | "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", 106 | "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", 107 | "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", 108 | "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", 109 | "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", 110 | "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", 111 | "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", 112 | "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", 113 | "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", 114 | "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", 115 | "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", 116 | "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", 117 | "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", 118 | "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", 119 | "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", 120 | "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", 121 | "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", 122 | "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", 123 | "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", 124 | "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", 125 | "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", 126 | "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", 127 | "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", 128 | "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", 129 | "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", 130 | "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", 131 | "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", 132 | "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", 133 | "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", 134 | "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", 135 | "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", 136 | "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", 137 | "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", 138 | "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", 139 | "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", 140 | "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", 141 | "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", 142 | "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", 143 | "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", 144 | "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" 145 | ], 146 | "markers": "python_version >= '3.6'", 147 | "version": "==2.0.1" 148 | }, 149 | "prometheus-client": { 150 | "hashes": [ 151 | "sha256:1b12ba48cee33b9b0b9de64a1047cbd3c5f2d0ab6ebcead7ddda613a750ec3c5", 152 | "sha256:317453ebabff0a1b02df7f708efbab21e3489e7072b61cb6957230dd004a0af0" 153 | ], 154 | "index": "pypi", 155 | "version": "==0.12.0" 156 | }, 157 | "requests": { 158 | "hashes": [ 159 | "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 160 | "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" 161 | ], 162 | "index": "pypi", 163 | "version": "==2.27.1" 164 | }, 165 | "urllib3": { 166 | "hashes": [ 167 | "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", 168 | "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" 169 | ], 170 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 171 | "version": "==1.26.8" 172 | }, 173 | "werkzeug": { 174 | "hashes": [ 175 | "sha256:63d3dc1cf60e7b7e35e97fa9861f7397283b75d765afcaefd993d6046899de8f", 176 | "sha256:aa2bb6fc8dee8d6c504c0ac1e7f5f7dc5810a9903e793b6f715a9f015bdadb9a" 177 | ], 178 | "markers": "python_version >= '3.6'", 179 | "version": "==2.0.2" 180 | } 181 | }, 182 | "develop": { 183 | "autopep8": { 184 | "hashes": [ 185 | "sha256:44f0932855039d2c15c4510d6df665e4730f2b8582704fa48f9c55bd3e17d979", 186 | "sha256:ed77137193bbac52d029a52c59bec1b0629b5a186c495f1eb21b126ac466083f" 187 | ], 188 | "index": "pypi", 189 | "version": "==1.6.0" 190 | }, 191 | "pycodestyle": { 192 | "hashes": [ 193 | "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20", 194 | "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f" 195 | ], 196 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 197 | "version": "==2.8.0" 198 | }, 199 | "toml": { 200 | "hashes": [ 201 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 202 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 203 | ], 204 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 205 | "version": "==0.10.2" 206 | } 207 | } 208 | } 209 | --------------------------------------------------------------------------------