Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
472f13260f | ||
|
|
468dc0dc0d | ||
|
|
7e14e270df |
@@ -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
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user