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 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/)
@@ -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