Compare commits

...
4 Commits
Author SHA1 Message Date
twisla 8aa2848c5f Merge pull request 'Updates: a download that breaks is carried on' (#100) from resume-download into main
CI / build (push) Successful in 1m4s
Site / build (push) Successful in 12s
2026-10-11 16:30:05 +00:00
twislaandClaude Opus 5.5 472f13260f Updates: a download that breaks is carried on
CI / build (pull_request) Successful in 2m6s
Site / build (pull_request) Successful in 11s
When the connection breaks during a release download (issue #54), the
device waits up to a minute for Wi-Fi, asks for the rest of the file
with a Range request and carries on: the slot and the hash so far are
kept. It gives up after three tries in a row that bring nothing.
Nothing is kept across a restart.

Carrying on asks for 64 KB free, not 80: between two connections of an
install 78 KB is free, and the first attempt on the device was refused
for that. update damage cut now breaks every connection after n bytes.

Tried on the device: v0.23.0 installed over four connections, and a
download that brought nothing given up with nothing switched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 18:26:39 +02:00
twislaandClaude Opus 5.5 468dc0dc0d Updates: when an answer is the rest of the same file, and how long to try
For carrying on a download that broke (issue #54): the head's ETag and
Content-Range are read, a 206 is checked to be the rest of the same
file, and a policy says how many tries a download is worth. Host-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 18:26:39 +02:00
twisla 7e14e270df Merge pull request 'Updates: a newer release installs by itself, after a question' (#99) from auto-update into main
CI / build (push) Successful in 1m5s
Site / build (push) Successful in 16s
2026-10-11 15:54:03 +00:00
16 changed files with 314 additions and 21 deletions
+2 -2
View File
@@ -104,7 +104,7 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**:
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update.
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. A download that breaks is carried on from where it was (issue #54): the device waits up to a minute for Wi-Fi, asks for the rest of the file, and gives up only after three tries in a row that bring nothing.
- **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question.
- **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device.
- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card.
@@ -234,7 +234,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
| `install <path>` | Update from SD with that `.ota` file, as Settings → System → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, break each connection of the next download after n bytes (it is carried on, issue #54) or damage one byte of it, run the daily check again |
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
+32 -1
View File
@@ -1,6 +1,6 @@
# R1 — Releases
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Not started: the Issues App (#4), release channels (#53), resuming a download (#54).
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Built, not released yet: carrying on a download that broke (#54). Not started: the Issues App (#4), release channels (#53).
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
@@ -156,6 +156,37 @@ With the setting on, `update pretend v0.22.0` and `update daily`:
- `/system/updates.log` got its line at each confirmed update.
- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys.
## Carrying on a download that broke (issue #54)
A release goes straight into the inactive slot (Q167), so a connection that broke meant starting again, and on a weak link a 2.5 MB file might never arrive whole.
### What the server gives (checked 2026-10-11)
A request with `Range: bytes=0-0` for a release's file gets `206`, `Content-Range: bytes 0-0/2510384` and a strong `ETag`.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q282 | **Carried on within the same install, in memory only.** The slot's write handle and the parser, with its hash so far, stay as they are while the connection is opened again with `Range: bytes=<where it was>-`. The parser never sees the break. |
| Q283 | **Nothing is kept across a restart** (the issue's second question): the slot cannot be reopened at an offset, so a download cut by a power-off starts over. |
| Q284 | **The answer must be the rest of the same file:** a `206` from that byte to the end, of the same total size, with the same `ETag` if both answers have one. The `ETag` goes in `If-Range`, so a file that changed comes back whole (a `200`), which is refused. What decides whether the whole is good is still the Update File's own signature and hash. |
| Q285 | **How long:** before each try, up to a minute for Wi-Fi to come back. Tries go on while each brings more bytes, 20 at most in one download; three in a row that bring nothing end it. |
| Q286 | **`update damage cut <n>`** now breaks each connection of the next download after n bytes, so both the carrying on and the giving up can be tried. |
### As built
- **`lib/release/src/resume.h`**, host-tested (5 tests with the head's new fields): `resumeRefusal`, `ifRangeFor`, `ResumePolicy`. `HttpHead` reads `ETag` and `Content-Range`.
- **`HttpsGet::openFrom`** asks for a range; **`GiteaSource`** in the Update Service does the waiting and the trying inside its `read()`.
- `update status` says how often the last download was carried on.
### Checked on the device (2026-10-11)
- **`update damage cut 800000`, then an install of v0.23.0** (2 510 384 bytes): it arrived over four connections, its hash matched, and the device restarted into it.
- **`update damage cut 0`:** three connections that brought nothing, then it gave up (`carried on 2 time(s)`), with the running slot still the one to boot.
- **Memory decided the first attempt.** Between two connections 78 KB was free, under the 80 KB a TLS connection asks for before it starts, so every try was refused and the download gave up. The install's own buffers (about 8 KB) are what is missing, and nothing else asks for memory while the screen is taken over: carrying on asks for 64 KB (the 52 KB peak and 12 KB). The lowest the heap went during the broken download was 20 KB, with a console client connected.
- Not checked: a real Wi-Fi drop in the middle (the wait for Wi-Fi to come back), and a file that changed on the server.
## One firmware: the Debug Console in every build (issue #68)
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
+11
View File
@@ -1,5 +1,7 @@
#include "http_head.h"
#include <cstdio>
#include <algorithm>
#include <cctype>
#include <cstdlib>
@@ -58,6 +60,15 @@ void HttpHeadParser::line() {
else if (name == "transfer-encoding") head_.chunked = lower(value).find("chunked") != std::string::npos;
else if (name == "location") head_.location = value;
else if (name == "content-type") head_.contentType = value;
else if (name == "etag") head_.etag = value;
else if (name == "content-range") {
long from = -1, to = -1, total = -1;
if (std::sscanf(lower(value).c_str(), "bytes %ld-%ld/%ld", &from, &to, &total) == 3 && from >= 0 && to >= from && total > to) {
head_.rangeFrom = from;
head_.rangeTo = to;
head_.rangeTotal = total;
}
}
}
size_t ChunkedDecoder::decode(const uint8_t* in, size_t len, uint8_t* out) {
+3
View File
@@ -12,6 +12,9 @@ struct HttpHead {
long contentLength = -1; // -1: not given
bool chunked = false;
std::string location, contentType;
std::string etag; // as sent, quotes included; "" if none
// "Content-Range: bytes 1000-2999/3000", the answer to a range request; -1: not given.
long rangeFrom = -1, rangeTo = -1, rangeTotal = -1;
};
class HttpHeadParser {
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <string>
#include "http_head.h"
namespace roro::release {
// Carrying on a download that broke (issue #54): the connection is opened again with
// "Range: bytes=<from>-", and what was already received stays where it is. The Update File's own
// hash and signature are what decide whether the whole is good; these checks only keep the device
// from carrying on with something that plainly is not the rest of the same file.
// The validator to send as "If-Range", so a file that changed comes back whole (a 200) instead of
// in part: only a strong ETag may be used for that. "" for none.
inline std::string ifRangeFor(const std::string& etag) {
return etag.size() >= 2 && etag.front() == '"' && etag.back() == '"' ? etag : "";
}
// Is this answer the rest of the file, from byte `from` of `total`? "" if so, or why not, in
// words for the screen. `etag` is what the first answer gave ("" if nothing).
inline std::string resumeRefusal(const HttpHead& head, long from, long total, const std::string& etag) {
if (head.status == 200) return "The file changed on the server";
if (head.status != 206) return "The server answered " + std::to_string(head.status);
if (head.chunked || head.rangeFrom != from) return "The server sent another part of the file";
if (head.rangeTotal != total || head.rangeTo != total - 1 || head.contentLength != total - from) return "The file changed size on the server";
if (!etag.empty() && !head.etag.empty() && head.etag != etag) return "The file changed on the server";
return "";
}
// How long to keep trying. A break that came after new bytes is the link being poor, and is
// worth another try; breaks with nothing in between mean it is not coming back.
class ResumePolicy {
public:
static constexpr int kMaxResumes = 20; // in one download
static constexpr int kMaxStuck = 3; // in a row without a byte
// The connection broke with `received` bytes of the file here: try again?
bool again(long received) {
stuck_ = received > last_ ? 0 : stuck_ + 1;
last_ = received;
if (resumes_ >= kMaxResumes || stuck_ >= kMaxStuck) return false;
resumes_++;
return true;
}
int resumes() const { return resumes_; }
private:
long last_ = 0;
int resumes_ = 0, stuck_ = 0;
};
} // namespace roro::release
+1 -1
View File
@@ -46,7 +46,7 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**:
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update.
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. A download that breaks is carried on from where it was (issue #54): the device waits up to a minute for Wi-Fi, asks for the rest of the file, and gives up only after three tries in a row that bring nothing.
- **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question.
- **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device.
- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card.
+1 -1
View File
@@ -100,7 +100,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
| `install <path>` | Update from SD with that `.ota` file, as Settings → System → Firmware does |
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, break each connection of the next download after n bytes (it is carried on, issue #54) or damage one byte of it, run the daily check again |
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
+3 -2
View File
@@ -83,7 +83,7 @@ An update that goes wrong is the case you most want to rehearse, and the firmwar
update status # what the device runs, what failed here before, the daily check, heap
update check | update list # look at the server: the latest release, or the last ten
update pretend v0.9.0 # take the running version to be v0.9.0: the latest release now counts as "new"
update damage cut 50000 # the next download is cut after 50000 bytes
update damage cut 800000 # each connection of the next download breaks after 800000 bytes
update damage flip 100000 # ...or has the byte at offset 100000 damaged
update install v0.11.0 # try the install
update probe git.twis.la # is that server's certificate accepted? (the two ISRG roots only)
@@ -91,7 +91,8 @@ update daily # forget today's daily check: it runs again
update pretend off # back to the real version
```
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a cut or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`.
- **A download that breaks is carried on:** with `damage cut 800000` a 2.5 MB release comes in four connections, each asking for the rest of the file, and installs; `update status` then says how often it was carried on. With `damage cut 0` no connection brings anything, and it gives up after three tries.
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a download that gave up or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`.
- **An undamaged install really installs** the release, into the other slot, and the device restarts into it. Your build stays in the slot it was in until the next update overwrites it, and the console's setting and token are untouched: the release has the console too.
- The server is read by the device itself, with Wi-Fi up; the download is one TLS connection, about 52 KB of heap at its peak, so IRC steps aside (see [the memory limit](/howto/not-enough-memory/)). `status: heap` in the stream shows it happen.
+32 -1
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/R1.md"
tag = "R1"
+++
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Not started: the Issues App (#4), release channels (#53), resuming a download (#54).
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Built, not released yet: carrying on a download that broke (#54). Not started: the Issues App (#4), release channels (#53).
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
@@ -164,6 +164,37 @@ With the setting on, `update pretend v0.22.0` and `update daily`:
- `/system/updates.log` got its line at each confirmed update.
- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys.
## Carrying on a download that broke (issue #54)
A release goes straight into the inactive slot (Q167), so a connection that broke meant starting again, and on a weak link a 2.5 MB file might never arrive whole.
### What the server gives (checked 2026-10-11)
A request with `Range: bytes=0-0` for a release's file gets `206`, `Content-Range: bytes 0-0/2510384` and a strong `ETag`.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q282 | **Carried on within the same install, in memory only.** The slot's write handle and the parser, with its hash so far, stay as they are while the connection is opened again with `Range: bytes=<where it was>-`. The parser never sees the break. |
| Q283 | **Nothing is kept across a restart** (the issue's second question): the slot cannot be reopened at an offset, so a download cut by a power-off starts over. |
| Q284 | **The answer must be the rest of the same file:** a `206` from that byte to the end, of the same total size, with the same `ETag` if both answers have one. The `ETag` goes in `If-Range`, so a file that changed comes back whole (a `200`), which is refused. What decides whether the whole is good is still the Update File's own signature and hash. |
| Q285 | **How long:** before each try, up to a minute for Wi-Fi to come back. Tries go on while each brings more bytes, 20 at most in one download; three in a row that bring nothing end it. |
| Q286 | **`update damage cut <n>`** now breaks each connection of the next download after n bytes, so both the carrying on and the giving up can be tried. |
### As built
- **`lib/release/src/resume.h`**, host-tested (5 tests with the head's new fields): `resumeRefusal`, `ifRangeFor`, `ResumePolicy`. `HttpHead` reads `ETag` and `Content-Range`.
- **`HttpsGet::openFrom`** asks for a range; **`GiteaSource`** in the Update Service does the waiting and the trying inside its `read()`.
- `update status` says how often the last download was carried on.
### Checked on the device (2026-10-11)
- **`update damage cut 800000`, then an install of v0.23.0** (2 510 384 bytes): it arrived over four connections, its hash matched, and the device restarted into it.
- **`update damage cut 0`:** three connections that brought nothing, then it gave up (`carried on 2 time(s)`), with the running slot still the one to boot.
- **Memory decided the first attempt.** Between two connections 78 KB was free, under the 80 KB a TLS connection asks for before it starts, so every try was refused and the download gave up. The install's own buffers (about 8 KB) are what is missing, and nothing else asks for memory while the screen is taken over: carrying on asks for 64 KB (the 52 KB peak and 12 KB). The lowest the heap went during the broken download was 20 KB, with a console client connected.
- Not checked: a real Wi-Fi drop in the middle (the wait for Wi-Fi to come back), and a file that changed on the server.
## One firmware: the Debug Console in every build (issue #68)
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
+2
View File
@@ -19,6 +19,8 @@ The page shows the **version** running, its **status**, the address to **push up
An install needs Wi-Fi if it is a download. The new firmware is checked before anything is written (the signature after the first 160 bytes) and again at the end (the image's hash). Then the device restarts. It will **wait up to 60 seconds** if you are typing, so that a restart never eats a note.
A download that **breaks** (the Wi-Fi drops, the link is poor) is not started over: the device waits up to a minute for Wi-Fi to come back, asks the server for the rest of the file, and carries on from where it was, as often as it takes while each try brings something more. It gives up after three tries in a row that bring nothing, and then nothing is changed. If the device is switched off in between, the download starts from the beginning next time.
## Check for updates
**Settings → System → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs **nothing** by itself unless you ask for that (below), and it does not announce a version that already failed and rolled back on this device.
+3 -1
View File
@@ -579,6 +579,7 @@ static void updateCommand(const String& args) {
console.printf("update: installing by itself %s%s%s\n", settings.getBool(Setting::InstallUpdates) ? "on" : "off",
autoUpdate.waiting() ? ", waiting for a quiet moment to offer " : autoUpdate.asking() ? ", asking about " : "",
autoUpdate.waiting() || autoUpdate.asking() ? autoUpdate.tag().c_str() : "");
console.printf("update: the last download was carried on %d time(s) after a break\n", update->lastResumes());
console.printf("update: gitea %s %s\n", g.status() == GiteaReleases::Status::Done ? "done" : g.status() == GiteaReleases::Status::Failed ? "failed" : g.status() == GiteaReleases::Status::Busy ? "busy" : "never asked", g.error().c_str());
printReleases(false);
} else if (args.startsWith("install ")) {
@@ -594,7 +595,8 @@ static void updateCommand(const String& args) {
} else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download
long n = args.substring(args.lastIndexOf(' ') + 1).toInt();
args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n);
console.printf("update: the next download will be %s at byte %ld\n", args.startsWith("damage cut") ? "cut" : "damaged", n);
if (args.startsWith("damage cut")) console.printf("update: each connection of the next download will break after %ld bytes\n", n);
else console.printf("update: the next download will be damaged at byte %ld\n", n);
} else if (args == "daily") { // forget today's check: the daily one runs again at once, if it may
update->forgetToday();
+13 -3
View File
@@ -27,9 +27,20 @@ std::string whyNotConnected(NetworkClientSecure& tls, const std::string& host) {
} // namespace
std::string HttpsGet::open(const std::string& host, const std::string& path, const char* accept) {
std::string why = request(host, path, accept, "", kNeedFree);
if (!why.empty()) return why;
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
return "";
}
std::string HttpsGet::openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange) {
return request(host, path, accept, "Range: bytes=" + std::to_string(from) + "-\r\n" + (ifRange.empty() ? "" : "If-Range: " + ifRange + "\r\n"), kNeedFreeToCarryOn);
}
std::string HttpsGet::request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree) {
close();
if (time(nullptr) < kClockSetAfter) return "The clock isn't set: can't check certificates";
if (esp_get_free_heap_size() < kNeedFree) return "Not enough memory for a secure connection";
if (esp_get_free_heap_size() < needFree) return "Not enough memory for a secure connection";
tls_.setCACert(kTrustedRootsPem);
tls_.setTimeout(15);
@@ -38,7 +49,7 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
raw_.reset(new (std::nothrow) uint8_t[kRaw]);
if (!raw_) return "Not enough memory";
tls_.print(("GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUser-Agent: roro9stack/" + versionString() +
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n")
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\n" + more + "Connection: close\r\n\r\n")
.c_str());
release::HttpHeadParser parser;
@@ -50,7 +61,6 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
if (parser.complete() && used < static_cast<size_t>(n)) early_.assign(reinterpret_cast<const char*>(raw_.get()) + used, n - used);
}
head_ = parser.head();
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
left_ = head_.chunked ? -1 : head_.contentLength;
return "";
}
+11
View File
@@ -20,12 +20,22 @@ class HttpsGet {
// checking the certificate); with Q86's 20 KB to spare, it starts with at least this much free.
static constexpr size_t kNeedFree = 80 * 1024;
// Carrying on a download (issue #54) happens in the middle of an install, whose own buffers
// (about 8 KB, measured: 78 KB free between two connections) are already taken out of what is
// free, and with the screen taken over nothing else will ask for memory: the peak and 12 KB.
static constexpr size_t kNeedFreeToCarryOn = 64 * 1024;
explicit HttpsGet(net::User user) : tls_(user) {}
~HttpsGet() { close(); }
// Connects and reads the head. "" when a 200 is on its way, or why not, in words for the screen.
std::string open(const std::string& host, const std::string& path, const char* accept);
// The same, for the rest of a file from byte `from` (issue #54): asks for that range, with
// `ifRange` (an ETag, or "") so that a file that changed comes whole. The answer, a 206 or
// not, is for the caller to judge with head(): "" here only says a head was read.
std::string openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange);
long contentLength() const { return head_.contentLength; } // -1: not known
const release::HttpHead& head() const { return head_; }
// Body bytes into `buf`: how many, 0 at the end, -1 when the connection broke or stalled
// before the end.
@@ -37,6 +47,7 @@ class HttpsGet {
static constexpr uint32_t kStallMs = 10000;
int readRaw(uint8_t* into, size_t len); // from the connection, waiting up to kStallMs
std::string request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree);
Counted<NetworkClientSecure> tls_;
release::HttpHead head_;
+53 -9
View File
@@ -10,6 +10,7 @@
#include <ctime>
#include "http_head.h"
#include "resume.h"
#include "platform/https_get.h"
#include "platform/ota_device.h"
#include "platform/system_info.h"
@@ -64,21 +65,61 @@ class FileSource : public UpdateSource {
File& f_;
};
// A release being downloaded from Gitea: the same bytes a push or a card would give.
// A release being downloaded from Gitea: the same bytes a push or a card would give. When the
// connection breaks, it is opened again for the rest of the file (issue #54): what was written
// to the slot stays, and the parser never sees the break.
class GiteaSource : public UpdateSource {
public:
GiteaSource(HttpsGet& get, long cutAfter, long flipAt) : get_(get), cut_(cutAfter), flip_(flipAt) {}
static constexpr uint32_t kWifiWaitMs = 60000; // for Wi-Fi to come back, before each try
static constexpr uint32_t kPauseMs = 2000;
GiteaSource(HttpsGet& get, const release::Url& url, WifiService& wifi, long cutAfter, long flipAt)
: get_(get), url_(url), wifi_(wifi), total_(get.contentLength()), etag_(get.head().etag), cut_(cutAfter), flip_(flipAt) {}
int read(uint8_t* buf, size_t len) override {
if (cut_ >= 0 && pos_ >= cut_) return -1; // Debug Builds: the connection "breaks" here
int n = get_.read(buf, len);
if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong"
if (n > 0) pos_ += n;
return n;
for (;;) {
// Debug: each connection "breaks" after cut_ bytes.
int n = cut_ >= 0 && pos_ - openedAt_ >= cut_ ? -1 : get_.read(buf, len);
if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong"
if (n > 0) pos_ += n;
if (n >= 0) return n;
if (!resume()) return -1;
}
}
int resumes() const { return policy_.resumes(); }
const std::string& why() const { return why_; } // why it gave up, when it did
private:
bool resume() {
// Without a length there is no knowing what "the rest" is.
while (total_ > 0 && pos_ < total_ && policy_.again(pos_)) {
get_.close();
delay(kPauseMs);
uint32_t since = millis();
while (wifi_.state() != WifiController::State::Connected && millis() - since < kWifiWaitMs) delay(250);
if (wifi_.state() != WifiController::State::Connected) {
why_ = "Wi-Fi did not come back";
continue;
}
why_ = get_.openFrom(url_.host, url_.path, "application/octet-stream", pos_, release::ifRangeFor(etag_));
if (!why_.empty()) continue; // no connection yet: another try, while the policy allows
why_ = release::resumeRefusal(get_.head(), pos_, total_, etag_);
if (!why_.empty()) return false; // it answered, and it is not the rest of this file
openedAt_ = pos_;
ESP_LOGW("update", "carrying on from byte %ld of %ld (try %d)", pos_, total_, policy_.resumes());
return true;
}
if (why_.empty()) why_ = "The download broke";
return false;
}
HttpsGet& get_;
long cut_, flip_, pos_ = 0;
release::Url url_;
WifiService& wifi_;
long total_;
std::string etag_, why_;
release::ResumePolicy policy_;
long cut_, flip_, pos_ = 0, openedAt_ = 0;
};
constexpr time_t kClockSetAfter = 1700000000; // anything earlier is the clock's default
@@ -363,9 +404,12 @@ void UpdateService::installFromGitea(const release::Release& r) {
notify("Update refused: " + why, NotificationLevel::Warning);
return;
}
GiteaSource source(get, damageCut_, damageFlip_);
GiteaSource source(get, url, wifi_, damageCut_, damageFlip_);
damageCut_ = damageFlip_ = -1;
install(source, "Gitea");
resumes_ = source.resumes();
// What the parser says after a break is only that the file was short: say why it broke.
if (phase_ != Phase::Installed && !source.why().empty()) notify("Download gave up: " + source.why(), NotificationLevel::Warning);
}
void UpdateService::taskEntry(void* self) { static_cast<UpdateService*>(self)->listen(); }
+2
View File
@@ -71,6 +71,7 @@ class UpdateService : public Service {
// (is its certificate accepted?), and a download cut short or with one byte flipped.
void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; }
std::string probeResult() const { return probeResult_; }
int lastResumes() const { return resumes_; } // how often the last download from Gitea was carried on (issue #54)
void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; }
void forgetToday() { store_.putInt("rel_day", 0); nextCheckMs_ = 0; announced_.clear(); } // the daily check runs at the next tick
@@ -115,6 +116,7 @@ class UpdateService : public Service {
uint32_t nextCheckMs_ = 90000; // not before the device has settled
std::string probeHost_, probePath_;
volatile long damageCut_ = -1, damageFlip_ = -1;
volatile int resumes_ = 0;
std::string probeResult_;
};
+92
View File
@@ -3,6 +3,7 @@
#include <string>
#include "http_head.h"
#include "resume.h"
using namespace roro::release;
@@ -42,6 +43,92 @@ void test_a_download_head() {
TEST_ASSERT_EQUAL_STRING("https://elsewhere.example/x", r.head().location.c_str());
}
void test_a_part_of_a_file() {
std::string head = "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 1000-2510383/2510384\r\n"
"ETag: \"ac948b3f\"\r\nContent-Length: 2509384\r\n\r\n";
HttpHeadParser p;
p.feed(head.data(), head.size());
TEST_ASSERT_EQUAL_INT(206, p.head().status);
TEST_ASSERT_EQUAL_INT(1000, p.head().rangeFrom);
TEST_ASSERT_EQUAL_INT(2510383, p.head().rangeTo);
TEST_ASSERT_EQUAL_INT(2510384, p.head().rangeTotal);
TEST_ASSERT_EQUAL_STRING("\"ac948b3f\"", p.head().etag.c_str());
// A range that makes no sense is no range.
for (const char* bad : {"bytes */2510384", "bytes 5-2/10", "bytes 0-10/10", "items 0-1/2", ""}) {
std::string h = std::string("HTTP/1.1 206 Partial Content\r\nContent-Range: ") + bad + "\r\n\r\n";
HttpHeadParser q;
q.feed(h.data(), h.size());
TEST_ASSERT_EQUAL_INT(-1, q.head().rangeFrom);
}
}
static HttpHead part(long from, long total, const char* etag = "\"abc\"") {
HttpHead h;
h.status = 206;
h.rangeFrom = from;
h.rangeTo = total - 1;
h.rangeTotal = total;
h.contentLength = total - from;
h.etag = etag;
return h;
}
void test_the_rest_of_the_same_file_is_taken() {
TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000), 1000, 5000, "\"abc\"").c_str());
TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000, ""), 1000, 5000, "\"abc\"").c_str()); // no ETag this time
TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000), 1000, 5000, "").c_str()); // none the first time
}
void test_anything_else_is_refused() {
HttpHead whole;
whole.status = 200; // If-Range did not match: the file is another one now
whole.contentLength = 5000;
TEST_ASSERT_EQUAL_STRING("The file changed on the server", resumeRefusal(whole, 1000, 5000, "\"abc\"").c_str());
HttpHead gone;
gone.status = 404;
TEST_ASSERT_EQUAL_STRING("The server answered 404", resumeRefusal(gone, 1000, 5000, "").c_str());
TEST_ASSERT_FALSE(resumeRefusal(part(0, 5000), 1000, 5000, "\"abc\"").empty()); // from the start
TEST_ASSERT_FALSE(resumeRefusal(part(1000, 6000), 1000, 5000, "\"abc\"").empty()); // another size
TEST_ASSERT_FALSE(resumeRefusal(part(1000, 5000, "\"xyz\""), 1000, 5000, "\"abc\"").empty());
HttpHead shortPart = part(1000, 5000);
shortPart.rangeTo = 2000; // not up to the end
TEST_ASSERT_FALSE(resumeRefusal(shortPart, 1000, 5000, "\"abc\"").empty());
HttpHead chunked = part(1000, 5000);
chunked.chunked = true;
TEST_ASSERT_FALSE(resumeRefusal(chunked, 1000, 5000, "\"abc\"").empty());
}
void test_only_a_strong_etag_goes_in_if_range() {
TEST_ASSERT_EQUAL_STRING("\"abc\"", ifRangeFor("\"abc\"").c_str());
TEST_ASSERT_EQUAL_STRING("", ifRangeFor("W/\"abc\"").c_str());
TEST_ASSERT_EQUAL_STRING("", ifRangeFor("").c_str());
}
void test_how_long_a_download_is_tried() {
ResumePolicy poor; // a poor link: each try brings some more
for (int i = 0; i < ResumePolicy::kMaxResumes; i++) TEST_ASSERT_TRUE(poor.again(1000 * (i + 1)));
TEST_ASSERT_FALSE(poor.again(1000000));
TEST_ASSERT_EQUAL_INT(ResumePolicy::kMaxResumes, poor.resumes());
ResumePolicy dead; // the link is gone: nothing more comes
TEST_ASSERT_TRUE(dead.again(5000));
TEST_ASSERT_TRUE(dead.again(5000));
TEST_ASSERT_TRUE(dead.again(5000));
TEST_ASSERT_FALSE(dead.again(5000));
ResumePolicy fromNothing; // not even a first byte
TEST_ASSERT_TRUE(fromNothing.again(0));
TEST_ASSERT_TRUE(fromNothing.again(0));
TEST_ASSERT_FALSE(fromNothing.again(0));
ResumePolicy recovering; // stuck twice, then bytes again: the count starts over
recovering.again(5000);
recovering.again(5000);
recovering.again(5000);
TEST_ASSERT_TRUE(recovering.again(9000));
TEST_ASSERT_TRUE(recovering.again(9000));
}
void test_a_head_that_is_not_http() {
HttpHeadParser p;
std::string junk = "\x16\x03\x01 not http at all\r\n\r\n";
@@ -114,6 +201,11 @@ int main() {
UNITY_BEGIN();
RUN_TEST(test_a_head_in_pieces);
RUN_TEST(test_a_download_head);
RUN_TEST(test_a_part_of_a_file);
RUN_TEST(test_the_rest_of_the_same_file_is_taken);
RUN_TEST(test_anything_else_is_refused);
RUN_TEST(test_only_a_strong_etag_goes_in_if_range);
RUN_TEST(test_how_long_a_download_is_tried);
RUN_TEST(test_a_head_that_is_not_http);
RUN_TEST(test_chunked_bodies);
RUN_TEST(test_urls);