Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e50529343 | ||
|
|
b988799fb3 | ||
|
|
b8fd8f4ad4 | ||
|
|
7ea5c9a4fb |
@@ -1,5 +1,20 @@
|
||||
menu "ESPresso Frame Configuration"
|
||||
|
||||
config FRAME_BOARD_NAME
|
||||
string "Board variant name, reported to the server"
|
||||
default "devkit"
|
||||
help
|
||||
Sent as the X-Frame-Board request header on every
|
||||
GET /frame/config poll, so the server can learn which board
|
||||
this device is and automatically fetch the right OTA build
|
||||
from a configured Gitea repo's releases -- no manual "which
|
||||
board" picker in the web UI. Must match one of the asset
|
||||
names .gitea/workflows/firmware-release-build.yml publishes
|
||||
(firmware-<name>.bin): "devkit" (this default, for the
|
||||
plain ESP32-C6-DevKitC-1 build) or "xiao" (set via
|
||||
sdkconfig.xiao for the Seeed XIAO ESP32-C6 build -- see
|
||||
build_for_board.sh).
|
||||
|
||||
config FRAME_XIAO_ANTENNA_INIT
|
||||
bool "Select onboard antenna on Seeed XIAO ESP32-C6 (RF switch init)"
|
||||
default n
|
||||
|
||||
@@ -342,9 +342,13 @@ static bool json_extract_string(const char *json, const char *key, char *out, si
|
||||
/* GETs the server's /frame/config -- doubles as both the reachability
|
||||
* check (any completed HTTP response means the socket-level connection
|
||||
* succeeded), the source of the server-configurable refresh interval,
|
||||
* and (via the X-Frame-Version request header / firmware_version
|
||||
* response field) the device's OTA update check -- piggybacked on a
|
||||
* request already made every wake, no extra round trip. */
|
||||
* and (via the X-Frame-Version/X-Frame-Board request headers and
|
||||
* firmware_version response field) the device's OTA update check --
|
||||
* piggybacked on a request already made every wake, no extra round
|
||||
* trip. X-Frame-Board lets the server learn which board this device is
|
||||
* (CONFIG_FRAME_BOARD_NAME) so it can pick the right Gitea release
|
||||
* asset itself, instead of a user manually selecting a board in the
|
||||
* web UI. */
|
||||
static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
{
|
||||
frame_server_config_t result = {
|
||||
@@ -364,6 +368,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
esp_http_client_set_header(client, "X-Frame-Version", esp_app_get_description()->version);
|
||||
esp_http_client_set_header(client, "X-Frame-Board", CONFIG_FRAME_BOARD_NAME);
|
||||
|
||||
esp_err_t err = esp_http_client_open(client, 0);
|
||||
if (err != ESP_OK) {
|
||||
|
||||
@@ -8,6 +8,8 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_xiao.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions_xiao.csv"
|
||||
|
||||
CONFIG_FRAME_BOARD_NAME="xiao"
|
||||
|
||||
# Powers the XIAO's RF switch and selects its onboard antenna -- without
|
||||
# this the softAP/STA radio doesn't reliably reach the antenna at all.
|
||||
# See the Kconfig help text (FRAME_XIAO_ANTENNA_INIT) for why.
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.1.1
|
||||
1.1.2
|
||||
|
||||
+28
-17
@@ -48,14 +48,17 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
|
||||
builds both supported boards and publishes them as release assets
|
||||
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt`
|
||||
changes on `main`. In the web UI's Settings form, set **Firmware Gitea
|
||||
repo URL** to that repo (e.g. `https://git.example.com/owner/repo`) and
|
||||
pick the frame's **board**; if the repo is private, also set
|
||||
`GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`. The
|
||||
server then periodically checks for a newer release and either shows
|
||||
an "Update frame" button or, with **Automatically apply updates**
|
||||
checked, stages it itself -- either way the frame only actually
|
||||
updates on its own next wake (see `POST /api/firmware` above).
|
||||
changes on `main`. In the web UI's "Firmware update" card, set the
|
||||
**Gitea repo URL** to that repo (e.g. `https://git.example.com/owner/repo`);
|
||||
if the repo is private, also set `GITEA_FIRMWARE_TOKEN` (a read-only
|
||||
PAT) in `docker-compose.yml`. Which board's build to fetch is learned
|
||||
from the frame itself (its `X-Frame-Board` header, `CONFIG_FRAME_BOARD_NAME`
|
||||
on the firmware side) -- nothing to pick by hand, though the frame
|
||||
does need to have checked in at least once first. The server then
|
||||
periodically checks for a newer release and either shows an "Update
|
||||
frame" button or, with **Automatically apply updates** checked,
|
||||
stages it itself -- either way the frame only actually updates on its
|
||||
own next wake (see `POST /api/firmware` above).
|
||||
|
||||
## Endpoints
|
||||
|
||||
@@ -117,7 +120,11 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
device compares it against its own running version
|
||||
(`esp_app_get_description()->version`, sent as an `X-Frame-Version`
|
||||
request header, stored as `device_firmware_version`) to decide whether
|
||||
to OTA
|
||||
to OTA. The device also sends an `X-Frame-Board` header
|
||||
(`CONFIG_FRAME_BOARD_NAME`, e.g. `"xiao"`), stored as
|
||||
`device_board_variant` -- how the Gitea auto-update feature below
|
||||
learns which board to fetch a release for, instead of a user picking
|
||||
it
|
||||
- `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... |
|
||||
null, "location_line2": ... | null, "taken_at": ... | null}` for the
|
||||
current photo (same idempotent current-photo semantics as
|
||||
@@ -170,16 +177,20 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
been uploaded yet
|
||||
- `GET /api/firmware/check` -- throttled (`gitea_releases.UPDATE_CHECK_INTERVAL_S`,
|
||||
15 min) check of the configured Gitea repo's latest release for the
|
||||
frame's board variant. `{"enabled": false}` if no repo URL is
|
||||
configured; otherwise `{"enabled": true, "latest_version": "1.2.3" | null,
|
||||
"staged_version": "1.2.2" | null, "update_available": bool}`. If
|
||||
"Automatically apply updates" is on and a newer release is found, this
|
||||
call also stages it immediately (same effect as a manual upload) --
|
||||
otherwise the web UI shows an "Update frame" button
|
||||
frame's board variant (learned from the device, see `device_board_variant`
|
||||
below -- not user-configured). `{"enabled": false}` if no repo URL is
|
||||
configured; otherwise `{"enabled": true, "board": "xiao" | null,
|
||||
"latest_version": "1.2.3" | null, "staged_version": "1.2.2" | null,
|
||||
"update_available": bool}`. `update_available` stays false until the
|
||||
board is known, regardless of what Gitea has. If "Automatically apply
|
||||
updates" is on and a newer release is found, this call also stages it
|
||||
immediately (same effect as a manual upload) -- otherwise the web UI
|
||||
shows an "Update frame" button
|
||||
- `POST /api/firmware/apply-latest` -- the "Update frame" button: pulls
|
||||
and stages the latest Gitea release right now, bypassing the check
|
||||
throttle. 404 if no repo is configured, has no releases, or the latest
|
||||
release has no asset for the configured board variant
|
||||
throttle. 400 if no repo is configured or no device has checked in
|
||||
yet (board unknown); 404 if the repo has no releases, or the latest
|
||||
release has no asset for the frame's board
|
||||
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...],
|
||||
"device": {"last_seen": ts | null, "overdue": bool,
|
||||
"firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null,
|
||||
|
||||
@@ -91,21 +91,24 @@ class FrameConfig(BaseModel):
|
||||
battery_log: list = []
|
||||
|
||||
# Device liveness/telemetry: last_seen is touched by every /frame/*
|
||||
# request; device_firmware_version comes from the X-Frame-Version
|
||||
# header the device sends with its config poll.
|
||||
# request; device_firmware_version/device_board_variant come from the
|
||||
# X-Frame-Version/X-Frame-Board headers the device sends with its
|
||||
# config poll (CONFIG_FRAME_BOARD_NAME on the firmware side).
|
||||
last_seen: float = 0.0
|
||||
device_firmware_version: str = ""
|
||||
device_board_variant: str = "" # "" until a device has ever checked in
|
||||
# Version parsed out of the most recently uploaded OTA image
|
||||
# (POST /api/firmware); "" = none uploaded yet.
|
||||
firmware_available_version: str = ""
|
||||
|
||||
# Gitea-hosted firmware auto-update (see app/gitea_releases.py).
|
||||
# repo_url empty = feature off, no Gitea calls made at all.
|
||||
# board_variant picks which release asset to pull -- must match one of
|
||||
# the names .gitea/workflows/firmware-release-build.yml publishes
|
||||
# repo_url empty = feature off, no Gitea calls made at all. Which
|
||||
# release asset to pull is learned from the device itself
|
||||
# (device_board_variant below, from its X-Frame-Board header) rather
|
||||
# than picked by the user -- must match one of the names
|
||||
# .gitea/workflows/firmware-release-build.yml publishes
|
||||
# (firmware-<board_variant>.bin).
|
||||
firmware_update_repo_url: str = "" # e.g. "https://git.example.com/owner/repo"
|
||||
firmware_board_variant: str = "xiao" # "xiao" or "devkit"
|
||||
firmware_auto_update: bool = False # pull+stage a newer release with no button click
|
||||
# Optional Gitea PAT (read-only access is enough) for a private repo's
|
||||
# releases; blank is fine for a public repo. GITEA_FIRMWARE_TOKEN env
|
||||
|
||||
+30
-13
@@ -207,10 +207,14 @@ def frame_config(request: Request):
|
||||
reachability check. Always returns 200 with current settings
|
||||
(defaults if nothing's been saved yet) -- no Immich-configured gate,
|
||||
since this doubles as the "is the server up" signal. Also captures
|
||||
the device's running firmware version (X-Frame-Version header) and
|
||||
advertises the uploaded OTA image's version, so the device's update
|
||||
check costs zero extra round trips."""
|
||||
the device's running firmware version and board variant (X-Frame-
|
||||
Version/X-Frame-Board headers -- the latter is how the Gitea
|
||||
auto-update feature learns which release asset to fetch, instead of
|
||||
a user picking it in the web UI) and advertises the uploaded OTA
|
||||
image's version, so the device's update check costs zero extra
|
||||
round trips."""
|
||||
reported_version = request.headers.get("X-Frame-Version", "")
|
||||
reported_board = request.headers.get("X-Frame-Board", "")
|
||||
with config.locked():
|
||||
cfg = config.load()
|
||||
cfg.last_seen = time.time()
|
||||
@@ -221,6 +225,8 @@ def frame_config(request: Request):
|
||||
if cfg.device_firmware_version and reported_version != cfg.device_firmware_version:
|
||||
cfg.stats.ota_updates_applied += 1
|
||||
cfg.device_firmware_version = reported_version
|
||||
if reported_board:
|
||||
cfg.device_board_variant = reported_board
|
||||
config.save(cfg)
|
||||
return {
|
||||
"refresh_interval_s": _effective_refresh_interval_s(cfg),
|
||||
@@ -276,7 +282,6 @@ def api_config_save(
|
||||
quiet_hours_end: str = Form("07:00"),
|
||||
timezone: str = Form("UTC"),
|
||||
firmware_update_repo_url: str = Form(""),
|
||||
firmware_board_variant: str = Form("xiao"),
|
||||
firmware_auto_update: bool = Form(False),
|
||||
):
|
||||
# Immich URL/API key/Gitea token are env-var only (IMMICH_URL/
|
||||
@@ -309,7 +314,6 @@ def api_config_save(
|
||||
if timezone in ALL_TIMEZONES:
|
||||
cfg.timezone = timezone
|
||||
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
|
||||
cfg.firmware_board_variant = firmware_board_variant if firmware_board_variant in ("xiao", "devkit") else "xiao"
|
||||
cfg.firmware_auto_update = firmware_auto_update
|
||||
cfg.stats.config_saves += 1
|
||||
config.save(cfg)
|
||||
@@ -493,13 +497,19 @@ def _fetch_latest_release(cfg: config.FrameConfig) -> dict | None:
|
||||
def _apply_gitea_update(cfg: config.FrameConfig) -> str:
|
||||
"""Downloads the configured Gitea repo's latest release asset for this
|
||||
frame's board variant and stages it exactly like a manual
|
||||
POST /api/firmware upload would. Network I/O happens before the lock
|
||||
is taken, matching the load/mutate/save concurrency pattern used
|
||||
elsewhere (see config.locked())."""
|
||||
POST /api/firmware upload would. The board comes from the device
|
||||
itself (device_board_variant, learned from its X-Frame-Board header
|
||||
on GET /frame/config -- see frame_config()), not a user picker, so
|
||||
there's nothing to fetch until a device has checked in at least
|
||||
once. Network I/O happens before the lock is taken, matching the
|
||||
load/mutate/save concurrency pattern used elsewhere (see
|
||||
config.locked())."""
|
||||
if not cfg.device_board_variant:
|
||||
raise HTTPException(400, "No frame has checked in yet -- can't tell which board's build to fetch")
|
||||
release = _fetch_latest_release(cfg)
|
||||
if not release:
|
||||
raise HTTPException(404, "No releases found in the configured Gitea repo")
|
||||
asset_name = gitea_releases.asset_name_for_board(cfg.firmware_board_variant)
|
||||
asset_name = gitea_releases.asset_name_for_board(cfg.device_board_variant)
|
||||
asset_url = release["assets"].get(asset_name)
|
||||
if not asset_url:
|
||||
raise HTTPException(404, f"Latest release has no '{asset_name}' asset")
|
||||
@@ -524,7 +534,11 @@ def api_firmware_check():
|
||||
(gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads
|
||||
the release's tag name, not its binaries. If firmware_auto_update is
|
||||
on and a newer version is found, applies it immediately; otherwise
|
||||
just reports it so the web UI can offer the "Update frame" button."""
|
||||
just reports it so the web UI can offer the "Update frame" button.
|
||||
Applying (auto or manual) needs to know the frame's board, which is
|
||||
learned from the device's own X-Frame-Board header rather than
|
||||
picked by the user -- update_available stays false until a device
|
||||
has checked in at least once, regardless of what Gitea has."""
|
||||
cfg = config.load()
|
||||
if not cfg.firmware_update_repo_url:
|
||||
return {"enabled": False}
|
||||
@@ -543,9 +557,11 @@ def api_firmware_check():
|
||||
config.save(cfg)
|
||||
cfg = config.load()
|
||||
|
||||
update_available = bool(
|
||||
cfg.firmware_gitea_latest_version
|
||||
) and cfg.firmware_gitea_latest_version != cfg.firmware_available_version
|
||||
update_available = (
|
||||
bool(cfg.firmware_gitea_latest_version)
|
||||
and cfg.firmware_gitea_latest_version != cfg.firmware_available_version
|
||||
and bool(cfg.device_board_variant)
|
||||
)
|
||||
if update_available and cfg.firmware_auto_update:
|
||||
_apply_gitea_update(cfg)
|
||||
cfg = config.load()
|
||||
@@ -553,6 +569,7 @@ def api_firmware_check():
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"board": cfg.device_board_variant or None,
|
||||
"latest_version": cfg.firmware_gitea_latest_version or None,
|
||||
"staged_version": cfg.firmware_available_version or None,
|
||||
"update_available": update_available,
|
||||
|
||||
@@ -207,6 +207,12 @@
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button.btn-inline {
|
||||
margin-top: 0;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
|
||||
@@ -75,25 +75,6 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Firmware Gitea repo URL
|
||||
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
|
||||
value="{{ cfg.firmware_update_repo_url }}">
|
||||
</label>
|
||||
<label>Frame's board
|
||||
<select id="firmware_board_variant">
|
||||
<option value="xiao" {% if cfg.firmware_board_variant == "xiao" %}selected{% endif %}>Seeed XIAO ESP32-C6</option>
|
||||
<option value="devkit" {% if cfg.firmware_board_variant == "devkit" %}selected{% endif %}>ESP32-C6-DevKitC-1</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="firmware_auto_update" {% if cfg.firmware_auto_update %}checked{% endif %}>
|
||||
<label for="firmware_auto_update">Automatically apply updates</label>
|
||||
</div>
|
||||
<p class="sub" style="margin-top: 8px;">When a Gitea repo URL is set, the
|
||||
server periodically checks its releases for a newer build for the
|
||||
board above. With auto-apply off, an "Update frame" button appears
|
||||
below when one's found; with it on, the server stages the new
|
||||
build itself -- the frame still only updates on its own next wake.</p>
|
||||
<button type="button" class="secondary" id="load-albums">Load Albums</button>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
@@ -124,12 +105,31 @@
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Firmware update</h2>
|
||||
<p class="sub" id="firmware-board">
|
||||
{% if cfg.device_board_variant %}Detected board: {{ cfg.device_board_variant }}
|
||||
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
|
||||
</p>
|
||||
<p class="sub" id="firmware-available">
|
||||
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
|
||||
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
||||
</p>
|
||||
<input type="file" id="firmware-file" accept=".bin">
|
||||
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
||||
|
||||
<div id="firmware-repo-display" style="margin-top: 16px; {% if not cfg.firmware_update_repo_url %}display: none;{% endif %}">
|
||||
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ cfg.firmware_update_repo_url }}</code>
|
||||
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
|
||||
</p>
|
||||
</div>
|
||||
<label id="firmware-repo-edit" style="{% if cfg.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
|
||||
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
|
||||
value="{{ cfg.firmware_update_repo_url }}">
|
||||
</label>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="firmware_auto_update" {% if cfg.firmware_auto_update %}checked{% endif %}>
|
||||
<label for="firmware_auto_update">Automatically apply updates</label>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
||||
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
||||
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
||||
</section>
|
||||
@@ -167,7 +167,6 @@
|
||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
||||
timezone: document.getElementById('timezone').value || 'UTC',
|
||||
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
|
||||
firmware_board_variant: document.getElementById('firmware_board_variant').value,
|
||||
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
|
||||
});
|
||||
const resp = await fetch('/api/config', {
|
||||
@@ -215,6 +214,29 @@
|
||||
}
|
||||
});
|
||||
|
||||
function showRepoDisplayMode(url) {
|
||||
document.getElementById('firmware-repo-text').textContent = url;
|
||||
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
|
||||
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
|
||||
}
|
||||
|
||||
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
|
||||
document.getElementById('firmware-repo-display').style.display = 'none';
|
||||
document.getElementById('firmware-repo-edit').style.display = 'block';
|
||||
document.getElementById('firmware_update_repo_url').focus();
|
||||
});
|
||||
|
||||
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
|
||||
try {
|
||||
await saveConfig();
|
||||
showStatus(true, 'Saved.');
|
||||
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
|
||||
loadFirmwareCheck();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
let upcomingItems = [];
|
||||
|
||||
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
|
||||
@@ -522,19 +544,26 @@
|
||||
async function loadFirmwareCheck() {
|
||||
const statusEl = document.getElementById('firmware-gitea-status');
|
||||
const btn = document.getElementById('firmware-update-btn');
|
||||
const boardEl = document.getElementById('firmware-board');
|
||||
try {
|
||||
const resp = await fetch('/api/firmware/check');
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.board) {
|
||||
boardEl.textContent = `Detected board: ${data.board}`;
|
||||
}
|
||||
if (!data.enabled) {
|
||||
statusEl.style.display = 'none';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'block';
|
||||
if (data.update_available) {
|
||||
if (!data.board) {
|
||||
statusEl.textContent = "Waiting for the frame to check in before it can look up the right build.";
|
||||
btn.style.display = 'none';
|
||||
} else if (data.update_available) {
|
||||
statusEl.textContent = `Update available: v${data.latest_version}.`;
|
||||
btn.style.display = 'inline-block';
|
||||
} else if (data.latest_version) {
|
||||
|
||||
Reference in New Issue
Block a user