Add ESP32-C3 launcher and digital probe
This commit is contained in:
commit
935395797a
108
.env.example
Normal file
108
.env.example
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# ================================================================
|
||||||
|
# ESP32-C3 universal launcher configuration
|
||||||
|
# Copy to /.env on the ESP32. NEVER commit the real secrets file.
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
PRINT_CONFIG_ON_BOOT=false
|
||||||
|
|
||||||
|
# ---------- Wi-Fi ----------
|
||||||
|
WIFI_ENABLED=true
|
||||||
|
WIFI_SSID=YourSSID
|
||||||
|
WIFI_PASSWORD=YourWiFiPassword
|
||||||
|
WIFI_HOSTNAME=magnifica-s-pro
|
||||||
|
WIFI_CONNECT_ON_BOOT=true
|
||||||
|
WIFI_REQUIRED=true
|
||||||
|
WIFI_TIMEOUT_SECONDS=20
|
||||||
|
WIFI_POWER_SAVE=true
|
||||||
|
|
||||||
|
# false = Wi-Fi is switched off after updater network tasks when the app
|
||||||
|
# itself does not need permanent Wi-Fi.
|
||||||
|
WIFI_KEEP_CONNECTED=true
|
||||||
|
|
||||||
|
NTP_SYNC_ENABLED=true
|
||||||
|
NTP_HOST=pool.ntp.org
|
||||||
|
|
||||||
|
# ---------- Bluetooth ----------
|
||||||
|
# ESP32-C3 supports BLE only, not Bluetooth Classic.
|
||||||
|
BLUETOOTH_ENABLED=false
|
||||||
|
BLUETOOTH_NAME=Coffee
|
||||||
|
BLUETOOTH_ADVERTISE=true
|
||||||
|
BLUETOOTH_ADV_INTERVAL_MS=2500
|
||||||
|
|
||||||
|
# ---------- UART ----------
|
||||||
|
UART_ENABLED=false
|
||||||
|
UART_ID=1
|
||||||
|
UART_BAUD=115200
|
||||||
|
UART_BITS=8
|
||||||
|
UART_PARITY=none
|
||||||
|
UART_STOP_BITS=1
|
||||||
|
UART_TX_GPIO=20
|
||||||
|
UART_RX_GPIO=21
|
||||||
|
|
||||||
|
# ---------- Self-update ----------
|
||||||
|
UPDATE_ENABLED=true
|
||||||
|
|
||||||
|
# Remote manifest requests are intentionally infrequent.
|
||||||
|
UPDATE_CHECK_INTERVAL_HOURS=24
|
||||||
|
|
||||||
|
# A due update will not start until the application has been idle this long.
|
||||||
|
UPDATE_MIN_IDLE_SECONDS=900
|
||||||
|
|
||||||
|
# If due but busy, this is only a LOCAL sleep before reconsidering idleness.
|
||||||
|
# It does NOT contact the repository every 15 minutes unless the device is idle.
|
||||||
|
UPDATE_BUSY_RETRY_SECONDS=900
|
||||||
|
|
||||||
|
# Failed network/repository attempts wait before another remote attempt.
|
||||||
|
UPDATE_FAILURE_RETRY_SECONDS=3600
|
||||||
|
|
||||||
|
# Boot checks still obey the boot delay and idle requirement.
|
||||||
|
UPDATE_CHECK_ON_BOOT=true
|
||||||
|
UPDATE_BOOT_DELAY_SECONDS=180
|
||||||
|
|
||||||
|
UPDATE_HTTP_TIMEOUT_SECONDS=20
|
||||||
|
UPDATE_MAX_FILE_BYTES=262144
|
||||||
|
UPDATE_ALLOW_HTTP=false
|
||||||
|
|
||||||
|
# ---------- Repository ----------
|
||||||
|
# auto | gitea | forgejo | github | gitlab | bitbucket | generic
|
||||||
|
UPDATE_PROVIDER=gitea
|
||||||
|
UPDATE_REPO_URL=https://git.rolfsvaag.no/frarol96/Magnifica_S-Pro
|
||||||
|
UPDATE_BRANCH=main
|
||||||
|
UPDATE_MANIFEST_PATH=firmware/manifest.json
|
||||||
|
|
||||||
|
# Optional overrides for unusual/self-hosted raw URL layouts:
|
||||||
|
# UPDATE_MANIFEST_URL=https://host/specific/path/manifest.json
|
||||||
|
# UPDATE_RAW_BASE_URL=https://host/specific/raw/base/
|
||||||
|
|
||||||
|
# ---------- Authentication ----------
|
||||||
|
# none/public | auto | basic | bearer | token | header
|
||||||
|
#
|
||||||
|
# Private Gitea/Forgejo:
|
||||||
|
# UPDATE_AUTH_MODE=auto
|
||||||
|
# UPDATE_TOKEN=...
|
||||||
|
#
|
||||||
|
# Private GitLab:
|
||||||
|
# UPDATE_AUTH_MODE=auto
|
||||||
|
# UPDATE_TOKEN=...
|
||||||
|
#
|
||||||
|
# Private GitHub:
|
||||||
|
# UPDATE_AUTH_MODE=auto
|
||||||
|
# UPDATE_TOKEN=...
|
||||||
|
#
|
||||||
|
# Bitbucket app password / generic Basic auth:
|
||||||
|
# UPDATE_AUTH_MODE=basic
|
||||||
|
# UPDATE_USERNAME=...
|
||||||
|
# UPDATE_PASSWORD=...
|
||||||
|
#
|
||||||
|
# Custom auth header:
|
||||||
|
# UPDATE_AUTH_MODE=header
|
||||||
|
# UPDATE_AUTH_HEADER=X-Api-Key
|
||||||
|
# UPDATE_AUTH_PREFIX=
|
||||||
|
# UPDATE_TOKEN=...
|
||||||
|
|
||||||
|
UPDATE_AUTH_MODE=none
|
||||||
|
UPDATE_TOKEN=
|
||||||
|
UPDATE_USERNAME=
|
||||||
|
UPDATE_PASSWORD=
|
||||||
|
UPDATE_AUTH_HEADER=Authorization
|
||||||
|
UPDATE_AUTH_PREFIX=
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
# Local Codex project log; intentionally never version-controlled.
|
||||||
|
AGENTS.md
|
||||||
115
README.md
Normal file
115
README.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# ESP32-C3 Universal MicroPython Launcher
|
||||||
|
|
||||||
|
A fault-tolerant launcher intended for the Magnifica ESP32-C3 project, but kept generic enough to reuse elsewhere.
|
||||||
|
|
||||||
|
## Core behavior
|
||||||
|
|
||||||
|
- `/.env` on the device holds all secrets and per-device options.
|
||||||
|
- Wi-Fi, BLE and UART are independent startup options.
|
||||||
|
- One Git repository can hold both the launcher and main application.
|
||||||
|
- The ESP32 does **not** need Git. It downloads a tiny JSON manifest and only changed raw files.
|
||||||
|
- `/launcher.py`, `/app/main.py`, and other manifest-listed files can update in one staged transaction.
|
||||||
|
- SHA-256 is verified before any file is committed.
|
||||||
|
- `.bak` rollback files plus a stable `boot.py` protect against interrupted or broken OTA.
|
||||||
|
- Secrets are never updated and are never in the manifest.
|
||||||
|
|
||||||
|
## Energy / update behavior
|
||||||
|
|
||||||
|
There is no 5-second repository polling loop.
|
||||||
|
|
||||||
|
Defaults are:
|
||||||
|
|
||||||
|
- remote manifest check: **once every 24 hours**
|
||||||
|
- boot delay before a possible check: **3 minutes**
|
||||||
|
- minimum inactivity before OTA may begin: **5 minutes**
|
||||||
|
- if busy when due: reconsider locally after **15 minutes**
|
||||||
|
- network failure: wait **1 hour** before another remote attempt
|
||||||
|
- BLE disabled by default
|
||||||
|
- Wi-Fi power saving enabled best-effort
|
||||||
|
|
||||||
|
If Wi-Fi exists only for OTA, set `WIFI_KEEP_CONNECTED=false`. If the app itself needs Wi-Fi for remote control, leave it `true`.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
repo/
|
||||||
|
├─ README.md
|
||||||
|
├─ .gitignore
|
||||||
|
├─ .env.example
|
||||||
|
├─ firmware/
|
||||||
|
│ ├─ boot.py # stable recovery stub; install manually
|
||||||
|
│ ├─ launcher.py # OTA-updatable launcher
|
||||||
|
│ ├─ manifest.json # generated SHA-256 manifest
|
||||||
|
│ └─ app/
|
||||||
|
│ ├─ __init__.py
|
||||||
|
│ └─ main.py # actual Magnifica logic
|
||||||
|
├─ tools/
|
||||||
|
│ └─ build_manifest.py
|
||||||
|
└─ docs/
|
||||||
|
├─ DIGITAL_PROBE.md
|
||||||
|
├─ SETUP.md
|
||||||
|
└─ OTA_PROTOCOL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
On the ESP32:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├─ boot.py
|
||||||
|
├─ launcher.py
|
||||||
|
├─ .env
|
||||||
|
├─ update_state.json # generated automatically
|
||||||
|
└─ app/
|
||||||
|
├─ __init__.py
|
||||||
|
└─ main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safe inactivity-aware updates
|
||||||
|
|
||||||
|
The application receives `ctx["activity"]`.
|
||||||
|
|
||||||
|
Call this whenever a real interaction or machine-state change occurs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ctx["activity"].touch()
|
||||||
|
```
|
||||||
|
|
||||||
|
Bracket anything that must never be interrupted by OTA:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ctx["activity"].begin()
|
||||||
|
try:
|
||||||
|
await brew_or_move_mechanism()
|
||||||
|
finally:
|
||||||
|
ctx["activity"].end()
|
||||||
|
```
|
||||||
|
|
||||||
|
OTA can start only after the long check interval is due **and** the device has been idle for `UPDATE_MIN_IDLE_SECONDS` **and** no busy lock is active.
|
||||||
|
|
||||||
|
## Gitea example
|
||||||
|
|
||||||
|
```env
|
||||||
|
UPDATE_PROVIDER=gitea
|
||||||
|
UPDATE_REPO_URL=https://git.example.com/user/repo
|
||||||
|
UPDATE_BRANCH=main
|
||||||
|
UPDATE_AUTH_MODE=auto
|
||||||
|
UPDATE_TOKEN=your_token
|
||||||
|
```
|
||||||
|
|
||||||
|
A public Gitea repo uses:
|
||||||
|
|
||||||
|
```env
|
||||||
|
UPDATE_AUTH_MODE=none
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other platforms
|
||||||
|
|
||||||
|
Built-in URL/auth handling exists for Gitea/Forgejo/Codeberg, GitLab, GitHub and Bitbucket. For unusual self-hosted layouts, use `UPDATE_MANIFEST_URL` or `UPDATE_RAW_BASE_URL` and optionally a custom auth header.
|
||||||
|
|
||||||
|
See `docs/SETUP.md` and `docs/OTA_PROTOCOL.md`.
|
||||||
|
|
||||||
|
## Development probe
|
||||||
|
|
||||||
|
The first Magnifica S development tool is an input-only digital probe for
|
||||||
|
front-panel signal discovery. See `docs/DIGITAL_PROBE.md`; do not connect an
|
||||||
|
unknown machine wire directly to an ESP32 GPIO.
|
||||||
50
docs/DIGITAL_PROBE.md
Normal file
50
docs/DIGITAL_PROBE.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Read-only digital probe
|
||||||
|
|
||||||
|
`app.digital_probe` is the first Magnifica S development tool. It measures a
|
||||||
|
single already-conditioned digital signal; it does not control the machine and
|
||||||
|
never configures the ESP32-C3 pin as an output.
|
||||||
|
|
||||||
|
## Electrical boundary — required before connection
|
||||||
|
|
||||||
|
Do **not** attach an unknown wire from the coffee machine directly to an
|
||||||
|
ESP32-C3 GPIO. First identify the wire with the machine unplugged, then verify
|
||||||
|
its voltage, polarity, ground relationship, and behaviour at power-on with
|
||||||
|
appropriate test equipment. Connect it only through an interface that ensures
|
||||||
|
the ESP32 input stays between GND and 3.3 V at all times, including transients.
|
||||||
|
|
||||||
|
Use a board GPIO that is actually broken out and not occupied by the board.
|
||||||
|
Avoid GPIO2, GPIO8, and GPIO9 (ESP32-C3 boot strapping), GPIO12–GPIO17
|
||||||
|
(normally flash), and GPIO18–GPIO19 when USB-JTAG is needed. GPIO3 is a
|
||||||
|
candidate on boards that expose it, but confirm the board schematic first.
|
||||||
|
|
||||||
|
Keep the probe ground disconnected until it is known to be safe to share a
|
||||||
|
ground. If the machine signal is not confirmed low-voltage and ground-referred,
|
||||||
|
use a suitably rated isolated/conditioned interface instead of a direct link.
|
||||||
|
|
||||||
|
## Install and use
|
||||||
|
|
||||||
|
Deploy the whole `firmware/app/` directory. The probe is included in the OTA
|
||||||
|
manifest, but it is not auto-started, so normal launcher behaviour is unchanged.
|
||||||
|
|
||||||
|
At the MicroPython REPL:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.digital_probe import open_probe
|
||||||
|
probe = open_probe(3) # Replace 3 after confirming your board pin and wiring.
|
||||||
|
|
||||||
|
probe.state()
|
||||||
|
probe.frequency(seconds=2)
|
||||||
|
probe.pulse(timeout_us=1_000_000)
|
||||||
|
probe.watch(seconds=5, max_events=200)
|
||||||
|
```
|
||||||
|
|
||||||
|
`frequency()` counts rising edges in its measurement window. It is useful for
|
||||||
|
signal discovery and approximate rates, but very fast or noisy signals can
|
||||||
|
overflow or exceed MicroPython IRQ handling capacity. `pulse()` reports one
|
||||||
|
high and one low width; negative results are timeouts (`-2` means the requested
|
||||||
|
level did not arrive, `-1` means its pulse did not finish). `watch()` is a
|
||||||
|
short, polling-based transition trace and can miss narrow pulses.
|
||||||
|
|
||||||
|
For every capture, note the machine state, wire identifier, wiring/interface,
|
||||||
|
measurement settings, and output. That context is necessary before mapping a
|
||||||
|
panel signal to a machine event.
|
||||||
67
docs/OTA_PROTOCOL.md
Normal file
67
docs/OTA_PROTOCOL.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# OTA protocol
|
||||||
|
|
||||||
|
The updater uses a JSON manifest rather than Git operations on-device.
|
||||||
|
|
||||||
|
## Manifest schema 1
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"release": "1.2.3",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"source": "firmware/launcher.py",
|
||||||
|
"target": "/launcher.py",
|
||||||
|
"sha256": "..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "firmware/app/main.py",
|
||||||
|
"target": "/app/main.py",
|
||||||
|
"sha256": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A file is downloaded only when its local SHA-256 differs.
|
||||||
|
|
||||||
|
## Transaction
|
||||||
|
|
||||||
|
1. Wait for the long update interval to become due.
|
||||||
|
2. Wait for configured inactivity and no busy lock.
|
||||||
|
3. Connect Wi-Fi if needed.
|
||||||
|
4. Fetch the manifest.
|
||||||
|
5. Hash local target files.
|
||||||
|
6. Download only changed files into `/.update`.
|
||||||
|
7. Verify every SHA-256.
|
||||||
|
8. Create `/update_pending.json` with `commit_complete=false`.
|
||||||
|
9. Rename current files to `.bak`.
|
||||||
|
10. Rename staged files into their final locations.
|
||||||
|
11. Mark `commit_complete=true`.
|
||||||
|
12. Reboot.
|
||||||
|
13. New launcher/app performs setup.
|
||||||
|
14. App calls `ctx["system"].ready()`.
|
||||||
|
15. Backups and pending marker are removed.
|
||||||
|
|
||||||
|
A power loss during commit leaves `commit_complete=false`; stable `boot.py` restores the backups before normal startup.
|
||||||
|
|
||||||
|
If a completed update cannot import/start, `boot.py` restores the backups.
|
||||||
|
|
||||||
|
## Why boot.py is intentionally stable
|
||||||
|
|
||||||
|
The recovery component should not replace itself in the same transaction it is responsible for recovering. Update `/boot.py` manually and rarely.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Credentials exist only in `/.env`.
|
||||||
|
|
||||||
|
Supported modes:
|
||||||
|
|
||||||
|
- `none` / `public`
|
||||||
|
- `auto`
|
||||||
|
- `basic`
|
||||||
|
- `bearer`
|
||||||
|
- `token`
|
||||||
|
- `header` (arbitrary header + optional prefix)
|
||||||
|
|
||||||
|
HTTPS is required unless `UPDATE_ALLOW_HTTP=true` is explicitly set.
|
||||||
96
docs/SETUP.md
Normal file
96
docs/SETUP.md
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# Setup
|
||||||
|
|
||||||
|
## 1. Flash MicroPython
|
||||||
|
|
||||||
|
Use a current ESP32-C3 MicroPython build.
|
||||||
|
|
||||||
|
## 2. Initial device files
|
||||||
|
|
||||||
|
Copy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
firmware/boot.py -> /boot.py
|
||||||
|
firmware/launcher.py -> /launcher.py
|
||||||
|
firmware/app/ -> /app/
|
||||||
|
.env.example -> /.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `/.env` on the ESP32 with real Wi-Fi/repository credentials.
|
||||||
|
|
||||||
|
## 3. Build the manifest before pushing a release
|
||||||
|
|
||||||
|
On your development machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/build_manifest.py 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit/push the resulting `firmware/manifest.json` together with the code.
|
||||||
|
|
||||||
|
The ESP32 compares SHA-256 hashes. A changed hash, not just the release label, determines whether a file updates.
|
||||||
|
|
||||||
|
## 4. Application interface
|
||||||
|
|
||||||
|
Recommended application shape:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def setup(ctx):
|
||||||
|
# initialize GPIOs and internal state
|
||||||
|
ctx["system"].ready()
|
||||||
|
|
||||||
|
async def main(ctx):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`ready()` is significant after OTA: it says the new launcher/app got through setup successfully, so rollback backups may be deleted.
|
||||||
|
|
||||||
|
### Context entries
|
||||||
|
|
||||||
|
- `ctx["env"]` — parsed local `.env`
|
||||||
|
- `ctx["wifi"]` — WiFiManager
|
||||||
|
- `ctx["ble"]` — BLE object or `None`
|
||||||
|
- `ctx["uart"]` — UART object or `None`
|
||||||
|
- `ctx["activity"]` — idle/busy tracker
|
||||||
|
- `ctx["updater"]` — UpdateManager
|
||||||
|
- `ctx["system"]` — ready/reboot helpers
|
||||||
|
|
||||||
|
## 5. Activity tracking
|
||||||
|
|
||||||
|
On user/device activity:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ctx["activity"].touch()
|
||||||
|
```
|
||||||
|
|
||||||
|
For a protected operation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ctx["activity"].begin()
|
||||||
|
try:
|
||||||
|
await operation()
|
||||||
|
finally:
|
||||||
|
ctx["activity"].end()
|
||||||
|
```
|
||||||
|
|
||||||
|
A due update is postponed while the machine is active.
|
||||||
|
|
||||||
|
## 6. Energy-conscious configurations
|
||||||
|
|
||||||
|
OTA-only Wi-Fi:
|
||||||
|
|
||||||
|
```env
|
||||||
|
WIFI_ENABLED=true
|
||||||
|
WIFI_KEEP_CONNECTED=false
|
||||||
|
BLUETOOTH_ENABLED=false
|
||||||
|
UPDATE_CHECK_INTERVAL_HOURS=24
|
||||||
|
```
|
||||||
|
|
||||||
|
Always-online remote-control Wi-Fi:
|
||||||
|
|
||||||
|
```env
|
||||||
|
WIFI_ENABLED=true
|
||||||
|
WIFI_KEEP_CONNECTED=true
|
||||||
|
WIFI_POWER_SAVE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The ESP32-C3 supports BLE, not Bluetooth Classic.
|
||||||
1
firmware/app/__init__.py
Normal file
1
firmware/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Application package."""
|
||||||
125
firmware/app/digital_probe.py
Normal file
125
firmware/app/digital_probe.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""Read-only digital logic probe for a conditioned ESP32-C3 GPIO input.
|
||||||
|
|
||||||
|
The ESP32 pin is always configured as an input with no internal pull. This is
|
||||||
|
for observation only: it must receive a signal already conditioned to the
|
||||||
|
ESP32-C3 input range. It cannot make an unknown coffee-machine wire safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from machine import Pin, time_pulse_us
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class DigitalProbe:
|
||||||
|
"""Measure level, rising-edge frequency, pulse widths, and transitions."""
|
||||||
|
|
||||||
|
def __init__(self, pin_id):
|
||||||
|
self.pin_id = int(pin_id)
|
||||||
|
# Do not enable a pull resistor: the observed circuit must determine
|
||||||
|
# the level, and the probe must not intentionally bias it.
|
||||||
|
self.signal = Pin(self.pin_id, Pin.IN)
|
||||||
|
self._edge_count = 0
|
||||||
|
|
||||||
|
def _on_rising_edge(self, _pin):
|
||||||
|
# Keep IRQ work deliberately minimal. Do not print or allocate here.
|
||||||
|
self._edge_count += 1
|
||||||
|
|
||||||
|
def state(self):
|
||||||
|
"""Print and return the present digital level (0 or 1)."""
|
||||||
|
value = self.signal.value()
|
||||||
|
print("GPIO%d = %d" % (self.pin_id, value))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def frequency(self, seconds=2):
|
||||||
|
"""Count rising edges during a fixed window and return approximate Hz."""
|
||||||
|
duration_ms = int(float(seconds) * 1000)
|
||||||
|
if duration_ms <= 0:
|
||||||
|
raise ValueError("seconds must be greater than zero")
|
||||||
|
|
||||||
|
self._edge_count = 0
|
||||||
|
start = time.ticks_ms()
|
||||||
|
self.signal.irq(trigger=Pin.IRQ_RISING, handler=self._on_rising_edge)
|
||||||
|
try:
|
||||||
|
time.sleep_ms(duration_ms)
|
||||||
|
finally:
|
||||||
|
self.signal.irq(handler=None)
|
||||||
|
|
||||||
|
elapsed_ms = max(1, time.ticks_diff(time.ticks_ms(), start))
|
||||||
|
count = self._edge_count
|
||||||
|
hz = (count * 1000.0) / elapsed_ms
|
||||||
|
print("Freq: %.2f Hz (%d rising edges / %.3fs)" % (
|
||||||
|
hz, count, elapsed_ms / 1000.0))
|
||||||
|
return hz
|
||||||
|
|
||||||
|
def pulse(self, timeout_us=1_000_000):
|
||||||
|
"""Measure one HIGH then one LOW pulse; return their timing data.
|
||||||
|
|
||||||
|
``time_pulse_us`` returns -2 when a requested level never arrives and
|
||||||
|
-1 when that level does not end before the timeout.
|
||||||
|
"""
|
||||||
|
timeout_us = int(timeout_us)
|
||||||
|
if timeout_us <= 0:
|
||||||
|
raise ValueError("timeout_us must be greater than zero")
|
||||||
|
|
||||||
|
high_us = time_pulse_us(self.signal, 1, timeout_us)
|
||||||
|
low_us = time_pulse_us(self.signal, 0, timeout_us)
|
||||||
|
if high_us < 0 or low_us < 0:
|
||||||
|
print("No complete pulse: HIGH=%d LOW=%d" % (high_us, low_us))
|
||||||
|
print("-2 = level did not arrive; -1 = pulse did not end in time")
|
||||||
|
return None
|
||||||
|
|
||||||
|
period_us = high_us + low_us
|
||||||
|
if period_us <= 0:
|
||||||
|
print("No complete pulse")
|
||||||
|
return None
|
||||||
|
|
||||||
|
hz = 1_000_000.0 / period_us
|
||||||
|
duty = (high_us * 100.0) / period_us
|
||||||
|
result = {
|
||||||
|
"high_us": high_us,
|
||||||
|
"low_us": low_us,
|
||||||
|
"period_us": period_us,
|
||||||
|
"frequency_hz": hz,
|
||||||
|
"duty_percent": duty,
|
||||||
|
}
|
||||||
|
print("HIGH: %dus LOW: %dus" % (high_us, low_us))
|
||||||
|
print("Freq: %.2f Hz Duty: %.1f%%" % (hz, duty))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def watch(self, seconds=5, max_events=200):
|
||||||
|
"""Poll and print transitions with microsecond timestamps.
|
||||||
|
|
||||||
|
This is intended for short diagnostic captures. Narrow pulses can be
|
||||||
|
missed because output and polling run in Python; use ``frequency`` or
|
||||||
|
dedicated capture hardware for high-rate or timing-critical signals.
|
||||||
|
"""
|
||||||
|
duration_ms = int(float(seconds) * 1000)
|
||||||
|
max_events = int(max_events)
|
||||||
|
if duration_ms <= 0:
|
||||||
|
raise ValueError("seconds must be greater than zero")
|
||||||
|
if max_events <= 0:
|
||||||
|
raise ValueError("max_events must be greater than zero")
|
||||||
|
|
||||||
|
last = self.signal.value()
|
||||||
|
start_us = time.ticks_us()
|
||||||
|
deadline_ms = time.ticks_add(time.ticks_ms(), duration_ms)
|
||||||
|
events = 0
|
||||||
|
while time.ticks_diff(deadline_ms, time.ticks_ms()) > 0:
|
||||||
|
value = self.signal.value()
|
||||||
|
if value == last:
|
||||||
|
continue
|
||||||
|
now_us = time.ticks_us()
|
||||||
|
print("%9dus %d" % (time.ticks_diff(now_us, start_us), value))
|
||||||
|
last = value
|
||||||
|
events += 1
|
||||||
|
if events >= max_events:
|
||||||
|
print("Capture stopped after %d events" % max_events)
|
||||||
|
break
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def open_probe(pin_id):
|
||||||
|
"""Create a probe and print the interactive REPL commands."""
|
||||||
|
probe = DigitalProbe(pin_id)
|
||||||
|
print("ESP32-C3 read-only logic probe on GPIO%d" % probe.pin_id)
|
||||||
|
print("Use: probe.state(), probe.frequency(), probe.pulse(), probe.watch()")
|
||||||
|
return probe
|
||||||
32
firmware/app/main.py
Normal file
32
firmware/app/main.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Example application entry point.
|
||||||
|
|
||||||
|
Replace this with the Magnifica logic. The launcher provides connectivity,
|
||||||
|
configuration, activity tracking and OTA in ctx.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import uasyncio as asyncio
|
||||||
|
except ImportError:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(ctx):
|
||||||
|
# Configure GPIOs, state machines, etc. here.
|
||||||
|
# If this raises immediately after an OTA, boot.py can roll back.
|
||||||
|
ctx["system"].ready()
|
||||||
|
|
||||||
|
|
||||||
|
async def main(ctx):
|
||||||
|
activity = ctx["activity"]
|
||||||
|
uart = ctx["uart"]
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Example: UART traffic counts as activity.
|
||||||
|
if uart is not None and uart.any():
|
||||||
|
activity.touch()
|
||||||
|
data = uart.read()
|
||||||
|
if data:
|
||||||
|
print("[app] UART:", data)
|
||||||
|
|
||||||
|
# This local sleep is app responsiveness only; it is NOT OTA polling.
|
||||||
|
await asyncio.sleep_ms(250)
|
||||||
92
firmware/boot.py
Normal file
92
firmware/boot.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Stable recovery boot stub.
|
||||||
|
|
||||||
|
Install once as /boot.py. It is deliberately excluded from normal OTA updates.
|
||||||
|
If a staged launcher/application update is interrupted or cannot boot, this
|
||||||
|
restores the previous .bak files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ujson as json
|
||||||
|
except ImportError:
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
import uos as os
|
||||||
|
except ImportError:
|
||||||
|
import os
|
||||||
|
|
||||||
|
PENDING_FILE = "/update_pending.json"
|
||||||
|
|
||||||
|
|
||||||
|
def exists(path):
|
||||||
|
try:
|
||||||
|
os.stat(path)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def remove(path):
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def read_pending():
|
||||||
|
if not exists(PENDING_FILE):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(PENDING_FILE, "r") as f:
|
||||||
|
return json.loads(f.read())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(pending):
|
||||||
|
if not pending:
|
||||||
|
return False
|
||||||
|
restored = False
|
||||||
|
for item in reversed(pending.get("files", [])):
|
||||||
|
target = item.get("target")
|
||||||
|
backup = item.get("backup")
|
||||||
|
if not target or not backup:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if exists(backup):
|
||||||
|
remove(target)
|
||||||
|
os.rename(backup, target)
|
||||||
|
restored = True
|
||||||
|
except Exception as exc:
|
||||||
|
print("[boot] rollback failed:", target, exc)
|
||||||
|
remove(PENDING_FILE)
|
||||||
|
return restored
|
||||||
|
|
||||||
|
|
||||||
|
pending = read_pending()
|
||||||
|
|
||||||
|
# Never boot a half-committed file set after a power loss.
|
||||||
|
if pending and not pending.get("commit_complete", False):
|
||||||
|
print("[boot] interrupted OTA commit; rolling back")
|
||||||
|
rollback(pending)
|
||||||
|
try:
|
||||||
|
import machine
|
||||||
|
machine.reset()
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit
|
||||||
|
|
||||||
|
try:
|
||||||
|
import launcher
|
||||||
|
launcher.run()
|
||||||
|
except Exception as exc:
|
||||||
|
print("[boot] launcher/application startup failed:", exc)
|
||||||
|
pending = read_pending()
|
||||||
|
if pending:
|
||||||
|
print("[boot] reverting pending OTA")
|
||||||
|
rollback(pending)
|
||||||
|
try:
|
||||||
|
import machine
|
||||||
|
machine.reset()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
841
firmware/launcher.py
Normal file
841
firmware/launcher.py
Normal file
@ -0,0 +1,841 @@
|
|||||||
|
"""
|
||||||
|
Universal ESP32-C3 MicroPython launcher.
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
- Loads local /.env secrets/config.
|
||||||
|
- Independently enables/configures Wi-Fi, BLE and UART.
|
||||||
|
- Runs /app/main.py.
|
||||||
|
- Optional manifest-based self-update from Gitea/Forgejo/Codeberg,
|
||||||
|
GitHub, GitLab, Bitbucket, or a generic raw URL.
|
||||||
|
- Public, Basic, Bearer/token and custom-header authentication.
|
||||||
|
- Updates only begin after configured inactivity.
|
||||||
|
- Remote checks are infrequent (default: every 24 hours).
|
||||||
|
- Staged downloads, SHA-256 verification, backups and boot rollback.
|
||||||
|
|
||||||
|
This intentionally uses raw HTTP(S) files + a manifest rather than running Git
|
||||||
|
on the ESP32. MicroPython only downloads files that actually changed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ujson as json
|
||||||
|
except ImportError:
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
import uos as os
|
||||||
|
except ImportError:
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
import ubinascii as binascii
|
||||||
|
except ImportError:
|
||||||
|
import binascii
|
||||||
|
try:
|
||||||
|
import uhashlib as hashlib
|
||||||
|
except ImportError:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import time
|
||||||
|
try:
|
||||||
|
import uasyncio as asyncio
|
||||||
|
except ImportError:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
ENV_FILE = "/.env"
|
||||||
|
PENDING_FILE = "/update_pending.json"
|
||||||
|
STATE_FILE = "/update_state.json"
|
||||||
|
STAGE_DIR = "/.update"
|
||||||
|
DEFAULT_MANIFEST_PATH = "firmware/manifest.json"
|
||||||
|
_GLOBAL_ENV = {}
|
||||||
|
|
||||||
|
|
||||||
|
def log(*parts):
|
||||||
|
print("[launcher]", *parts)
|
||||||
|
|
||||||
|
|
||||||
|
def exists(path):
|
||||||
|
try:
|
||||||
|
os.stat(path)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def remove(path):
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def dirname(path):
|
||||||
|
if "/" not in path:
|
||||||
|
return ""
|
||||||
|
out = path.rsplit("/", 1)[0]
|
||||||
|
return out or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def mkdir_p(path):
|
||||||
|
if not path or path == "/":
|
||||||
|
return
|
||||||
|
current = ""
|
||||||
|
for piece in path.split("/"):
|
||||||
|
if not piece:
|
||||||
|
continue
|
||||||
|
current += "/" + piece
|
||||||
|
if not exists(current):
|
||||||
|
try:
|
||||||
|
os.mkdir(current)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def remove_tree_best_effort(path):
|
||||||
|
if not exists(path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for name in os.listdir(path):
|
||||||
|
child = path + "/" + name
|
||||||
|
try:
|
||||||
|
mode = os.stat(child)[0]
|
||||||
|
if mode & 0x4000:
|
||||||
|
remove_tree_best_effort(child)
|
||||||
|
else:
|
||||||
|
remove(child)
|
||||||
|
except Exception:
|
||||||
|
remove(child)
|
||||||
|
os.rmdir(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def as_bool(value, default=False):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return str(value).strip().lower() in ("1", "true", "yes", "on", "enabled")
|
||||||
|
|
||||||
|
|
||||||
|
def as_int(value, default=0):
|
||||||
|
try:
|
||||||
|
return int(str(value).strip())
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def strip_quotes(value):
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||||
|
return value[1:-1]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def load_env(path=ENV_FILE):
|
||||||
|
env = {}
|
||||||
|
if not exists(path):
|
||||||
|
log(path, "not found; built-in defaults will be used")
|
||||||
|
return env
|
||||||
|
|
||||||
|
with open(path, "r") as f:
|
||||||
|
for raw in f:
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.startswith("export "):
|
||||||
|
line = line[7:].strip()
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if value and value[0] not in ("'", '"') and " #" in value:
|
||||||
|
value = value.split(" #", 1)[0].rstrip()
|
||||||
|
env[key] = strip_quotes(value)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def safe_config_log(env):
|
||||||
|
secret_words = ("PASSWORD", "TOKEN", "SECRET", "KEY")
|
||||||
|
log("configuration:")
|
||||||
|
for key in sorted(env):
|
||||||
|
if any(word in key.upper() for word in secret_words):
|
||||||
|
value = "***" if env.get(key) else ""
|
||||||
|
else:
|
||||||
|
value = env.get(key)
|
||||||
|
print(" %s=%s" % (key, value))
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path):
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
h.update(chunk)
|
||||||
|
return binascii.hexlify(h.digest()).decode().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_atomic(path, obj):
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
f.write(json.dumps(obj))
|
||||||
|
remove(path)
|
||||||
|
os.rename(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path, default=None):
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
return json.loads(f.read())
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def epoch_is_valid():
|
||||||
|
try:
|
||||||
|
return time.time() > 1700000000
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityTracker:
|
||||||
|
"""Tracks idleness and protected/busy operations for safe OTA timing."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._last_ms = time.ticks_ms()
|
||||||
|
self._busy_depth = 0
|
||||||
|
|
||||||
|
def touch(self):
|
||||||
|
self._last_ms = time.ticks_ms()
|
||||||
|
|
||||||
|
def begin(self):
|
||||||
|
self._busy_depth += 1
|
||||||
|
self.touch()
|
||||||
|
|
||||||
|
def end(self):
|
||||||
|
if self._busy_depth > 0:
|
||||||
|
self._busy_depth -= 1
|
||||||
|
self.touch()
|
||||||
|
|
||||||
|
def busy(self):
|
||||||
|
return self._busy_depth > 0
|
||||||
|
|
||||||
|
def idle_ms(self):
|
||||||
|
return max(0, time.ticks_diff(time.ticks_ms(), self._last_ms))
|
||||||
|
|
||||||
|
def idle_for(self, seconds):
|
||||||
|
return (not self.busy()) and self.idle_ms() >= int(seconds * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
class WiFiManager:
|
||||||
|
def __init__(self, env):
|
||||||
|
self.env = env
|
||||||
|
self.wlan = None
|
||||||
|
|
||||||
|
def enabled(self):
|
||||||
|
return as_bool(self.env.get("WIFI_ENABLED"), False)
|
||||||
|
|
||||||
|
def connected(self):
|
||||||
|
return bool(self.wlan and self.wlan.isconnected())
|
||||||
|
|
||||||
|
def _ensure_interface(self):
|
||||||
|
if self.wlan:
|
||||||
|
return self.wlan
|
||||||
|
import network
|
||||||
|
self.wlan = network.WLAN(network.STA_IF)
|
||||||
|
self.wlan.active(True)
|
||||||
|
|
||||||
|
# Best effort: constants differ slightly between MicroPython builds.
|
||||||
|
if as_bool(self.env.get("WIFI_POWER_SAVE"), True):
|
||||||
|
try:
|
||||||
|
pm = getattr(network, "PM_POWERSAVE", None)
|
||||||
|
if pm is not None:
|
||||||
|
self.wlan.config(pm=pm)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self.wlan
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
if not self.enabled():
|
||||||
|
return None
|
||||||
|
ssid = self.env.get("WIFI_SSID", "")
|
||||||
|
password = self.env.get("WIFI_PASSWORD", "")
|
||||||
|
if not ssid:
|
||||||
|
raise RuntimeError("WIFI_ENABLED=true but WIFI_SSID is empty")
|
||||||
|
|
||||||
|
wlan = self._ensure_interface()
|
||||||
|
if wlan.isconnected():
|
||||||
|
return wlan
|
||||||
|
|
||||||
|
hostname = self.env.get("WIFI_HOSTNAME", "")
|
||||||
|
if hostname:
|
||||||
|
try:
|
||||||
|
wlan.config(hostname=hostname)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
timeout_s = as_int(self.env.get("WIFI_TIMEOUT_SECONDS"), 20)
|
||||||
|
log("connecting Wi-Fi:", ssid)
|
||||||
|
wlan.connect(ssid, password)
|
||||||
|
deadline = time.ticks_add(time.ticks_ms(), timeout_s * 1000)
|
||||||
|
while not wlan.isconnected():
|
||||||
|
if time.ticks_diff(deadline, time.ticks_ms()) <= 0:
|
||||||
|
raise RuntimeError("Wi-Fi connection timeout")
|
||||||
|
time.sleep_ms(250)
|
||||||
|
|
||||||
|
log("Wi-Fi connected:", wlan.ifconfig()[0])
|
||||||
|
self._sync_time_best_effort()
|
||||||
|
return wlan
|
||||||
|
|
||||||
|
def _sync_time_best_effort(self):
|
||||||
|
if not as_bool(self.env.get("NTP_SYNC_ENABLED"), True):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import ntptime
|
||||||
|
ntptime.host = self.env.get("NTP_HOST", "pool.ntp.org")
|
||||||
|
ntptime.settime()
|
||||||
|
except Exception as exc:
|
||||||
|
log("NTP sync skipped/failed:", exc)
|
||||||
|
|
||||||
|
def disconnect_if_allowed(self):
|
||||||
|
if not self.wlan or as_bool(self.env.get("WIFI_KEEP_CONNECTED"), True):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.wlan.disconnect()
|
||||||
|
self.wlan.active(False)
|
||||||
|
log("Wi-Fi powered down after network task")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ble_adv_payload(name):
|
||||||
|
payload = bytearray(b"\x02\x01\x06")
|
||||||
|
name_b = name.encode()
|
||||||
|
if len(name_b) > 26:
|
||||||
|
name_b = name_b[:26]
|
||||||
|
payload.extend(bytes((len(name_b) + 1, 0x09)))
|
||||||
|
payload.extend(name_b)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def setup_ble(env):
|
||||||
|
# ESP32-C3 supports BLE, not Bluetooth Classic.
|
||||||
|
if not as_bool(env.get("BLUETOOTH_ENABLED"), False):
|
||||||
|
log("BLE disabled")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import bluetooth
|
||||||
|
except ImportError:
|
||||||
|
import ubluetooth as bluetooth
|
||||||
|
|
||||||
|
ble = bluetooth.BLE()
|
||||||
|
ble.active(True)
|
||||||
|
name = env.get("BLUETOOTH_NAME", "ESP32-C3")
|
||||||
|
try:
|
||||||
|
ble.config(gap_name=name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if as_bool(env.get("BLUETOOTH_ADVERTISE"), True):
|
||||||
|
interval_ms = as_int(env.get("BLUETOOTH_ADV_INTERVAL_MS"), 1000)
|
||||||
|
try:
|
||||||
|
ble.gap_advertise(interval_ms * 1000, _ble_adv_payload(name))
|
||||||
|
log("BLE advertising as", name)
|
||||||
|
except Exception as exc:
|
||||||
|
log("BLE active; advertising failed:", exc)
|
||||||
|
return ble
|
||||||
|
|
||||||
|
|
||||||
|
def setup_uart(env):
|
||||||
|
if not as_bool(env.get("UART_ENABLED"), False):
|
||||||
|
log("UART disabled")
|
||||||
|
return None
|
||||||
|
from machine import UART, Pin
|
||||||
|
|
||||||
|
tx_raw = env.get("UART_TX_GPIO", "")
|
||||||
|
rx_raw = env.get("UART_RX_GPIO", "")
|
||||||
|
if tx_raw == "" or rx_raw == "":
|
||||||
|
raise RuntimeError("UART_ENABLED=true requires UART_TX_GPIO and UART_RX_GPIO")
|
||||||
|
|
||||||
|
parity_text = env.get("UART_PARITY", "none").strip().lower()
|
||||||
|
parity = None
|
||||||
|
if parity_text in ("even", "0"):
|
||||||
|
parity = 0
|
||||||
|
elif parity_text in ("odd", "1"):
|
||||||
|
parity = 1
|
||||||
|
|
||||||
|
uart_id = as_int(env.get("UART_ID"), 1)
|
||||||
|
baud = as_int(env.get("UART_BAUD"), 115200)
|
||||||
|
uart = UART(
|
||||||
|
uart_id,
|
||||||
|
baudrate=baud,
|
||||||
|
bits=as_int(env.get("UART_BITS"), 8),
|
||||||
|
parity=parity,
|
||||||
|
stop=as_int(env.get("UART_STOP_BITS"), 1),
|
||||||
|
tx=Pin(as_int(tx_raw)),
|
||||||
|
rx=Pin(as_int(rx_raw)),
|
||||||
|
)
|
||||||
|
log("UART%d: %d baud TX=GPIO%s RX=GPIO%s" % (uart_id, baud, tx_raw, rx_raw))
|
||||||
|
return uart
|
||||||
|
|
||||||
|
|
||||||
|
def detect_provider(repo_url, configured="auto"):
|
||||||
|
provider = (configured or "auto").strip().lower()
|
||||||
|
if provider != "auto":
|
||||||
|
return provider
|
||||||
|
value = (repo_url or "").lower()
|
||||||
|
if "github.com" in value:
|
||||||
|
return "github"
|
||||||
|
if "gitlab.com" in value:
|
||||||
|
return "gitlab"
|
||||||
|
if "bitbucket.org" in value:
|
||||||
|
return "bitbucket"
|
||||||
|
if "codeberg.org" in value:
|
||||||
|
return "forgejo"
|
||||||
|
return "gitea"
|
||||||
|
|
||||||
|
|
||||||
|
def clean_repo_url(url):
|
||||||
|
value = (url or "").strip().rstrip("/")
|
||||||
|
if value.endswith(".git"):
|
||||||
|
value = value[:-4]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def github_repo_parts(repo):
|
||||||
|
marker = "github.com/"
|
||||||
|
if marker not in repo:
|
||||||
|
raise ValueError("Invalid GitHub repository URL")
|
||||||
|
rest = repo.split(marker, 1)[1].strip("/")
|
||||||
|
parts = rest.split("/")
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise ValueError("Invalid GitHub repository URL")
|
||||||
|
return parts[0], parts[1]
|
||||||
|
|
||||||
|
|
||||||
|
def raw_url_for(env, path):
|
||||||
|
explicit_base = env.get("UPDATE_RAW_BASE_URL", "").strip()
|
||||||
|
if explicit_base:
|
||||||
|
return explicit_base.rstrip("/") + "/" + path.lstrip("/")
|
||||||
|
|
||||||
|
repo = clean_repo_url(env.get("UPDATE_REPO_URL", ""))
|
||||||
|
if not repo:
|
||||||
|
raise ValueError("Set UPDATE_REPO_URL or UPDATE_RAW_BASE_URL")
|
||||||
|
branch = env.get("UPDATE_BRANCH", "main")
|
||||||
|
provider = detect_provider(repo, env.get("UPDATE_PROVIDER", "auto"))
|
||||||
|
|
||||||
|
if provider == "github":
|
||||||
|
owner, name = github_repo_parts(repo)
|
||||||
|
return "https://api.github.com/repos/%s/%s/contents/%s?ref=%s" % (
|
||||||
|
owner, name, path.lstrip("/"), branch
|
||||||
|
)
|
||||||
|
if provider == "gitlab":
|
||||||
|
return "%s/-/raw/%s/%s" % (repo, branch, path.lstrip("/"))
|
||||||
|
if provider == "bitbucket":
|
||||||
|
return "%s/raw/%s/%s" % (repo, branch, path.lstrip("/"))
|
||||||
|
return "%s/raw/branch/%s/%s" % (repo, branch, path.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _basic_auth(username, password):
|
||||||
|
raw = ("%s:%s" % (username, password)).encode()
|
||||||
|
return binascii.b2a_base64(raw).decode().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers(env):
|
||||||
|
provider = detect_provider(env.get("UPDATE_REPO_URL", ""), env.get("UPDATE_PROVIDER", "auto"))
|
||||||
|
mode = env.get("UPDATE_AUTH_MODE", "none").strip().lower()
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
# GitHub Contents API raw response support.
|
||||||
|
if provider == "github":
|
||||||
|
headers["Accept"] = "application/vnd.github.raw+json"
|
||||||
|
|
||||||
|
if mode in ("", "none", "public"):
|
||||||
|
return headers
|
||||||
|
|
||||||
|
username = env.get("UPDATE_USERNAME", "")
|
||||||
|
password = env.get("UPDATE_PASSWORD", "")
|
||||||
|
token = env.get("UPDATE_TOKEN", "")
|
||||||
|
|
||||||
|
if mode == "auto":
|
||||||
|
if username or password:
|
||||||
|
headers["Authorization"] = "Basic " + _basic_auth(username, password)
|
||||||
|
elif token:
|
||||||
|
if provider in ("gitea", "forgejo"):
|
||||||
|
headers["Authorization"] = "token " + token
|
||||||
|
elif provider == "gitlab":
|
||||||
|
headers["PRIVATE-TOKEN"] = token
|
||||||
|
else:
|
||||||
|
headers["Authorization"] = "Bearer " + token
|
||||||
|
return headers
|
||||||
|
|
||||||
|
if mode == "basic":
|
||||||
|
headers["Authorization"] = "Basic " + _basic_auth(username, password)
|
||||||
|
elif mode == "bearer":
|
||||||
|
headers["Authorization"] = "Bearer " + token
|
||||||
|
elif mode == "token":
|
||||||
|
header = env.get("UPDATE_AUTH_HEADER", "Authorization")
|
||||||
|
prefix = env.get("UPDATE_AUTH_PREFIX", "")
|
||||||
|
if not prefix:
|
||||||
|
prefix = "token" if provider in ("gitea", "forgejo") else "Bearer"
|
||||||
|
headers[header] = (prefix + " " + token).strip()
|
||||||
|
elif mode == "header":
|
||||||
|
header = env.get("UPDATE_AUTH_HEADER", "Authorization")
|
||||||
|
prefix = env.get("UPDATE_AUTH_PREFIX", "")
|
||||||
|
headers[header] = ((prefix + " ") if prefix else "") + token
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown UPDATE_AUTH_MODE=" + mode)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def http_get(url, headers, timeout_s):
|
||||||
|
if url.startswith("http://") and not as_bool(_GLOBAL_ENV.get("UPDATE_ALLOW_HTTP"), False):
|
||||||
|
raise RuntimeError("Plain HTTP OTA blocked; use HTTPS or UPDATE_ALLOW_HTTP=true")
|
||||||
|
try:
|
||||||
|
import urequests as requests
|
||||||
|
except ImportError:
|
||||||
|
import requests
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, timeout=timeout_s)
|
||||||
|
except TypeError:
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
code = getattr(response, "status_code", 200)
|
||||||
|
if code >= 400:
|
||||||
|
try:
|
||||||
|
response.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError("HTTP %s: %s" % (code, url))
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_text(url, headers, timeout_s):
|
||||||
|
response = http_get(url, headers, timeout_s)
|
||||||
|
try:
|
||||||
|
return response.text
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
response.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_file(url, path, headers, timeout_s, max_bytes):
|
||||||
|
response = http_get(url, headers, timeout_s)
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
raw = getattr(response, "raw", None)
|
||||||
|
if raw is not None and hasattr(raw, "read"):
|
||||||
|
while True:
|
||||||
|
chunk = raw.read(1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if max_bytes and total > max_bytes:
|
||||||
|
raise RuntimeError("download exceeds UPDATE_MAX_FILE_BYTES")
|
||||||
|
f.write(chunk)
|
||||||
|
else:
|
||||||
|
data = response.content
|
||||||
|
total = len(data)
|
||||||
|
if max_bytes and total > max_bytes:
|
||||||
|
raise RuntimeError("download exceeds UPDATE_MAX_FILE_BYTES")
|
||||||
|
f.write(data)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
response.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateManager:
|
||||||
|
def __init__(self, env, wifi, activity):
|
||||||
|
self.env = env
|
||||||
|
self.wifi = wifi
|
||||||
|
self.activity = activity
|
||||||
|
self._next_due_ms = None
|
||||||
|
self._stopped = False
|
||||||
|
|
||||||
|
def enabled(self):
|
||||||
|
return as_bool(self.env.get("UPDATE_ENABLED"), False) and self.wifi.enabled()
|
||||||
|
|
||||||
|
def interval_seconds(self):
|
||||||
|
return max(1, as_int(self.env.get("UPDATE_CHECK_INTERVAL_HOURS"), 24)) * 3600
|
||||||
|
|
||||||
|
def idle_seconds(self):
|
||||||
|
return max(0, as_int(self.env.get("UPDATE_MIN_IDLE_SECONDS"), 300))
|
||||||
|
|
||||||
|
def busy_retry_seconds(self):
|
||||||
|
return max(60, as_int(self.env.get("UPDATE_BUSY_RETRY_SECONDS"), 900))
|
||||||
|
|
||||||
|
def boot_delay_seconds(self):
|
||||||
|
return max(0, as_int(self.env.get("UPDATE_BOOT_DELAY_SECONDS"), 180))
|
||||||
|
|
||||||
|
def _schedule_in(self, seconds):
|
||||||
|
self._next_due_ms = time.ticks_add(time.ticks_ms(), int(seconds * 1000))
|
||||||
|
|
||||||
|
def _seconds_until_due(self):
|
||||||
|
if self._next_due_ms is None:
|
||||||
|
return 0
|
||||||
|
return max(0, time.ticks_diff(self._next_due_ms, time.ticks_ms()) // 1000)
|
||||||
|
|
||||||
|
def _manifest_url(self):
|
||||||
|
explicit = self.env.get("UPDATE_MANIFEST_URL", "").strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
return raw_url_for(self.env, self.env.get("UPDATE_MANIFEST_PATH", DEFAULT_MANIFEST_PATH))
|
||||||
|
|
||||||
|
def _source_url(self, entry):
|
||||||
|
return entry.get("url") or raw_url_for(self.env, entry["source"])
|
||||||
|
|
||||||
|
def _state(self):
|
||||||
|
return read_json(STATE_FILE, {}) or {}
|
||||||
|
|
||||||
|
def _write_check_state(self, manifest=None):
|
||||||
|
state = self._state()
|
||||||
|
if epoch_is_valid():
|
||||||
|
state["last_check_epoch"] = time.time()
|
||||||
|
if manifest:
|
||||||
|
state["last_seen_release"] = manifest.get("release", "")
|
||||||
|
write_json_atomic(STATE_FILE, state)
|
||||||
|
|
||||||
|
def _boot_check_needed(self):
|
||||||
|
if not as_bool(self.env.get("UPDATE_CHECK_ON_BOOT"), True):
|
||||||
|
return False
|
||||||
|
state = self._state()
|
||||||
|
last = state.get("last_check_epoch", 0)
|
||||||
|
if epoch_is_valid() and last:
|
||||||
|
return (time.time() - last) >= self.interval_seconds()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def initialize_schedule(self):
|
||||||
|
if not self.enabled():
|
||||||
|
return
|
||||||
|
if self._boot_check_needed():
|
||||||
|
self._schedule_in(self.boot_delay_seconds())
|
||||||
|
else:
|
||||||
|
self._schedule_in(self.interval_seconds())
|
||||||
|
|
||||||
|
def _files_needing_update(self, manifest):
|
||||||
|
changed = []
|
||||||
|
for entry in manifest.get("files", []):
|
||||||
|
if entry.get("enabled", True) is False:
|
||||||
|
continue
|
||||||
|
target = entry.get("target")
|
||||||
|
expected = entry.get("sha256", "").lower()
|
||||||
|
if not target or not expected:
|
||||||
|
raise ValueError("manifest file entries require target and sha256")
|
||||||
|
if not exists(target) or sha256_file(target) != expected:
|
||||||
|
changed.append(entry)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def _stage(self, entries, headers):
|
||||||
|
remove_tree_best_effort(STAGE_DIR)
|
||||||
|
mkdir_p(STAGE_DIR)
|
||||||
|
timeout_s = max(5, as_int(self.env.get("UPDATE_HTTP_TIMEOUT_SECONDS"), 20))
|
||||||
|
max_bytes = max(1024, as_int(self.env.get("UPDATE_MAX_FILE_BYTES"), 262144))
|
||||||
|
staged = []
|
||||||
|
for index, entry in enumerate(entries):
|
||||||
|
path = STAGE_DIR + "/%03d.bin" % index
|
||||||
|
log("downloading", entry["source"], "->", entry["target"])
|
||||||
|
fetch_file(self._source_url(entry), path, headers, timeout_s, max_bytes)
|
||||||
|
actual = sha256_file(path)
|
||||||
|
expected = entry["sha256"].lower()
|
||||||
|
if actual != expected:
|
||||||
|
raise RuntimeError("SHA-256 mismatch for %s" % entry["source"])
|
||||||
|
staged.append({
|
||||||
|
"stage": path,
|
||||||
|
"target": entry["target"],
|
||||||
|
"backup": entry["target"] + ".bak",
|
||||||
|
})
|
||||||
|
return staged
|
||||||
|
|
||||||
|
def _commit(self, staged, release):
|
||||||
|
pending = {"release": release, "commit_complete": False, "files": staged}
|
||||||
|
write_json_atomic(PENDING_FILE, pending)
|
||||||
|
for item in staged:
|
||||||
|
target = item["target"]
|
||||||
|
backup = item["backup"]
|
||||||
|
stage = item["stage"]
|
||||||
|
mkdir_p(dirname(target))
|
||||||
|
remove(backup)
|
||||||
|
if exists(target):
|
||||||
|
os.rename(target, backup)
|
||||||
|
os.rename(stage, target)
|
||||||
|
pending["commit_complete"] = True
|
||||||
|
write_json_atomic(PENDING_FILE, pending)
|
||||||
|
|
||||||
|
def check_now(self):
|
||||||
|
if not self.enabled():
|
||||||
|
return False
|
||||||
|
self.wifi.connect()
|
||||||
|
timeout_s = max(5, as_int(self.env.get("UPDATE_HTTP_TIMEOUT_SECONDS"), 20))
|
||||||
|
headers = auth_headers(self.env)
|
||||||
|
url = self._manifest_url()
|
||||||
|
log("checking manifest:", url)
|
||||||
|
manifest = json.loads(fetch_text(url, headers, timeout_s))
|
||||||
|
if manifest.get("schema", 1) != 1:
|
||||||
|
raise RuntimeError("unsupported manifest schema")
|
||||||
|
|
||||||
|
changed = self._files_needing_update(manifest)
|
||||||
|
self._write_check_state(manifest)
|
||||||
|
if not changed:
|
||||||
|
log("firmware current:", manifest.get("release", "unknown"))
|
||||||
|
self.wifi.disconnect_if_allowed()
|
||||||
|
return False
|
||||||
|
|
||||||
|
log("%d changed file(s); staging update" % len(changed))
|
||||||
|
staged = self._stage(changed, headers)
|
||||||
|
self._commit(staged, manifest.get("release", ""))
|
||||||
|
log("OTA committed; rebooting")
|
||||||
|
self.wifi.disconnect_if_allowed()
|
||||||
|
time.sleep_ms(250)
|
||||||
|
import machine
|
||||||
|
machine.reset()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def service(self):
|
||||||
|
"""
|
||||||
|
Energy-conscious scheduler.
|
||||||
|
|
||||||
|
Default remote traffic is ONE manifest request per 24 hours, not a
|
||||||
|
five-second poll. If the check becomes due while the device is active,
|
||||||
|
this sleeps locally for UPDATE_BUSY_RETRY_SECONDS before reconsidering.
|
||||||
|
"""
|
||||||
|
if not self.enabled():
|
||||||
|
return
|
||||||
|
self.initialize_schedule()
|
||||||
|
|
||||||
|
while not self._stopped:
|
||||||
|
wait_s = self._seconds_until_due()
|
||||||
|
if wait_s > 0:
|
||||||
|
await asyncio.sleep(wait_s)
|
||||||
|
|
||||||
|
if not self.activity.idle_for(self.idle_seconds()):
|
||||||
|
# If it is simply not idle *long enough* yet, wake at the
|
||||||
|
# earliest point the idle threshold could be met. If an
|
||||||
|
# explicit busy lock is active, use the longer configured
|
||||||
|
# busy retry. Both are local sleeps; neither hits the repo.
|
||||||
|
if self.activity.busy():
|
||||||
|
defer_s = self.busy_retry_seconds()
|
||||||
|
else:
|
||||||
|
idle_left_ms = max(0, self.idle_seconds() * 1000 - self.activity.idle_ms())
|
||||||
|
defer_s = max(60, (idle_left_ms + 999) // 1000)
|
||||||
|
log("update due but device active/not-idle; deferring", defer_s, "seconds")
|
||||||
|
self._schedule_in(defer_s)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.activity.begin()
|
||||||
|
self.check_now()
|
||||||
|
except Exception as exc:
|
||||||
|
log("update check failed:", exc)
|
||||||
|
self._schedule_in(max(300, as_int(self.env.get("UPDATE_FAILURE_RETRY_SECONDS"), 3600)))
|
||||||
|
finally:
|
||||||
|
self.activity.end()
|
||||||
|
self.wifi.disconnect_if_allowed()
|
||||||
|
|
||||||
|
if self._seconds_until_due() == 0:
|
||||||
|
self._schedule_in(self.interval_seconds())
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stopped = True
|
||||||
|
|
||||||
|
|
||||||
|
def clear_pending_after_healthy_start():
|
||||||
|
pending = read_json(PENDING_FILE, None)
|
||||||
|
if not pending or not pending.get("commit_complete", False):
|
||||||
|
return
|
||||||
|
for item in pending.get("files", []):
|
||||||
|
backup = item.get("backup")
|
||||||
|
if backup:
|
||||||
|
remove(backup)
|
||||||
|
remove(PENDING_FILE)
|
||||||
|
remove_tree_best_effort(STAGE_DIR)
|
||||||
|
log("new firmware accepted; rollback backups removed")
|
||||||
|
|
||||||
|
|
||||||
|
class SystemContext:
|
||||||
|
def __init__(self, env, activity, updater):
|
||||||
|
self.env = env
|
||||||
|
self.activity = activity
|
||||||
|
self.updater = updater
|
||||||
|
self._ready = False
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
if not self._ready:
|
||||||
|
clear_pending_after_healthy_start()
|
||||||
|
self._ready = True
|
||||||
|
|
||||||
|
def reboot(self):
|
||||||
|
import machine
|
||||||
|
machine.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def load_application():
|
||||||
|
from app import main as app_main
|
||||||
|
return app_main
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async():
|
||||||
|
global _GLOBAL_ENV
|
||||||
|
env = load_env()
|
||||||
|
_GLOBAL_ENV = env
|
||||||
|
if as_bool(env.get("PRINT_CONFIG_ON_BOOT"), False):
|
||||||
|
safe_config_log(env)
|
||||||
|
|
||||||
|
activity = ActivityTracker()
|
||||||
|
wifi = WiFiManager(env)
|
||||||
|
|
||||||
|
if wifi.enabled() and as_bool(env.get("WIFI_CONNECT_ON_BOOT"), True):
|
||||||
|
try:
|
||||||
|
wifi.connect()
|
||||||
|
except Exception as exc:
|
||||||
|
if as_bool(env.get("WIFI_REQUIRED"), False):
|
||||||
|
raise
|
||||||
|
log("Wi-Fi unavailable; continuing:", exc)
|
||||||
|
|
||||||
|
ble = setup_ble(env)
|
||||||
|
uart = setup_uart(env)
|
||||||
|
updater = UpdateManager(env, wifi, activity)
|
||||||
|
system = SystemContext(env, activity, updater)
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"env": env,
|
||||||
|
"wifi": wifi,
|
||||||
|
"ble": ble,
|
||||||
|
"uart": uart,
|
||||||
|
"activity": activity,
|
||||||
|
"updater": updater,
|
||||||
|
"system": system,
|
||||||
|
}
|
||||||
|
|
||||||
|
app = load_application()
|
||||||
|
|
||||||
|
# Optional setup hook. If a newly installed app fails here, boot.py can
|
||||||
|
# restore the previous launcher/app files.
|
||||||
|
if hasattr(app, "setup"):
|
||||||
|
result = app.setup(ctx)
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
await result
|
||||||
|
else:
|
||||||
|
system.ready()
|
||||||
|
|
||||||
|
if updater.enabled():
|
||||||
|
asyncio.create_task(updater.service())
|
||||||
|
|
||||||
|
result = app.main(ctx)
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
await result
|
||||||
|
else:
|
||||||
|
log("synchronous main() returned; async main() is recommended")
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
asyncio.run(run_async())
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
asyncio.new_event_loop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
26
firmware/manifest.json
Normal file
26
firmware/manifest.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"release": "0.1.1-probe.1",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"source": "firmware/launcher.py",
|
||||||
|
"target": "/launcher.py",
|
||||||
|
"sha256": "f8ef1bba8e298877b39427870a10ea4784db33a943a5e10040bf652b4866cd2f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "firmware/app/__init__.py",
|
||||||
|
"target": "/app/__init__.py",
|
||||||
|
"sha256": "01fedc22f12a680534e37cb3c0ae5a89deb7af5a30514170ec7b7a193a7a121c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "firmware/app/digital_probe.py",
|
||||||
|
"target": "/app/digital_probe.py",
|
||||||
|
"sha256": "3d34db776bb39b5ca3a6628df2c73b07079687eaf84dc4d5c97a149064dfe409"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "firmware/app/main.py",
|
||||||
|
"target": "/app/main.py",
|
||||||
|
"sha256": "5b287d984df266007df4014ece8e323339c0da594f4c88bcf4a6da1ff067a16f"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
45
tools/build_manifest.py
Normal file
45
tools/build_manifest.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regenerate firmware/manifest.json SHA-256 entries."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
OUT = ROOT / "firmware" / "manifest.json"
|
||||||
|
FILES = [
|
||||||
|
("firmware/launcher.py", "/launcher.py"),
|
||||||
|
("firmware/app/__init__.py", "/app/__init__.py"),
|
||||||
|
("firmware/app/digital_probe.py", "/app/digital_probe.py"),
|
||||||
|
("firmware/app/main.py", "/app/main.py"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path):
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
release = sys.argv[1] if len(sys.argv) > 1 else "dev"
|
||||||
|
entries = []
|
||||||
|
for source, target in FILES:
|
||||||
|
entries.append({
|
||||||
|
"source": source,
|
||||||
|
"target": target,
|
||||||
|
"sha256": sha256(ROOT / source),
|
||||||
|
})
|
||||||
|
OUT.write_text(json.dumps({
|
||||||
|
"schema": 1,
|
||||||
|
"release": release,
|
||||||
|
"files": entries,
|
||||||
|
}, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print("wrote", OUT)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue
Block a user