airkeep 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- airkeep-0.1.0/LICENSE +21 -0
- airkeep-0.1.0/PKG-INFO +104 -0
- airkeep-0.1.0/README.md +90 -0
- airkeep-0.1.0/pyproject.toml +26 -0
- airkeep-0.1.0/src/airkeep/__init__.py +1 -0
- airkeep-0.1.0/src/airkeep/__main__.py +79 -0
- airkeep-0.1.0/src/airkeep/bambu-ca.pem +109 -0
- airkeep-0.1.0/src/airkeep/cloud.py +138 -0
- airkeep-0.1.0/src/airkeep/collector.py +77 -0
- airkeep-0.1.0/src/airkeep/setup.py +227 -0
- airkeep-0.1.0/src/airkeep/spool.py +102 -0
- airkeep-0.1.0/src/airkeep/telemetry.py +65 -0
airkeep-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Airkeep contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
airkeep-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: airkeep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send Bambu AMS readings to your Airkeep dashboard
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: paho-mqtt>=2.1,<3
|
|
8
|
+
Requires-Dist: pydantic>=2.12,<3
|
|
9
|
+
Requires-Dist: httpx>=0.28,<1
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Project-URL: Homepage, https://airkeep.io
|
|
12
|
+
Project-URL: Repository, https://github.com/danohn/airkeep
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Airkeep LAN agent
|
|
16
|
+
|
|
17
|
+
Run one agent on an always-on computer on the same LAN as your Bambu P2S. It subscribes to the printer over verified TLS and sends AMS humidity and temperature to your hosted Airkeep dashboard over outbound HTTPS.
|
|
18
|
+
|
|
19
|
+
The printer IP, serial and LAN access code stay in the agent's private local SQLite database. The dashboard receives an opaque agent enrollment identity, printer connectivity status, AMS IDs and readings. Setup tests the printer certificate chain, its exact serial and MQTT login before starting dashboard sign-in.
|
|
20
|
+
|
|
21
|
+
## Run with uv
|
|
22
|
+
|
|
23
|
+
With [uv installed](https://docs.astral.sh/uv/getting-started/installation/), run:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uvx airkeep
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
To install the command for regular use:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
uv tool install airkeep
|
|
33
|
+
airkeep
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
On first run, open the local setup link printed by Airkeep, normally `http://127.0.0.1:8788/`. The wizard opens on the printer form.
|
|
37
|
+
|
|
38
|
+
Enter the printer's private LAN IP, serial and eight-character LAN access code. The wizard tests the connection, then begins dashboard sign-in. The default hosted origin is `https://dashboard.airkeep.io`. You can enter your own HTTPS dashboard origin. Follow the displayed verification link, sign in and approve the displayed code. Keep the setup page open until it confirms completion. The setup listener closes automatically, and collection begins.
|
|
39
|
+
|
|
40
|
+
Airkeep stores its SQLite database in `~/.local/state/airkeep` by default, or in `$XDG_STATE_HOME/airkeep` if set. Running from a checkout with an existing `.local/agent/agent.sqlite3` reuses that directory. To reuse it from another working directory, pass its absolute directory path with `--data-dir`. `AIRKEEP_AGENT_DATA` also sets the data directory. The directory has mode `0700` and newly created files have mode `0600`. Only one process can use each data directory at a time.
|
|
41
|
+
|
|
42
|
+
For development, run `uv run --locked --project agent airkeep` from the repository root.
|
|
43
|
+
|
|
44
|
+
## Docker on Linux
|
|
45
|
+
|
|
46
|
+
The dedicated image is `ghcr.io/danohn/airkeep-agent`, built for `linux/amd64` and `linux/arm64`. Linux host networking lets the agent reach the LAN printer and keeps its setup listener on the host loopback interface.
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
docker run -d --name airkeep-agent \
|
|
50
|
+
--network host --restart unless-stopped \
|
|
51
|
+
--mount type=volume,source=airkeep-agent,target=/data \
|
|
52
|
+
ghcr.io/danohn/airkeep-agent:latest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Open the local setup page to configure the printer.
|
|
56
|
+
|
|
57
|
+
For a headless Linux host, forward its loopback port over SSH and open `http://127.0.0.1:8788` on your computer.
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
ssh -L 8788:127.0.0.1:8788 user@agent-host
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The host needs outbound TCP 8883 to the printer and outbound TCP 443 to the dashboard. IPv6 ULA and link-local printer addresses are supported alongside IPv4 private and link-local addresses. The setup port defaults to 8788 and can be changed with `--setup-port`. The wizard validates the exact loopback Host and Origin, so use the same local port when forwarding it.
|
|
64
|
+
|
|
65
|
+
Build a local image from the repository root with the following command.
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
docker build -f agent/Dockerfile -t airkeep-agent:local .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `LAN agent image` workflow tests and builds pull requests, then publishes the dedicated multi-platform image on main pushes and version tags. For a pinned release, replace `latest` with the published version or image digest.
|
|
72
|
+
|
|
73
|
+
## Buffering and reconnecting
|
|
74
|
+
|
|
75
|
+
The agent persists at most one reading per AMS every 30 seconds, retaining unsent readings for 24 hours. It uploads up to 100 readings per request. Fresh observations from the last 30 seconds are sent first, newest first, followed by replay history in original observation order. Each persisted event has a stable UUID. Successful HTTP 204 responses remove acknowledged events. Failed requests keep them for retry with exponential backoff and jitter. HTTP 429 responses honor a numeric Retry-After delay up to one hour.
|
|
76
|
+
|
|
77
|
+
Readings retained across restarts, failed deliveries or beyond the 30-second live window carry `replay=true` and their original timestamps. A new observation remains eligible for live delivery while older history is buffered. MQTT reconnects automatically after interrupted LAN connections. Cloud status updates include printer connectivity on transitions and at least every 30 seconds while the dashboard is reachable. A revoked enrollment stops uploads and leaves a generic `enrollment_required` status in the process output. The process continues collecting within the same 24-hour retention window.
|
|
78
|
+
|
|
79
|
+
If the dashboard permanently rejects an ingest request with HTTP 400 or 422, the agent reduces the batch size until it isolates the rejected reading. It then moves only that reading into a private local quarantine and continues delivering valid readings with their original event IDs. The quarantine retains at most 100 readings for 24 hours. Process output reports `invalid_reading_quarantined` and then `connected_with_quarantined_readings`, without including event contents or upstream error details. Check the host clock and agent version when this status appears. Successful replacement enrollment clears the quarantine along with previous buffered readings.
|
|
80
|
+
|
|
81
|
+
To replace credentials or enroll again, stop the running agent and run the same command with `--setup`. In Docker, stop the service and run an interactive replacement using the same volume.
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
docker stop airkeep-agent
|
|
85
|
+
docker run --rm -it --network host \
|
|
86
|
+
--mount type=volume,source=airkeep-agent,target=/data \
|
|
87
|
+
ghcr.io/danohn/airkeep-agent:latest airkeep --setup
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This process continues running after setup. Stop it with Ctrl-C, then restart the original container with `docker start airkeep-agent`. Successful replacement enrollment atomically clears previous buffered readings and creates a new installation identity, preventing delivery of a previous account's history into the replacement account. An interrupted replacement keeps the saved enrollment intact.
|
|
91
|
+
|
|
92
|
+
## Checks
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
uv run --locked --directory agent pytest -q
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The tests cover queue persistence, 24-hour pruning, sample throttling, event ordering and IDs, retries, replay, heartbeat transitions, revoked enrollment, device-flow pacing, local setup and TLS serial validation. Testing against a physical P2S requires that printer's LAN settings and network access.
|
|
99
|
+
|
|
100
|
+
## Publish to PyPI
|
|
101
|
+
|
|
102
|
+
Configure the [PyPI Trusted Publisher](https://pypi.org/manage/account/publishing/) for project `airkeep`, owner `danohn`, repository `airkeep`, workflow `publish-agent.yml`, and environment `pypi`. Use a pending publisher before the first release. Configure the GitHub `pypi` environment to accept release tags matching `agent-v*`.
|
|
103
|
+
|
|
104
|
+
Update the version in `agent/pyproject.toml`, then regenerate the agent and server lockfiles. Push a matching tag such as `agent-v0.1.0` to publish the wheel and source distribution. The workflow tests the agent and checks the tag against the package version before publishing. [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
|
airkeep-0.1.0/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Airkeep LAN agent
|
|
2
|
+
|
|
3
|
+
Run one agent on an always-on computer on the same LAN as your Bambu P2S. It subscribes to the printer over verified TLS and sends AMS humidity and temperature to your hosted Airkeep dashboard over outbound HTTPS.
|
|
4
|
+
|
|
5
|
+
The printer IP, serial and LAN access code stay in the agent's private local SQLite database. The dashboard receives an opaque agent enrollment identity, printer connectivity status, AMS IDs and readings. Setup tests the printer certificate chain, its exact serial and MQTT login before starting dashboard sign-in.
|
|
6
|
+
|
|
7
|
+
## Run with uv
|
|
8
|
+
|
|
9
|
+
With [uv installed](https://docs.astral.sh/uv/getting-started/installation/), run:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
uvx airkeep
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
To install the command for regular use:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
uv tool install airkeep
|
|
19
|
+
airkeep
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
On first run, open the local setup link printed by Airkeep, normally `http://127.0.0.1:8788/`. The wizard opens on the printer form.
|
|
23
|
+
|
|
24
|
+
Enter the printer's private LAN IP, serial and eight-character LAN access code. The wizard tests the connection, then begins dashboard sign-in. The default hosted origin is `https://dashboard.airkeep.io`. You can enter your own HTTPS dashboard origin. Follow the displayed verification link, sign in and approve the displayed code. Keep the setup page open until it confirms completion. The setup listener closes automatically, and collection begins.
|
|
25
|
+
|
|
26
|
+
Airkeep stores its SQLite database in `~/.local/state/airkeep` by default, or in `$XDG_STATE_HOME/airkeep` if set. Running from a checkout with an existing `.local/agent/agent.sqlite3` reuses that directory. To reuse it from another working directory, pass its absolute directory path with `--data-dir`. `AIRKEEP_AGENT_DATA` also sets the data directory. The directory has mode `0700` and newly created files have mode `0600`. Only one process can use each data directory at a time.
|
|
27
|
+
|
|
28
|
+
For development, run `uv run --locked --project agent airkeep` from the repository root.
|
|
29
|
+
|
|
30
|
+
## Docker on Linux
|
|
31
|
+
|
|
32
|
+
The dedicated image is `ghcr.io/danohn/airkeep-agent`, built for `linux/amd64` and `linux/arm64`. Linux host networking lets the agent reach the LAN printer and keeps its setup listener on the host loopback interface.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
docker run -d --name airkeep-agent \
|
|
36
|
+
--network host --restart unless-stopped \
|
|
37
|
+
--mount type=volume,source=airkeep-agent,target=/data \
|
|
38
|
+
ghcr.io/danohn/airkeep-agent:latest
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Open the local setup page to configure the printer.
|
|
42
|
+
|
|
43
|
+
For a headless Linux host, forward its loopback port over SSH and open `http://127.0.0.1:8788` on your computer.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
ssh -L 8788:127.0.0.1:8788 user@agent-host
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The host needs outbound TCP 8883 to the printer and outbound TCP 443 to the dashboard. IPv6 ULA and link-local printer addresses are supported alongside IPv4 private and link-local addresses. The setup port defaults to 8788 and can be changed with `--setup-port`. The wizard validates the exact loopback Host and Origin, so use the same local port when forwarding it.
|
|
50
|
+
|
|
51
|
+
Build a local image from the repository root with the following command.
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
docker build -f agent/Dockerfile -t airkeep-agent:local .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The `LAN agent image` workflow tests and builds pull requests, then publishes the dedicated multi-platform image on main pushes and version tags. For a pinned release, replace `latest` with the published version or image digest.
|
|
58
|
+
|
|
59
|
+
## Buffering and reconnecting
|
|
60
|
+
|
|
61
|
+
The agent persists at most one reading per AMS every 30 seconds, retaining unsent readings for 24 hours. It uploads up to 100 readings per request. Fresh observations from the last 30 seconds are sent first, newest first, followed by replay history in original observation order. Each persisted event has a stable UUID. Successful HTTP 204 responses remove acknowledged events. Failed requests keep them for retry with exponential backoff and jitter. HTTP 429 responses honor a numeric Retry-After delay up to one hour.
|
|
62
|
+
|
|
63
|
+
Readings retained across restarts, failed deliveries or beyond the 30-second live window carry `replay=true` and their original timestamps. A new observation remains eligible for live delivery while older history is buffered. MQTT reconnects automatically after interrupted LAN connections. Cloud status updates include printer connectivity on transitions and at least every 30 seconds while the dashboard is reachable. A revoked enrollment stops uploads and leaves a generic `enrollment_required` status in the process output. The process continues collecting within the same 24-hour retention window.
|
|
64
|
+
|
|
65
|
+
If the dashboard permanently rejects an ingest request with HTTP 400 or 422, the agent reduces the batch size until it isolates the rejected reading. It then moves only that reading into a private local quarantine and continues delivering valid readings with their original event IDs. The quarantine retains at most 100 readings for 24 hours. Process output reports `invalid_reading_quarantined` and then `connected_with_quarantined_readings`, without including event contents or upstream error details. Check the host clock and agent version when this status appears. Successful replacement enrollment clears the quarantine along with previous buffered readings.
|
|
66
|
+
|
|
67
|
+
To replace credentials or enroll again, stop the running agent and run the same command with `--setup`. In Docker, stop the service and run an interactive replacement using the same volume.
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
docker stop airkeep-agent
|
|
71
|
+
docker run --rm -it --network host \
|
|
72
|
+
--mount type=volume,source=airkeep-agent,target=/data \
|
|
73
|
+
ghcr.io/danohn/airkeep-agent:latest airkeep --setup
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This process continues running after setup. Stop it with Ctrl-C, then restart the original container with `docker start airkeep-agent`. Successful replacement enrollment atomically clears previous buffered readings and creates a new installation identity, preventing delivery of a previous account's history into the replacement account. An interrupted replacement keeps the saved enrollment intact.
|
|
77
|
+
|
|
78
|
+
## Checks
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
uv run --locked --directory agent pytest -q
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The tests cover queue persistence, 24-hour pruning, sample throttling, event ordering and IDs, retries, replay, heartbeat transitions, revoked enrollment, device-flow pacing, local setup and TLS serial validation. Testing against a physical P2S requires that printer's LAN settings and network access.
|
|
85
|
+
|
|
86
|
+
## Publish to PyPI
|
|
87
|
+
|
|
88
|
+
Configure the [PyPI Trusted Publisher](https://pypi.org/manage/account/publishing/) for project `airkeep`, owner `danohn`, repository `airkeep`, workflow `publish-agent.yml`, and environment `pypi`. Use a pending publisher before the first release. Configure the GitHub `pypi` environment to accept release tags matching `agent-v*`.
|
|
89
|
+
|
|
90
|
+
Update the version in `agent/pyproject.toml`, then regenerate the agent and server lockfiles. Push a matching tag such as `agent-v0.1.0` to publish the wheel and source distribution. The workflow tests the agent and checks the tag against the package version before publishing. [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "airkeep"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Send Bambu AMS readings to your Airkeep dashboard"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = ["paho-mqtt>=2.1,<3", "pydantic>=2.12,<3", "httpx>=0.28,<1"]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
airkeep = "airkeep.__main__:main"
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://airkeep.io"
|
|
16
|
+
Repository = "https://github.com/danohn/airkeep"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.11.15,<0.12"]
|
|
20
|
+
build-backend = "uv_build"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = ["pytest>=8.4"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Airkeep LAN agent."""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Airkeep LAN agent command line."""
|
|
2
|
+
import argparse
|
|
3
|
+
import fcntl
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import signal
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
from .collector import Collector
|
|
11
|
+
from .cloud import Cloud, Uploader
|
|
12
|
+
from .setup import PrinterSettings, run_setup
|
|
13
|
+
from .spool import Spool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_data_dir():
|
|
17
|
+
configured = os.environ.get('AIRKEEP_AGENT_DATA')
|
|
18
|
+
if configured:
|
|
19
|
+
return Path(configured)
|
|
20
|
+
legacy = Path('.local/agent')
|
|
21
|
+
if (legacy / 'agent.sqlite3').is_file():
|
|
22
|
+
return legacy
|
|
23
|
+
state_dir = Path(os.environ.get('XDG_STATE_HOME') or Path.home() / '.local' / 'state')
|
|
24
|
+
return state_dir / 'airkeep'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
parser = argparse.ArgumentParser(description='Collect Bambu telemetry locally for hosted Airkeep.')
|
|
29
|
+
parser.add_argument('--data-dir', type=Path, default=default_data_dir())
|
|
30
|
+
parser.add_argument('--setup', action='store_true', help='Replace enrollment and printer settings through local setup')
|
|
31
|
+
parser.add_argument('--setup-port', type=int, default=8788)
|
|
32
|
+
args = parser.parse_args()
|
|
33
|
+
if not 1 <= args.setup_port <= 65535:
|
|
34
|
+
parser.error('Choose a setup port between 1 and 65535.')
|
|
35
|
+
os.umask(0o077)
|
|
36
|
+
args.data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
37
|
+
args.data_dir.chmod(0o700)
|
|
38
|
+
# Keep only one collector/uploader for this identity, including during setup.
|
|
39
|
+
with (args.data_dir / 'agent.lock').open('a') as lock:
|
|
40
|
+
try:
|
|
41
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
42
|
+
except BlockingIOError:
|
|
43
|
+
raise SystemExit('An agent is already using this data directory.') from None
|
|
44
|
+
spool = Spool(args.data_dir / 'agent.sqlite3')
|
|
45
|
+
collector = cloud = None
|
|
46
|
+
stopped = threading.Event()
|
|
47
|
+
signal.signal(signal.SIGTERM, lambda *_: stopped.set())
|
|
48
|
+
signal.signal(signal.SIGINT, lambda *_: stopped.set())
|
|
49
|
+
try:
|
|
50
|
+
if args.setup or spool.configuration() is None:
|
|
51
|
+
run_setup(spool, args.setup_port, stopped)
|
|
52
|
+
config = spool.configuration()
|
|
53
|
+
settings = PrinterSettings.validate(config)
|
|
54
|
+
collector = Collector(settings, spool)
|
|
55
|
+
cloud = Cloud(config['origin'])
|
|
56
|
+
uploader = Uploader(cloud, spool, config['token'])
|
|
57
|
+
# Suppress transport logs that can contain IPs, URLs or credentials.
|
|
58
|
+
logging.getLogger('httpx').disabled = True
|
|
59
|
+
logging.getLogger('httpcore').disabled = True
|
|
60
|
+
collector.start()
|
|
61
|
+
status = None
|
|
62
|
+
while not stopped.is_set():
|
|
63
|
+
uploader.step(collector.connected)
|
|
64
|
+
if uploader.status != status:
|
|
65
|
+
status = uploader.status
|
|
66
|
+
print('Airkeep cloud ' + status, flush=True)
|
|
67
|
+
if uploader.revoked:
|
|
68
|
+
print('Stop the agent and run it with --setup to enroll again.', flush=True)
|
|
69
|
+
stopped.wait(1)
|
|
70
|
+
finally:
|
|
71
|
+
if collector:
|
|
72
|
+
collector.stop()
|
|
73
|
+
if cloud:
|
|
74
|
+
cloud.close()
|
|
75
|
+
spool.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == '__main__':
|
|
79
|
+
main()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIFfzCCA2egAwIBAgIUXtzR6tRiL/RHBRXOoyFU0+XrliowDQYJKoZIhvcNAQEL
|
|
3
|
+
BQAwRjELMAkGA1UEBhMCQ04xITAfBgNVBAoMGEJCTCBUZWNobm9sb2dpZXMgQ28u
|
|
4
|
+
IEx0ZDEUMBIGA1UEAwwLQkJMIENBMiBSU0EwIBcNMjUwNjE3MDEzODA4WhgPMjA1
|
|
5
|
+
MDA2MTcwMTM4MDhaMEYxCzAJBgNVBAYTAkNOMSEwHwYDVQQKDBhCQkwgVGVjaG5v
|
|
6
|
+
bG9naWVzIENvLiBMdGQxFDASBgNVBAMMC0JCTCBDQTIgUlNBMIICIjANBgkqhkiG
|
|
7
|
+
9w0BAQEFAAOCAg8AMIICCgKCAgEAo4550G4c42gTKzQqixwKT089RizIdZpyOcGA
|
|
8
|
+
679rPaOdWsMqVwnYPP2FpMqXKkjFbedE+SpGloi2NKCuiPNVRbq9PHOOZwTs7YLo
|
|
9
|
+
bOwf53FJuO6vRFpzFfX1tlc9zlFqJvZnYO9NgHpMysidocWcgrDN/SIDywgPB5CV
|
|
10
|
+
bYg3Vvzua9fwZx9e5KT9xd5IpTqdTrWS47jQOVKLhdQCbJFIlMrblOwLBAx+fHok
|
|
11
|
+
wqh6tkI6Ktuyyjw8Dysebi1ndWjKtZ2mW47r8xZ/J+z3EZqcyJMY6MRtx/zb1jBF
|
|
12
|
+
uHtkjrb5Kv1DMzSKlkaNJIbvC+Mk+hI97W+SjLSRuIdC7+oJUzWaSzgu9cjXCVfm
|
|
13
|
+
q8t4IL/35hP69PK95LgLectIrP96CYAT/aVMG19FrFW0QWEyfT+kzG4jkumfPbHq
|
|
14
|
+
Y2nNkEN0+tjj3h4WdzrWgQEojK/lhfcRFVkts74+aZoMpQP+vmL17CKmSzXk5o/e
|
|
15
|
+
K21xgxJdzMbdztfTpibiXk0abfOpN+1VR+3NYa+bROAKNyGaReEGsyW2bjcjNx51
|
|
16
|
+
5Vqzj3SVxhMSp5vfF9E4A1jE99M/l9jQDM6RzkT0lMccGAd5tUSdNvDlrqtQaQiK
|
|
17
|
+
v/ZsXPgXLTWfOpvaLNEgwdMgZMuhjpkwvAZyoYfeF9kyydjDh7bvrX//cz/VopAU
|
|
18
|
+
lxUtQtMCAwEAAaNjMGEwHQYDVR0OBBYEFNVJgQad1sNTN0jxVkwbJ/XM1an1MB8G
|
|
19
|
+
A1UdIwQYMBaAFNVJgQad1sNTN0jxVkwbJ/XM1an1MA8GA1UdEwEB/wQFMAMBAf8w
|
|
20
|
+
DgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQBFZDKMJfp/N4gBeFHh
|
|
21
|
+
MiFehaUyMS6e9mzrTfMLJLJoj6Jopa9V9jIfcCEBGZuRThqFcATV+UdFHSINpUcH
|
|
22
|
+
upcCYnazTRC4dn1hnxnQ1ojQcHxdGp9xGw/YclAKD97d8bPShfBMT1to9zbMK7T5
|
|
23
|
+
L8zgqg01YIOKjQk0Hcd0+0iUr6m8zQ5P8Rl3QXqAyeWgqmYQrrjTWwPsgdfHNXKX
|
|
24
|
+
vDrx7/cqry5lKU802hUplKMBxelv4W8407Ytj1lfJOwvxqxxsFU5jSwcUG3zo2vk
|
|
25
|
+
QtjRs8m5BKup5K1OPYkkPu7Ld89X0XpU073/dNDG11uxb1eDKrtNP6vZuZjNE2Pq
|
|
26
|
+
8HCoI1EtP+ItyqtUMvHi6Z2zsmlA25broVioeUKxjlIecpQ9JR/FhDu9CWNF/nDW
|
|
27
|
+
LSORNaMMzgsMSzI+HCiUhqN+qMIvVP6rzGTJzwqz/lc5Lf+ZPCnGA9WJTT4uPIhf
|
|
28
|
+
ufbZmnUJ35WuWKHxovDsqBh88zQ9sZ+ei4Hi4vVzOhUgfG3aLoSQEYqRoqaboANh
|
|
29
|
+
wCwzyuW2Rv54u5QSBbd6Gx1OpvsWmLPWd2/iL2kISl5wfmLGVydvSJa+rbOfuAy7
|
|
30
|
+
ycVQacVDQCAnbhoVrQy7+454QsKSW3ZV6BcyRrorewCyCYgd7nyxflxHZTBEykXX
|
|
31
|
+
haGNe/KFNvJBMOIuIUzknRRmiQ==
|
|
32
|
+
-----END CERTIFICATE-----
|
|
33
|
+
-----BEGIN CERTIFICATE-----
|
|
34
|
+
MIIB8zCCAZmgAwIBAgIUe61jGQ4RzIC8k+sNuqbI/CaNqPIwCgYIKoZIzj0EAwIw
|
|
35
|
+
RjELMAkGA1UEBhMCQ04xITAfBgNVBAoMGEJCTCBUZWNobm9sb2dpZXMgQ28uIEx0
|
|
36
|
+
ZDEUMBIGA1UEAwwLQkJMIENBMiBFQ0MwIBcNMjUwNjE3MDEzODM1WhgPMjA1MDA2
|
|
37
|
+
MTcwMTM4MzVaMEYxCzAJBgNVBAYTAkNOMSEwHwYDVQQKDBhCQkwgVGVjaG5vbG9n
|
|
38
|
+
aWVzIENvLiBMdGQxFDASBgNVBAMMC0JCTCBDQTIgRUNDMFkwEwYHKoZIzj0CAQYI
|
|
39
|
+
KoZIzj0DAQcDQgAEpKTF7wRSty4DXpGJzgCPwRh8ghLlxUC3qJbyEgLqTvJgbiwY
|
|
40
|
+
APPHK7kVbVmerkqhHOT4QeWRlTG3dOQGLA2VpaNjMGEwHQYDVR0OBBYEFKuRpsjY
|
|
41
|
+
REOyIKH7HwOE6jhGBd6NMB8GA1UdIwQYMBaAFKuRpsjYREOyIKH7HwOE6jhGBd6N
|
|
42
|
+
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMCA0gA
|
|
43
|
+
MEUCIErBiUm3VdtP3rz4kb8aLpI5p+BzL7M9vElBGWWJxpHMAiEA3r5tJWVGwuxi
|
|
44
|
+
YCrB1c40KYFRFyahGrhOJZAj/YhRdnU=
|
|
45
|
+
-----END CERTIFICATE-----
|
|
46
|
+
-----BEGIN CERTIFICATE-----
|
|
47
|
+
MIIEeTCCA2GgAwIBAgIUOq+lNIaC2xsswkFqj5JPyVBl45cwDQYJKoZIhvcNAQEL
|
|
48
|
+
BQAwQjELMAkGA1UEBhMCQ04xIjAgBgNVBAoMGUJCTCBUZWNobm9sb2dpZXMgQ28u
|
|
49
|
+
LCBMdGQxDzANBgNVBAMMBkJCTCBDQTAeFw0yNTA2MTcwMjAxMjdaFw0zNTA2MTUw
|
|
50
|
+
MjAxMjdaMEYxCzAJBgNVBAYTAkNOMSEwHwYDVQQKDBhCQkwgVGVjaG5vbG9naWVz
|
|
51
|
+
IENvLiBMdGQxFDASBgNVBAMMC0JCTCBDQTIgUlNBMIICIjANBgkqhkiG9w0BAQEF
|
|
52
|
+
AAOCAg8AMIICCgKCAgEAo4550G4c42gTKzQqixwKT089RizIdZpyOcGA679rPaOd
|
|
53
|
+
WsMqVwnYPP2FpMqXKkjFbedE+SpGloi2NKCuiPNVRbq9PHOOZwTs7YLobOwf53FJ
|
|
54
|
+
uO6vRFpzFfX1tlc9zlFqJvZnYO9NgHpMysidocWcgrDN/SIDywgPB5CVbYg3Vvzu
|
|
55
|
+
a9fwZx9e5KT9xd5IpTqdTrWS47jQOVKLhdQCbJFIlMrblOwLBAx+fHokwqh6tkI6
|
|
56
|
+
Ktuyyjw8Dysebi1ndWjKtZ2mW47r8xZ/J+z3EZqcyJMY6MRtx/zb1jBFuHtkjrb5
|
|
57
|
+
Kv1DMzSKlkaNJIbvC+Mk+hI97W+SjLSRuIdC7+oJUzWaSzgu9cjXCVfmq8t4IL/3
|
|
58
|
+
5hP69PK95LgLectIrP96CYAT/aVMG19FrFW0QWEyfT+kzG4jkumfPbHqY2nNkEN0
|
|
59
|
+
+tjj3h4WdzrWgQEojK/lhfcRFVkts74+aZoMpQP+vmL17CKmSzXk5o/eK21xgxJd
|
|
60
|
+
zMbdztfTpibiXk0abfOpN+1VR+3NYa+bROAKNyGaReEGsyW2bjcjNx515Vqzj3SV
|
|
61
|
+
xhMSp5vfF9E4A1jE99M/l9jQDM6RzkT0lMccGAd5tUSdNvDlrqtQaQiKv/ZsXPgX
|
|
62
|
+
LTWfOpvaLNEgwdMgZMuhjpkwvAZyoYfeF9kyydjDh7bvrX//cz/VopAUlxUtQtMC
|
|
63
|
+
AwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O
|
|
64
|
+
BBYEFNVJgQad1sNTN0jxVkwbJ/XM1an1MB8GA1UdIwQYMBaAFI80QmjcZ06PxCKe
|
|
65
|
+
xXxJ5avdRL4eMA0GCSqGSIb3DQEBCwUAA4IBAQAvS8tyfagaGsFf9YncA2ko/Na5
|
|
66
|
+
9BVF+8TlUo+32oznwIVpS1AhSgLP6rNVekXNFKbuP5htudLQ17ZRBJI/UMVyYEDq
|
|
67
|
+
IN7xv7Zj+zJwF6W6haYrjb2Vk8igw1XvNULZfvVNNKIkvJUiVqEslWrC+k74crk/
|
|
68
|
+
Wv8ChVf+zqvfIN6LV3esaGRL02J3AprQGb7DDhR1EefQMScDkNpGJMUmvCmfknrl
|
|
69
|
+
iK8qgvQN1SWO7JRf6fNKHsN1ZQvyP0pgLWxpT3V0/0/WttqX3cMGuJF+jVUzm/Nh
|
|
70
|
+
xYhFewG8vc3KzTjnwQApMA6CW554FOJWFyOD2jn5yJLT3Vue+aYDQRp4bKMx
|
|
71
|
+
-----END CERTIFICATE-----
|
|
72
|
+
-----BEGIN CERTIFICATE-----
|
|
73
|
+
MIICrjCCAZagAwIBAgIUOq+lNIaC2xsswkFqj5JPyVBl45gwDQYJKoZIhvcNAQEL
|
|
74
|
+
BQAwQjELMAkGA1UEBhMCQ04xIjAgBgNVBAoMGUJCTCBUZWNobm9sb2dpZXMgQ28u
|
|
75
|
+
LCBMdGQxDzANBgNVBAMMBkJCTCBDQTAeFw0yNTA2MTcwMjAxNDdaFw0zNTA2MTUw
|
|
76
|
+
MjAxNDdaMEYxCzAJBgNVBAYTAkNOMSEwHwYDVQQKDBhCQkwgVGVjaG5vbG9naWVz
|
|
77
|
+
IENvLiBMdGQxFDASBgNVBAMMC0JCTCBDQTIgRUNDMFkwEwYHKoZIzj0CAQYIKoZI
|
|
78
|
+
zj0DAQcDQgAEpKTF7wRSty4DXpGJzgCPwRh8ghLlxUC3qJbyEgLqTvJgbiwYAPPH
|
|
79
|
+
K7kVbVmerkqhHOT4QeWRlTG3dOQGLA2VpaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAO
|
|
80
|
+
BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFKuRpsjYREOyIKH7HwOE6jhGBd6NMB8G
|
|
81
|
+
A1UdIwQYMBaAFI80QmjcZ06PxCKexXxJ5avdRL4eMA0GCSqGSIb3DQEBCwUAA4IB
|
|
82
|
+
AQCg6PjUSSZV+4bvejcVMvgXmKzfD95osWn0ctnoMBxPDa+m+Gg+BcLT2IlFAe3E
|
|
83
|
+
KYMvu4T295WQc92rjKYqW6cirFppng9uEFW2mZLimxaSmutsTftE3sbMVMJ/SLYN
|
|
84
|
+
PV7TFv6mcBSIFWXwmBOIpbh4BUcVfONTvdSfIqfyAVxsq4xzc2nc6hPBpAm21Ayj
|
|
85
|
+
ToC1ev/TbDJ8VllFZiEVmWWlIP3aNzAm8S2mOpxPB2WnanaZHSrvXLFhstyzwrjD
|
|
86
|
+
yO1/isOZ7wtr7rcuTJdEvvvCimOZlkfRhaDoTew9tQ0E2FVpzzSinw02qmQ1xIE9
|
|
87
|
+
5/H5ZzJSPkpeAHWEPnKkxg0v
|
|
88
|
+
-----END CERTIFICATE-----
|
|
89
|
+
-----BEGIN CERTIFICATE-----
|
|
90
|
+
MIIDZTCCAk2gAwIBAgIUV1FckwXElyek1onFnQ9kL7Bk4N8wDQYJKoZIhvcNAQEL
|
|
91
|
+
BQAwQjELMAkGA1UEBhMCQ04xIjAgBgNVBAoMGUJCTCBUZWNobm9sb2dpZXMgQ28u
|
|
92
|
+
LCBMdGQxDzANBgNVBAMMBkJCTCBDQTAeFw0yMjA0MDQwMzQyMTFaFw0zMjA0MDEw
|
|
93
|
+
MzQyMTFaMEIxCzAJBgNVBAYTAkNOMSIwIAYDVQQKDBlCQkwgVGVjaG5vbG9naWVz
|
|
94
|
+
IENvLiwgTHRkMQ8wDQYDVQQDDAZCQkwgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IB
|
|
95
|
+
DwAwggEKAoIBAQDL3pnDdxGOk5Z6vugiT4dpM0ju+3Xatxz09UY7mbj4tkIdby4H
|
|
96
|
+
oeEdiYSZjc5LJngJuCHwtEbBJt1BriRdSVrF6M9D2UaBDyamEo0dxwSaVxZiDVWC
|
|
97
|
+
eeCPdELpFZdEhSNTaT4O7zgvcnFsfHMa/0vMAkvE7i0qp3mjEzYLfz60axcDoJLk
|
|
98
|
+
p7n6xKXI+cJbA4IlToFjpSldPmC+ynOo7YAOsXt7AYKY6Glz0BwUVzSJxU+/+VFy
|
|
99
|
+
/QrmYGNwlrQtdREHeRi0SNK32x1+bOndfJP0sojuIrDjKsdCLye5CSZIvqnbowwW
|
|
100
|
+
1jRwZgTBR29Zp2nzCoxJYcU9TSQp/4KZuWNVAgMBAAGjUzBRMB0GA1UdDgQWBBSP
|
|
101
|
+
NEJo3GdOj8QinsV8SeWr3US+HjAfBgNVHSMEGDAWgBSPNEJo3GdOj8QinsV8SeWr
|
|
102
|
+
3US+HjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQABlBIT5ZeG
|
|
103
|
+
fgcK1LOh1CN9sTzxMCLbtTPFF1NGGA13mApu6j1h5YELbSKcUqfXzMnVeAb06Htu
|
|
104
|
+
3CoCoe+wj7LONTFO++vBm2/if6Jt/DUw1CAEcNyqeh6ES0NX8LJRVSe0qdTxPJuA
|
|
105
|
+
BdOoo96iX89rRPoxeed1cpq5hZwbeka3+CJGV76itWp35Up5rmmUqrlyQOr/Wax6
|
|
106
|
+
itosIzG0MfhgUzU51A2P/hSnD3NDMXv+wUY/AvqgIL7u7fbDKnku1GzEKIkfH8hm
|
|
107
|
+
Rs6d8SCU89xyrwzQ0PR853irHas3WrHVqab3P+qNwR0YirL0Qk7Xt/q3O1griNg2
|
|
108
|
+
Blbjg3obpHo9
|
|
109
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""HTTPS-only device enrollment and idempotent telemetry delivery."""
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
CLIENT_ID = 'airkeep-relay'
|
|
9
|
+
DEFAULT_ORIGIN = 'https://dashboard.airkeep.io'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def https_origin(value):
|
|
13
|
+
url = urlsplit(value)
|
|
14
|
+
if (url.scheme != 'https' or not url.hostname or url.username is not None or url.password is not None
|
|
15
|
+
or url.path not in ('', '/') or '?' in value or '#' in value
|
|
16
|
+
or any(character.isspace() for character in value)):
|
|
17
|
+
raise ValueError('Enter an HTTPS dashboard origin without a path or credentials.')
|
|
18
|
+
if url.port is not None and not 1 <= url.port <= 65535:
|
|
19
|
+
raise ValueError('Enter a valid HTTPS port.')
|
|
20
|
+
return value.rstrip('/')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Cloud:
|
|
24
|
+
def __init__(self, origin, client=None):
|
|
25
|
+
self.origin = https_origin(origin)
|
|
26
|
+
self.client = client or httpx.Client(timeout=10, follow_redirects=False, trust_env=False)
|
|
27
|
+
|
|
28
|
+
def post(self, path, **kwargs):
|
|
29
|
+
# Explicitly prohibit redirects even for an injected/test client.
|
|
30
|
+
return self.client.post(self.origin + path, follow_redirects=False, **kwargs)
|
|
31
|
+
|
|
32
|
+
def begin(self, installation_id):
|
|
33
|
+
response = self.post('/auth/device/code', data={'client_id': CLIENT_ID, 'installation_id': installation_id})
|
|
34
|
+
if response.status_code != 200:
|
|
35
|
+
raise ValueError('The dashboard could not begin enrollment. Try again.')
|
|
36
|
+
data = response.json()
|
|
37
|
+
if not isinstance(data, dict):
|
|
38
|
+
raise ValueError('The dashboard returned an invalid enrollment response.')
|
|
39
|
+
if not isinstance(data.get('verification_uri'), str):
|
|
40
|
+
raise ValueError('The dashboard returned an invalid verification address.')
|
|
41
|
+
uri = urlsplit(data['verification_uri'])
|
|
42
|
+
if uri.scheme != 'https' or not uri.hostname or uri.username is not None or uri.password is not None:
|
|
43
|
+
raise ValueError('The dashboard returned an invalid verification address.')
|
|
44
|
+
expires, interval = data.get('expires_in'), data.get('interval', 5)
|
|
45
|
+
if (not isinstance(expires, (int, float)) or not isinstance(interval, (int, float))
|
|
46
|
+
or not 1 <= expires <= 86400 or not 1 <= interval <= 300):
|
|
47
|
+
raise ValueError('The dashboard returned invalid enrollment timing.')
|
|
48
|
+
if not all(isinstance(data.get(key), str) and data[key] for key in ('device_code', 'user_code')):
|
|
49
|
+
raise ValueError('The dashboard returned invalid enrollment codes.')
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
def poll(self, device_code):
|
|
53
|
+
response = self.post('/auth/token', data={'client_id': CLIENT_ID,
|
|
54
|
+
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', 'device_code': device_code})
|
|
55
|
+
if response.status_code == 200:
|
|
56
|
+
data = response.json()
|
|
57
|
+
if not isinstance(data, dict):
|
|
58
|
+
raise ValueError('The dashboard returned an invalid enrollment response.')
|
|
59
|
+
token = data.get('access_token')
|
|
60
|
+
kind = data.get('token_type')
|
|
61
|
+
if not isinstance(kind, str) or kind.lower() != 'bearer' or not isinstance(token, str) or not token:
|
|
62
|
+
raise ValueError('The dashboard returned an invalid enrollment token.')
|
|
63
|
+
return {'access_token': token}
|
|
64
|
+
if response.status_code == 400:
|
|
65
|
+
data = response.json()
|
|
66
|
+
error = data.get('error') if isinstance(data, dict) else None
|
|
67
|
+
if error in ('authorization_pending', 'slow_down', 'access_denied', 'expired_token'):
|
|
68
|
+
return {'error': error}
|
|
69
|
+
raise ValueError('The dashboard could not check enrollment. Try again.')
|
|
70
|
+
|
|
71
|
+
def close(self):
|
|
72
|
+
self.client.close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Uploader:
|
|
76
|
+
def __init__(self, cloud, spool, token):
|
|
77
|
+
self.cloud, self.spool, self.token = cloud, spool, token
|
|
78
|
+
self.next_attempt = 0
|
|
79
|
+
self.heartbeat_at = 0
|
|
80
|
+
self.last_connected = None
|
|
81
|
+
self.failures = 0
|
|
82
|
+
self.batch_limit = 100
|
|
83
|
+
self.revoked = False
|
|
84
|
+
self.status = 'connecting'
|
|
85
|
+
|
|
86
|
+
def step(self, connected, now=None):
|
|
87
|
+
now = time.monotonic() if now is None else now
|
|
88
|
+
if self.revoked or now < self.next_attempt:
|
|
89
|
+
return
|
|
90
|
+
batch = self.spool.batch(limit=self.batch_limit)
|
|
91
|
+
heartbeat = self.last_connected != connected or now >= self.heartbeat_at
|
|
92
|
+
if not batch and not heartbeat:
|
|
93
|
+
return
|
|
94
|
+
body = {'readings': batch}
|
|
95
|
+
if heartbeat:
|
|
96
|
+
body['printerConnected'] = connected
|
|
97
|
+
try:
|
|
98
|
+
response = self.cloud.post('/v1/ingest', headers={'Authorization': 'Bearer ' + self.token}, json=body)
|
|
99
|
+
status = response.status_code
|
|
100
|
+
except httpx.HTTPError:
|
|
101
|
+
status = 0
|
|
102
|
+
if status == 204:
|
|
103
|
+
self.spool.acknowledge(batch)
|
|
104
|
+
self.failures = 0
|
|
105
|
+
self.batch_limit = 100
|
|
106
|
+
self.next_attempt = now
|
|
107
|
+
self.status = 'connected_with_quarantined_readings' if self.spool.quarantined_count() else 'connected'
|
|
108
|
+
if heartbeat:
|
|
109
|
+
self.last_connected = connected
|
|
110
|
+
self.heartbeat_at = now + 30
|
|
111
|
+
return
|
|
112
|
+
if status in (400, 422) and batch:
|
|
113
|
+
# Validation is atomic at the ingest endpoint. Narrow the batch
|
|
114
|
+
# until only the rejected event is quarantined, retaining valid
|
|
115
|
+
# live observations and backlog with their original event IDs.
|
|
116
|
+
if len(batch) > 1:
|
|
117
|
+
self.batch_limit = len(batch) // 2
|
|
118
|
+
self.status = 'isolating_invalid_reading'
|
|
119
|
+
else:
|
|
120
|
+
self.spool.quarantine(batch[0], status)
|
|
121
|
+
self.batch_limit = 100
|
|
122
|
+
self.status = 'invalid_reading_quarantined'
|
|
123
|
+
self.next_attempt = now
|
|
124
|
+
return
|
|
125
|
+
self.spool.mark_replay(batch)
|
|
126
|
+
if status in (401, 403):
|
|
127
|
+
self.revoked = True
|
|
128
|
+
self.status = 'enrollment_required'
|
|
129
|
+
return
|
|
130
|
+
self.status = 'ingest_rejected' if status in (400, 422) else 'retrying'
|
|
131
|
+
self.failures = min(self.failures + 1, 8)
|
|
132
|
+
delay = min(300, 2 ** self.failures) * random.uniform(0.8, 1.2)
|
|
133
|
+
if status == 429:
|
|
134
|
+
try:
|
|
135
|
+
delay = max(delay, min(3600, float(response.headers.get('Retry-After', 0))))
|
|
136
|
+
except ValueError:
|
|
137
|
+
pass
|
|
138
|
+
self.next_attempt = now + delay
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import ssl
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
from paho.mqtt import client as mqtt
|
|
7
|
+
from .telemetry import Reading, parse_readings
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("airkeep.mqtt")
|
|
10
|
+
BAMBU_CA = Path(__file__).with_name('bambu-ca.pem')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReadingSink(Protocol):
|
|
14
|
+
def save_reading(self, reading: Reading): ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Collector:
|
|
18
|
+
def __init__(self, settings, store: ReadingSink):
|
|
19
|
+
self.settings, self.store = settings, store
|
|
20
|
+
self.connected = False
|
|
21
|
+
self.error: str | None = None
|
|
22
|
+
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
|
23
|
+
self.client.username_pw_set("bblp", settings.printer_code)
|
|
24
|
+
context = ssl.create_default_context(cafile=str(settings.ca_path))
|
|
25
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
26
|
+
context.maximum_version = ssl.TLSVersion.TLSv1_2
|
|
27
|
+
context.check_hostname = False # Printer cert CN is its serial, not its IP.
|
|
28
|
+
self.client.tls_set_context(context)
|
|
29
|
+
self.client.on_socket_open = self.verify_serial
|
|
30
|
+
self.client.on_connect = self.on_connect
|
|
31
|
+
self.client.on_disconnect = self.on_disconnect
|
|
32
|
+
self.client.on_connect_fail = self.on_connect_fail
|
|
33
|
+
self.client.on_message = self.on_message
|
|
34
|
+
self.client.reconnect_delay_set(2, 60)
|
|
35
|
+
self.client.connect_timeout = 8
|
|
36
|
+
|
|
37
|
+
def verify_serial(self, client, userdata, sock):
|
|
38
|
+
serials = [value for rdn in sock.getpeercert().get("subject", ())
|
|
39
|
+
for key, value in rdn if key == "commonName"]
|
|
40
|
+
if serials != [self.settings.printer_serial]:
|
|
41
|
+
# Paho invokes this callback before sending MQTT CONNECT (including
|
|
42
|
+
# the access code). Close before raising so a retry cannot reuse it.
|
|
43
|
+
sock.close()
|
|
44
|
+
raise ssl.CertificateError("Printer certificate serial does not match configured printer")
|
|
45
|
+
|
|
46
|
+
def on_connect(self, client, userdata, flags, reason, props):
|
|
47
|
+
self.connected = not reason.is_failure
|
|
48
|
+
self.error = None if self.connected else str(reason)
|
|
49
|
+
if self.connected:
|
|
50
|
+
client.subscribe(f"device/{self.settings.printer_serial}/report")
|
|
51
|
+
|
|
52
|
+
def on_connect_fail(self, client, userdata):
|
|
53
|
+
self.connected = False
|
|
54
|
+
self.error = "Printer connection failed; check LAN settings and certificate"
|
|
55
|
+
|
|
56
|
+
def on_disconnect(self, client, userdata, flags, reason, props):
|
|
57
|
+
self.connected = False
|
|
58
|
+
self.error = "Printer disconnected; reconnecting"
|
|
59
|
+
|
|
60
|
+
def on_message(self, client, userdata, message):
|
|
61
|
+
if len(message.payload) > 1024 * 1024:
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
payload = json.loads(message.payload)
|
|
65
|
+
if isinstance(payload, dict):
|
|
66
|
+
for reading in parse_readings(payload):
|
|
67
|
+
self.store.save_reading(reading)
|
|
68
|
+
except (ValueError, TypeError, RecursionError):
|
|
69
|
+
log.warning("Ignored malformed printer report")
|
|
70
|
+
|
|
71
|
+
def start(self):
|
|
72
|
+
self.client.connect_async(self.settings.printer_ip, 8883, 30)
|
|
73
|
+
self.client.loop_start()
|
|
74
|
+
|
|
75
|
+
def stop(self):
|
|
76
|
+
self.client.disconnect()
|
|
77
|
+
self.client.loop_stop()
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""One-time, loopback-only browser setup. Printer secrets never leave this host."""
|
|
2
|
+
import html
|
|
3
|
+
import ipaddress
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from urllib.parse import parse_qs
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .cloud import Cloud, DEFAULT_ORIGIN, https_origin
|
|
15
|
+
from .collector import BAMBU_CA, Collector
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(repr=False)
|
|
19
|
+
class PrinterSettings:
|
|
20
|
+
printer_ip: str
|
|
21
|
+
printer_serial: str
|
|
22
|
+
printer_code: str = field(repr=False)
|
|
23
|
+
ca_path: Path = BAMBU_CA
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def validate(cls, fields):
|
|
27
|
+
address = ipaddress.ip_address(fields['printer_ip'].strip())
|
|
28
|
+
local = (ipaddress.ip_network('10.0.0.0/8'), ipaddress.ip_network('172.16.0.0/12'),
|
|
29
|
+
ipaddress.ip_network('192.168.0.0/16'), ipaddress.ip_network('169.254.0.0/16'),
|
|
30
|
+
ipaddress.ip_network('fc00::/7'), ipaddress.ip_network('fe80::/10'))
|
|
31
|
+
if not any(address in network for network in local):
|
|
32
|
+
raise ValueError('Enter the printer’s private LAN IP address.')
|
|
33
|
+
serial = fields['printer_serial'].strip()
|
|
34
|
+
code = fields['printer_code'].strip()
|
|
35
|
+
if not re.fullmatch(r'[A-Za-z0-9]{8,32}', serial):
|
|
36
|
+
raise ValueError('Enter the serial shown in printer settings.')
|
|
37
|
+
if not re.fullmatch(r'[A-Za-z0-9]{8}', code):
|
|
38
|
+
raise ValueError('Enter the eight-character LAN access code.')
|
|
39
|
+
return cls(str(address), serial, code)
|
|
40
|
+
|
|
41
|
+
def private_config(self):
|
|
42
|
+
return {'printer_ip': self.printer_ip, 'printer_serial': self.printer_serial, 'printer_code': self.printer_code}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DiscardReadings:
|
|
46
|
+
def save_reading(self, reading):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_printer(settings, timeout=12):
|
|
51
|
+
collector = Collector(settings, DiscardReadings())
|
|
52
|
+
try:
|
|
53
|
+
collector.start()
|
|
54
|
+
deadline = time.monotonic() + timeout
|
|
55
|
+
while time.monotonic() < deadline:
|
|
56
|
+
if collector.connected:
|
|
57
|
+
return True
|
|
58
|
+
time.sleep(0.1)
|
|
59
|
+
return False
|
|
60
|
+
except (OSError, ValueError):
|
|
61
|
+
return False
|
|
62
|
+
finally:
|
|
63
|
+
collector.stop()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Setup:
|
|
67
|
+
def __init__(self, spool, port, printer_test=test_printer, cloud_factory=Cloud):
|
|
68
|
+
self.spool = spool
|
|
69
|
+
self.origin = f'http://127.0.0.1:{port}'
|
|
70
|
+
self.printer_test, self.cloud_factory = printer_test, cloud_factory
|
|
71
|
+
self.printer = None
|
|
72
|
+
self.cloud = None
|
|
73
|
+
self.enrollment = None
|
|
74
|
+
self.message = ''
|
|
75
|
+
self.completed_at = None
|
|
76
|
+
self.installation_id = uuid.uuid4().hex
|
|
77
|
+
self.interval = 5
|
|
78
|
+
self.next_poll = 0
|
|
79
|
+
|
|
80
|
+
def tick(self):
|
|
81
|
+
if not self.enrollment or self.completed_at is not None:
|
|
82
|
+
return
|
|
83
|
+
now = time.monotonic()
|
|
84
|
+
if now >= self.expires_at:
|
|
85
|
+
self.enrollment = None
|
|
86
|
+
self.message = 'The sign-in code expired. Begin sign-in again.'
|
|
87
|
+
return
|
|
88
|
+
if now < self.next_poll:
|
|
89
|
+
return
|
|
90
|
+
self.next_poll = now + self.interval
|
|
91
|
+
try:
|
|
92
|
+
result = self.cloud.poll(self.enrollment['device_code'])
|
|
93
|
+
except (ValueError, KeyError, httpx.HTTPError):
|
|
94
|
+
self.message = 'Waiting for the dashboard connection. Retrying.'
|
|
95
|
+
return
|
|
96
|
+
error = result.get('error')
|
|
97
|
+
if error == 'slow_down':
|
|
98
|
+
self.interval += 5
|
|
99
|
+
self.next_poll = now + self.interval
|
|
100
|
+
elif error in ('access_denied', 'expired_token'):
|
|
101
|
+
self.enrollment = None
|
|
102
|
+
self.message = 'Sign-in ended. Begin sign-in again.'
|
|
103
|
+
elif not error:
|
|
104
|
+
self.spool.configure(dict(self.printer.private_config(), origin=self.cloud.origin,
|
|
105
|
+
installation_id=self.installation_id, token=result['access_token']))
|
|
106
|
+
self.completed_at = time.monotonic()
|
|
107
|
+
|
|
108
|
+
def close(self):
|
|
109
|
+
if self.cloud:
|
|
110
|
+
self.cloud.close()
|
|
111
|
+
|
|
112
|
+
def page(self):
|
|
113
|
+
content = '<h1>Connect Airkeep</h1>'
|
|
114
|
+
if self.completed_at is not None:
|
|
115
|
+
content += '<p>Setup complete. The agent is collecting printer readings and sending them to your dashboard. You can close this window.</p>'
|
|
116
|
+
elif self.enrollment:
|
|
117
|
+
uri = html.escape(self.enrollment['verification_uri'], quote=True)
|
|
118
|
+
code = html.escape(self.enrollment['user_code'])
|
|
119
|
+
content += f'<p>Open <a href="{uri}" target="_blank" rel="noreferrer">your Airkeep dashboard</a>, sign in, and enter this code.</p><p><strong>{code}</strong></p><p>This page checks approval automatically.</p>'
|
|
120
|
+
elif self.printer:
|
|
121
|
+
content += '<p>Printer TLS, serial and LAN access code verified.</p><form method="post" action="/enroll"><button>Begin dashboard sign-in</button></form>'
|
|
122
|
+
else:
|
|
123
|
+
content += f'''<p>Keep your P2S on the same LAN, enable LAN access, and copy its settings here.</p>
|
|
124
|
+
<form method="post" action="/printer">
|
|
125
|
+
<label>Printer LAN IP <input name="printer_ip" required autocomplete="off"></label>
|
|
126
|
+
<label>Printer serial <input name="printer_serial" required autocomplete="off"></label>
|
|
127
|
+
<label>LAN access code <input name="printer_code" type="password" required autocomplete="off"></label>
|
|
128
|
+
<label>Dashboard HTTPS origin <input name="origin" value="{DEFAULT_ORIGIN}" type="url" required></label>
|
|
129
|
+
<button>Test printer connection</button></form>'''
|
|
130
|
+
if self.message:
|
|
131
|
+
content += '<p role="status">' + html.escape(self.message) + '</p>'
|
|
132
|
+
refresh = '<meta http-equiv="refresh" content="3">' if self.enrollment and self.completed_at is None else ''
|
|
133
|
+
return f'''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">{refresh}<title>Airkeep setup</title>
|
|
134
|
+
<style>body{{font:18px system-ui;max-width:38rem;margin:4rem auto;padding:1rem;background:#faf8f1;color:#20251e}}label{{display:block;margin:1rem 0}}input{{display:block;width:95%;font:inherit;padding:.5rem}}button{{font:inherit;padding:.6rem 1rem}}strong{{font:2rem monospace}}</style><main>{content}</main></html>'''.encode()
|
|
135
|
+
|
|
136
|
+
def handler(self):
|
|
137
|
+
setup = self
|
|
138
|
+
|
|
139
|
+
class Handler(BaseHTTPRequestHandler):
|
|
140
|
+
def setup(self):
|
|
141
|
+
super().setup()
|
|
142
|
+
self.connection.settimeout(5)
|
|
143
|
+
|
|
144
|
+
def log_message(self, *args):
|
|
145
|
+
# Keep form values and enrollment codes out of logs.
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
def valid_host(self):
|
|
149
|
+
return self.headers.get('Host') == setup.origin.removeprefix('http://')
|
|
150
|
+
|
|
151
|
+
def respond(self, status=200, content=b''):
|
|
152
|
+
self.send_response(status)
|
|
153
|
+
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
|
154
|
+
self.send_header('Content-Length', str(len(content)))
|
|
155
|
+
self.send_header('Cache-Control', 'no-store')
|
|
156
|
+
# Browsers need to send the real Origin on local form posts.
|
|
157
|
+
self.send_header('Referrer-Policy', 'same-origin')
|
|
158
|
+
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
159
|
+
self.send_header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
|
160
|
+
if status == 303:
|
|
161
|
+
self.send_header('Location', '/')
|
|
162
|
+
self.end_headers()
|
|
163
|
+
self.wfile.write(content)
|
|
164
|
+
|
|
165
|
+
def do_GET(self):
|
|
166
|
+
if not self.valid_host() or self.path != '/':
|
|
167
|
+
self.respond(403)
|
|
168
|
+
return
|
|
169
|
+
self.respond(content=setup.page())
|
|
170
|
+
|
|
171
|
+
def do_POST(self):
|
|
172
|
+
if (not self.valid_host() or self.headers.get('Origin') != setup.origin
|
|
173
|
+
or setup.completed_at is not None):
|
|
174
|
+
self.respond(403)
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
length = int(self.headers.get('Content-Length', '0'))
|
|
178
|
+
if (not 0 <= length <= 4096 or (length == 0 and self.path != '/enroll')
|
|
179
|
+
or self.headers.get('Content-Type') != 'application/x-www-form-urlencoded'):
|
|
180
|
+
self.respond(400)
|
|
181
|
+
return
|
|
182
|
+
fields = {key: value[0] for key, value in parse_qs(self.rfile.read(length).decode(), max_num_fields=8).items() if len(value) == 1}
|
|
183
|
+
except (ValueError, UnicodeError):
|
|
184
|
+
self.respond(400)
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
if self.path == '/printer' and setup.enrollment is None:
|
|
188
|
+
printer = PrinterSettings.validate(fields)
|
|
189
|
+
origin = https_origin(fields['origin'].strip())
|
|
190
|
+
if not setup.printer_test(printer):
|
|
191
|
+
setup.message = 'Printer test failed. Check LAN reachability, serial, access code and LAN mode, then try again.'
|
|
192
|
+
else:
|
|
193
|
+
setup.printer = printer
|
|
194
|
+
if setup.cloud:
|
|
195
|
+
setup.cloud.close()
|
|
196
|
+
setup.cloud = setup.cloud_factory(origin)
|
|
197
|
+
setup.message = ''
|
|
198
|
+
elif self.path == '/enroll' and setup.printer and setup.enrollment is None:
|
|
199
|
+
setup.enrollment = setup.cloud.begin(setup.installation_id)
|
|
200
|
+
setup.interval = float(setup.enrollment.get('interval', 5))
|
|
201
|
+
setup.next_poll = time.monotonic() + setup.interval
|
|
202
|
+
setup.expires_at = time.monotonic() + float(setup.enrollment['expires_in'])
|
|
203
|
+
setup.message = ''
|
|
204
|
+
else:
|
|
205
|
+
self.respond(404)
|
|
206
|
+
return
|
|
207
|
+
except (ValueError, KeyError, httpx.HTTPError):
|
|
208
|
+
setup.message = 'Setup could not complete. Check the settings and dashboard connection, then try again.'
|
|
209
|
+
# The enrollment page refreshes automatically, so serve it at GET /.
|
|
210
|
+
self.respond(303)
|
|
211
|
+
|
|
212
|
+
return Handler
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run_setup(spool, port, stopped=None):
|
|
216
|
+
setup = Setup(spool, port)
|
|
217
|
+
try:
|
|
218
|
+
with HTTPServer(('127.0.0.1', port), setup.handler()) as server:
|
|
219
|
+
server.timeout = 0.5
|
|
220
|
+
print(f'Open {setup.origin} to set up your printer.', flush=True)
|
|
221
|
+
while setup.completed_at is None or time.monotonic() - setup.completed_at < 4:
|
|
222
|
+
if stopped is not None and stopped.is_set():
|
|
223
|
+
raise SystemExit('Local setup stopped.')
|
|
224
|
+
server.handle_request()
|
|
225
|
+
setup.tick()
|
|
226
|
+
finally:
|
|
227
|
+
setup.close()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Private configuration and bounded, transactional telemetry outbox."""
|
|
2
|
+
import json
|
|
3
|
+
import sqlite3
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Spool:
|
|
10
|
+
def __init__(self, path):
|
|
11
|
+
self.lock = threading.Lock()
|
|
12
|
+
self.db = sqlite3.connect(path, check_same_thread=False)
|
|
13
|
+
self.db.row_factory = sqlite3.Row
|
|
14
|
+
self.db.executescript('''
|
|
15
|
+
PRAGMA secure_delete=ON;
|
|
16
|
+
CREATE TABLE IF NOT EXISTS configuration (id INTEGER PRIMARY KEY CHECK(id=1), data TEXT NOT NULL);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS samples (
|
|
18
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT UNIQUE NOT NULL,
|
|
19
|
+
ams_id TEXT NOT NULL, observed REAL NOT NULL, data TEXT NOT NULL,
|
|
20
|
+
replay INTEGER NOT NULL DEFAULT 0);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS sampling (ams_id TEXT PRIMARY KEY, observed REAL NOT NULL);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS quarantine (
|
|
23
|
+
event_id TEXT PRIMARY KEY, rejected_at REAL NOT NULL,
|
|
24
|
+
status INTEGER NOT NULL, data TEXT NOT NULL);
|
|
25
|
+
''')
|
|
26
|
+
self.mark_replay()
|
|
27
|
+
|
|
28
|
+
def configuration(self):
|
|
29
|
+
with self.lock:
|
|
30
|
+
row = self.db.execute('SELECT data FROM configuration WHERE id=1').fetchone()
|
|
31
|
+
return json.loads(row['data']) if row else None
|
|
32
|
+
|
|
33
|
+
def configure(self, config):
|
|
34
|
+
# Enrollment replacement and spool deletion commit together. Never send a
|
|
35
|
+
# previous account's backlog using a new account's bearer token.
|
|
36
|
+
with self.lock, self.db:
|
|
37
|
+
self.db.execute('DELETE FROM samples')
|
|
38
|
+
self.db.execute('DELETE FROM sampling')
|
|
39
|
+
self.db.execute('DELETE FROM quarantine')
|
|
40
|
+
self.db.execute('INSERT OR REPLACE INTO configuration VALUES (1,?)', (json.dumps(config),))
|
|
41
|
+
|
|
42
|
+
def save_reading(self, reading):
|
|
43
|
+
with self.lock, self.db:
|
|
44
|
+
self._prune(time.time())
|
|
45
|
+
previous = self.db.execute('SELECT observed FROM sampling WHERE ams_id=?', (reading.amsID,)).fetchone()
|
|
46
|
+
# Resume sampling after the system clock is corrected backwards.
|
|
47
|
+
if previous and 0 <= reading.observedAt - previous['observed'] < 30:
|
|
48
|
+
return
|
|
49
|
+
self.db.execute('INSERT INTO samples(event_id,ams_id,observed,data,replay) VALUES (?,?,?,?,?)',
|
|
50
|
+
(str(uuid.uuid4()), reading.amsID, reading.observedAt,
|
|
51
|
+
reading.model_dump_json(), 0))
|
|
52
|
+
self.db.execute('INSERT OR REPLACE INTO sampling VALUES (?,?)', (reading.amsID, reading.observedAt))
|
|
53
|
+
|
|
54
|
+
def _prune(self, now):
|
|
55
|
+
self.db.execute('DELETE FROM samples WHERE observed < ?', (now - 86400,))
|
|
56
|
+
self.db.execute('DELETE FROM sampling WHERE observed < ?', (now - 86400,))
|
|
57
|
+
self.db.execute('DELETE FROM quarantine WHERE rejected_at < ?', (now - 86400,))
|
|
58
|
+
|
|
59
|
+
def batch(self, now=None, limit=100):
|
|
60
|
+
now = time.time() if now is None else now
|
|
61
|
+
with self.lock, self.db:
|
|
62
|
+
self._prune(now)
|
|
63
|
+
# A fresh observation can resume live updates while history drains.
|
|
64
|
+
# Previously attempted events remain replay even if still recent.
|
|
65
|
+
rows = self.db.execute('''
|
|
66
|
+
SELECT *, (replay=0 AND observed BETWEEN ? AND ?) AS live
|
|
67
|
+
FROM samples
|
|
68
|
+
ORDER BY live DESC, CASE WHEN live THEN observed END DESC, observed,sequence
|
|
69
|
+
LIMIT ?
|
|
70
|
+
''', (now - 30, now, min(100, max(1, limit)))).fetchall()
|
|
71
|
+
return [dict(json.loads(row['data']), eventID=row['event_id'],
|
|
72
|
+
replay=not bool(row['live'])) for row in rows]
|
|
73
|
+
|
|
74
|
+
def acknowledge(self, readings):
|
|
75
|
+
with self.lock, self.db:
|
|
76
|
+
self.db.executemany('DELETE FROM samples WHERE event_id=?', [(item['eventID'],) for item in readings])
|
|
77
|
+
|
|
78
|
+
def mark_replay(self, readings=None):
|
|
79
|
+
with self.lock, self.db:
|
|
80
|
+
if readings is None:
|
|
81
|
+
self.db.execute('UPDATE samples SET replay=1')
|
|
82
|
+
else:
|
|
83
|
+
self.db.executemany('UPDATE samples SET replay=1 WHERE event_id=?',
|
|
84
|
+
[(item['eventID'],) for item in readings])
|
|
85
|
+
|
|
86
|
+
def quarantine(self, reading, status, now=None):
|
|
87
|
+
now = time.time() if now is None else now
|
|
88
|
+
with self.lock, self.db:
|
|
89
|
+
self.db.execute('INSERT OR REPLACE INTO quarantine VALUES (?,?,?,?)',
|
|
90
|
+
(reading['eventID'], now, status, json.dumps(reading)))
|
|
91
|
+
self.db.execute('DELETE FROM samples WHERE event_id=?', (reading['eventID'],))
|
|
92
|
+
self._prune(now)
|
|
93
|
+
self.db.execute('''DELETE FROM quarantine WHERE event_id NOT IN (
|
|
94
|
+
SELECT event_id FROM quarantine ORDER BY rejected_at DESC,event_id LIMIT 100)''')
|
|
95
|
+
|
|
96
|
+
def quarantined_count(self):
|
|
97
|
+
with self.lock, self.db:
|
|
98
|
+
self._prune(time.time())
|
|
99
|
+
return self.db.execute('SELECT COUNT(*) FROM quarantine').fetchone()[0]
|
|
100
|
+
|
|
101
|
+
def close(self):
|
|
102
|
+
self.db.close()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Bambu AMS report parsing shared by the relay and dashboard server."""
|
|
2
|
+
import math
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Reading(BaseModel):
|
|
9
|
+
amsID: str
|
|
10
|
+
humidity: float
|
|
11
|
+
temperature: float | None
|
|
12
|
+
rawIndex: int | None
|
|
13
|
+
observedAt: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_readings(payload: dict, now: float | None = None) -> list[Reading]:
|
|
17
|
+
now = time.time() if now is None else now
|
|
18
|
+
if not isinstance(payload, dict) or not math.isfinite(now):
|
|
19
|
+
return []
|
|
20
|
+
values = []
|
|
21
|
+
report = payload.get("print")
|
|
22
|
+
if not isinstance(report, dict):
|
|
23
|
+
return []
|
|
24
|
+
ams = report.get("ams")
|
|
25
|
+
if not isinstance(ams, dict):
|
|
26
|
+
return []
|
|
27
|
+
units = ams.get("ams") or []
|
|
28
|
+
if not isinstance(units, list):
|
|
29
|
+
return []
|
|
30
|
+
for unit in units[:16]:
|
|
31
|
+
if not isinstance(unit, dict):
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
identifier = unit.get("id")
|
|
35
|
+
if isinstance(identifier, bool) or not isinstance(identifier, (str, int)):
|
|
36
|
+
continue
|
|
37
|
+
identifier = str(identifier)
|
|
38
|
+
if not 1 <= len(identifier) <= 8 or not identifier.isascii() or not identifier.isdecimal():
|
|
39
|
+
continue
|
|
40
|
+
# Missing humidity_raw is a delta update, never a zero reading.
|
|
41
|
+
if isinstance(unit.get("humidity_raw"), bool):
|
|
42
|
+
continue
|
|
43
|
+
humidity = float(unit["humidity_raw"])
|
|
44
|
+
if not math.isfinite(humidity) or not 1 <= humidity <= 100:
|
|
45
|
+
continue # Zero is a known boot/sensor fault; do not label it dry.
|
|
46
|
+
temperature = None
|
|
47
|
+
if "temp" in unit:
|
|
48
|
+
try:
|
|
49
|
+
temp = float(unit["temp"])
|
|
50
|
+
if math.isfinite(temp) and 0 <= temp <= 100:
|
|
51
|
+
temperature = temp
|
|
52
|
+
except (ValueError, TypeError, OverflowError):
|
|
53
|
+
pass
|
|
54
|
+
index = None
|
|
55
|
+
try:
|
|
56
|
+
raw = int(unit.get("humidity", 0))
|
|
57
|
+
if 1 <= raw <= 5:
|
|
58
|
+
index = raw
|
|
59
|
+
except (ValueError, TypeError, OverflowError):
|
|
60
|
+
pass
|
|
61
|
+
values.append(Reading(amsID=identifier, humidity=humidity,
|
|
62
|
+
temperature=temperature, rawIndex=index, observedAt=now))
|
|
63
|
+
except (KeyError, ValueError, TypeError, OverflowError):
|
|
64
|
+
continue
|
|
65
|
+
return values
|