Compare commits

...
8 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
twislaandClaude Opus 5.5 c3fd9909a1 Updates: a newer release installs by itself, after a question
CI / build (pull_request) Successful in 2m8s
Site / build (pull_request) Successful in 11s
With Settings > System > Install updates on By itself (issue #52), a
release found by the daily check is offered once no key was pressed for
2 minutes and nothing would be cut by a restart. The question goes over
whatever App is open, with Cancel and Accept, and installs after 30 s
without an answer. Each update that held, and each one undone, is a
dated line in /system/updates.log on the card.

Tried on the device: the question, Cancel, and an install of v0.23.0
left unanswered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:49:09 +02:00
twislaandClaude Opus 5.5 8ba0102910 Settings: Install updates, when asked or by itself
Off by default: what runs on the device changes only when its owner
said it may (issue #52).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:49:09 +02:00
twislaandClaude Opus 5.5 17651cb911 Updates: when a release may install by itself, as a state machine
Issue #52: once a newer release is known, wait for a quiet moment, ask
for 30 seconds, and install unless the answer is no. The caller says
what busy and idle are. Host-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-11 17:49:09 +02:00
twisla 5af695f43a Merge pull request 'GNSS App: a Compass, with Places and a level' (#98) from compass into main
CI / build (push) Successful in 1m6s
Site / build (push) Successful in 14s
2026-10-11 15:17:16 +00:00
29 changed files with 764 additions and 36 deletions
+3 -2
View File
@@ -104,9 +104,10 @@ 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.
The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed.
@@ -233,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 |
+64 -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**). Not started: the Issues App (#4), automatic installs (#52), 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.
@@ -124,6 +124,69 @@ The device looks at the project's Gitea for a newer release, says so, and instal
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
## Installing a release by itself (issue #52)
For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. |
| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) |
| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. |
| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. |
| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. |
| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. |
| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. |
### As built
- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are.
- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported.
- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up.
- `update status` says whether a release is waiting for its quiet moment.
### Checked on the device (2026-10-11)
With the setting on, `update pretend v0.22.0` and `update daily`:
- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed.
- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it.
- `/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 -1
View File
@@ -130,7 +130,7 @@ Settings had grown to 21 rows in one list. It now opens on five groups, each a s
| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher |
| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs |
| Network | Wi-Fi, VPN |
| System | Check for updates, Firmware, Debug Console, About |
| System | Check for updates, Install updates, Firmware, Debug Console, About |
- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows.
- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name.
+3
View File
@@ -48,6 +48,7 @@ const Member kRows[] = {
{3, {Row::Wifi, Kind::Page, "Wi-Fi"}},
{3, {Row::Vpn, Kind::Page, "VPN"}},
{4, {Row::CheckUpdates, Kind::Toggle, "Check for updates"}},
{4, {Row::InstallUpdates, Kind::Toggle, "Install updates"}},
{4, {Row::Firmware, Kind::Page, "Firmware"}},
{4, {Row::DebugConsole, Kind::Page, "Debug Console"}},
{4, {Row::About, Kind::Page, "About"}},
@@ -152,6 +153,7 @@ std::string SettingsMenu::value(int i) const {
case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off";
case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off";
case Row::CheckUpdates: return settings_.getBool(Setting::CheckUpdates) ? "Daily" : "Off";
case Row::InstallUpdates: return settings_.getBool(Setting::InstallUpdates) ? "By itself" : "When asked";
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
@@ -210,6 +212,7 @@ void SettingsMenu::toggle(int i) {
if (row(i) == Row::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled));
if (row(i) == Row::GnssQuiet) settings_.setBool(Setting::GnssQuietForLora, !settings_.getBool(Setting::GnssQuietForLora));
if (row(i) == Row::CheckUpdates) settings_.setBool(Setting::CheckUpdates, !settings_.getBool(Setting::CheckUpdates));
if (row(i) == Row::InstallUpdates) settings_.setBool(Setting::InstallUpdates, !settings_.getBool(Setting::InstallUpdates));
if (row(i) == Row::Coordinates) settings_.setBool(Setting::CoordinatesDms, !settings_.getBool(Setting::CoordinatesDms));
if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw));
}
+1 -1
View File
@@ -12,7 +12,7 @@ namespace roro {
// Rendering and navigation live in the App. Every index is into what is listed now.
class SettingsMenu {
public:
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About,
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, InstallUpdates, Firmware, Vpn, DebugConsole, About,
GroupDevice, GroupDisplay, GroupPosition, GroupNetwork, GroupSystem };
enum class Kind { Text, Choice, Toggle, Slider, Page, Group };
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <cstdint>
#include <string>
namespace roro::release {
// Installing a release without being asked to (issue #52): once a newer one is known, wait for a
// quiet moment (nothing recording, nothing written, no key for a while), then say so on the
// screen for 30 seconds. Unless someone answers no, it is installed. Host-tested; the caller says
// what "quiet" is and does the installing.
class AutoUpdate {
public:
static constexpr uint32_t kIdleMs = 120000; // no key for this long: nobody is using the device
static constexpr uint32_t kAskMs = 30000; // how long the question stays before it answers itself
enum class Action : uint8_t {
None,
Ask, // the question goes up
Dismiss, // the question comes down, nothing is installed
Install, // the question comes down, install tag()
};
// A newer release is known. A second offer while one is waiting or asked replaces its tag.
void offer(const std::string& tag) {
tag_ = tag;
if (state_ == State::Idle) state_ = State::Waiting;
}
// The setting went off, or the release is no longer wanted.
void withdraw() { withdrawn_ = true; }
// The user's answer to the question.
void answer(bool install) {
if (state_ != State::Asking) return;
answered_ = true;
accepted_ = install;
}
// Call often. `busy`: something is recording or being written, and a restart would cut it.
// `idleMs`: how long since the last key.
Action step(uint32_t nowMs, bool busy, uint32_t idleMs) {
bool withdrawn = withdrawn_;
withdrawn_ = false;
switch (state_) {
case State::Idle: return Action::None;
case State::Waiting:
if (withdrawn) return state_ = State::Idle, Action::None;
if (busy || idleMs < kIdleMs) return Action::None;
state_ = State::Asking;
askedMs_ = nowMs;
answered_ = false;
return Action::Ask;
case State::Asking:
if (withdrawn) return state_ = State::Idle, Action::Dismiss;
if (answered_) return state_ = State::Idle, accepted_ ? Action::Install : Action::Dismiss;
// Something started meanwhile (a Track from a console): the question goes, and comes back later.
if (busy) return state_ = State::Waiting, Action::Dismiss;
if (nowMs - askedMs_ >= kAskMs) return state_ = State::Idle, Action::Install;
return Action::None;
}
return Action::None;
}
bool waiting() const { return state_ == State::Waiting; }
bool asking() const { return state_ == State::Asking; }
const std::string& tag() const { return tag_; }
// Whole seconds until the question answers itself, rounded up.
int secondsLeft(uint32_t nowMs) const {
uint32_t gone = nowMs - askedMs_;
return !asking() || gone >= kAskMs ? 0 : static_cast<int>((kAskMs - gone + 999) / 1000);
}
private:
enum class State : uint8_t { Idle, Waiting, Asking };
State state_ = State::Idle;
std::string tag_;
uint32_t askedMs_ = 0;
bool answered_ = false, accepted_ = false, withdrawn_ = false;
};
} // namespace roro::release
+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
+6
View File
@@ -24,6 +24,12 @@ inline bool shouldAnnounce(const Release& latest, const std::string& running, co
return isNewer(latest, running) && latest.tag != failed && latest.tag != announced;
}
// Should the background check offer to install it by itself (issue #52)? Any newer release, each
// day it is still the newest; never a version that rolled back on this device.
inline bool shouldInstall(const Release& latest, const std::string& running, const std::string& failed) {
return isNewer(latest, running) && latest.tag != failed;
}
// Is `url` a download this device takes from the project's server, for this repository? Whatever
// the API says, the device only fetches from where it asked (a redirected or rewritten answer
// can't send it elsewhere).
+1
View File
@@ -56,6 +56,7 @@ const Definition kDefinitions[] = {
{"launcher_list", Kind::Bool, 0, nullptr, 0, 1}, // off: the grid
{"theme", Kind::Int, 0, nullptr, 0, 6}, // roro9stack's own; ui::kThemeCount of them
{"theme_light", Kind::Bool, 0, nullptr, 0, 1},
{"auto_install", Kind::Bool, 0, nullptr, 0, 1}, // off: what runs here changes only when someone says so
};
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition");
+1
View File
@@ -44,6 +44,7 @@ enum class Setting : uint8_t {
LauncherList, // bool: the Launcher is a list of names instead of a grid of icons (issue #9)
Theme, // int: the colour theme, an index into ui::palette (issue #10)
ThemeLight, // bool: its light side instead of its dark one
InstallUpdates, // bool: a newer release found by the daily check is installed without being asked for (issue #52)
Count
};
+2 -1
View File
@@ -46,9 +46,10 @@ 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.
The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed.
+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.
+64 -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**). Not started: the Issues App (#4), automatic installs (#52), 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.
@@ -132,6 +132,69 @@ The device looks at the project's Gitea for a newer release, says so, and instal
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
## Installing a release by itself (issue #52)
For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out.
### Decisions (2026-10-11)
| # | Decision |
|---|---|
| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. |
| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) |
| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. |
| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. |
| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. |
| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. |
| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. |
### As built
- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are.
- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported.
- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up.
- `update status` says whether a release is waiting for its quiet moment.
### Checked on the device (2026-10-11)
With the setting on, `update pretend v0.22.0` and `update daily`:
- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed.
- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it.
- `/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 -1
View File
@@ -138,7 +138,7 @@ Settings had grown to 21 rows in one list. It now opens on five groups, each a s
| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher |
| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs |
| Network | Wi-Fi, VPN |
| System | Check for updates, Firmware, Debug Console, About |
| System | Check for updates, Install updates, Firmware, Debug Console, About |
- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows.
- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name.
+1
View File
@@ -50,6 +50,7 @@ Inside a group, move with the arrows. On a toggle, a choice or a slider, left an
| Setting | What it is |
|---|---|
| **Check for updates** | Once a day, see [Updates](/guide/updates/) |
| **Install updates** | `When asked` (the default), or `By itself`: a newer release is installed in a quiet moment, after a question that answers itself in 30 seconds. See [Updates](/guide/updates/) |
| **Firmware** | The page described in [Updates](/guide/updates/) |
| **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you |
| **About** | The firmware version, the node number, battery, memory, uptime, clock and licence |
+15 -1
View File
@@ -19,9 +19,23 @@ 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, and it does not announce a version that already failed and rolled back on this device.
**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.
## Install updates by itself
**Settings → System → Install updates** is `When asked` by default. Set to `By itself`, a newer release found by the daily check is installed without your going to the Firmware page:
1. The device waits for a **quiet moment**: no key pressed for 2 minutes, and nothing a restart would cut short (a Track or a Capture recording, files being copied or received, an SSH session, a web share).
2. It then asks on the screen, over whatever is open, and lights the screen if it was off: `Update to v0.24.0?`, with a count from **30 seconds**.
3. **Cancel** (or <kbd>`</kbd>) leaves things as they are until the next day's check. **Accept**, or no answer when the count ends, downloads the release, installs it and restarts.
Everything else is as for an install you ask for: the signature is checked before anything is written, the new firmware runs on Probation, and a version that rolled back on this device is not offered again. It needs **Check for updates** to be on.
Each update that held, whoever started it, and each one that was undone, is written with its date in `/system/updates.log` on the SD card.
## Probation and Rollback
+72 -4
View File
@@ -20,6 +20,7 @@
#include "services/web_share.h"
#include "apps/gemini_app.h"
#include "apps/gnss_app.h"
#include "auto_update.h"
#include "apps/irc_app.h"
#include "apps/launcher_app.h"
#include "apps/lora_scanner_app.h"
@@ -81,6 +82,12 @@ static Screen screen;
static BatteryService* battery;
static StorageService* storageService;
static FileOps* fileOps;
static WebShare* webShare;
// Installing a release by itself (issue #52): see autoUpdateStep().
static release::AutoUpdate autoUpdate;
static std::unique_ptr<DialogModel> autoDialog;
static uint32_t lastKeyMs = 0;
static bool autoRedraw = false;
static std::vector<std::string> filesInUse(const std::string& path, bool folder);
static PowerService* power;
static ClockService* clockService;
@@ -238,7 +245,7 @@ void setup() {
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings, *storageService), icons::gnss});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService), icons::gemini});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService), icons::lora});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi)), icons::storage});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *(webShare = new WebShare(*storageService, *fileOps, *wifi))), icons::storage});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power), icons::notes});
sshService = new SshService(settings);
sshApp = new SshApp(*sshService, settings, *storageService);
@@ -569,6 +576,10 @@ static void updateCommand(const String& args) {
console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(),
update->failedVersion().empty() ? "none" : update->failedVersion().c_str(),
settings.getBool(Setting::CheckUpdates) ? "on" : "off", (unsigned)ESP.getFreeHeap());
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 ")) {
@@ -584,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();
@@ -849,8 +861,57 @@ static void screenshotKey() {
}
// Every key goes through here, from the keyboard or from a console's `key`.
// Installing a release by itself (issue #52, Settings > System > Install updates): once the daily
// check has found one, wait until nothing would be cut by a restart and no key was pressed for a
// while, then ask on the screen, over whatever is open. Left unanswered for 30 s, it is installed.
// Would a restart now cut something short?
static bool busyForRestart() {
return gnssService->tracking() || loraCapture->capturing() || upload.active() || fileOps->busy() ||
(scpService && scpService->busy()) || (sshService && sshService->active()) || (webShare && webShare->running()) ||
(noiseTest && noiseTest->running()) || update->phase() != UpdateService::Phase::Idle || update->onProbation() ||
update->giteaBusy();
}
static void autoUpdateStep(uint32_t now) {
static int shownSeconds = -1;
if (!settings.getBool(Setting::InstallUpdates) && (autoUpdate.waiting() || autoUpdate.asking())) autoUpdate.withdraw();
std::string tag;
if (update->takeOffer(tag)) autoUpdate.offer(tag);
if (autoDialog && autoDialog->result() != DialogModel::kPending) autoUpdate.answer(autoDialog->result() == 1);
switch (autoUpdate.step(now, busyForRestart(), now - lastKeyMs)) {
case release::AutoUpdate::Action::None: break;
case release::AutoUpdate::Action::Ask:
autoDialog.reset(new DialogModel({"Cancel", "Accept"}));
power->onNotification(now, now + release::AutoUpdate::kAskMs); // a screen that is off lights up for it
autoRedraw = true;
break;
case release::AutoUpdate::Action::Dismiss:
autoDialog.reset();
autoRedraw = true;
break;
case release::AutoUpdate::Action::Install: {
autoDialog.reset();
autoRedraw = true;
std::string why = update->requestInstall(autoUpdate.tag(), true);
if (!why.empty())
bus.publish(Event::withText(EventType::Notification, ("Update not installed: " + why).c_str(), static_cast<int32_t>(NotificationLevel::Warning)));
break;
}
}
int seconds = autoUpdate.asking() ? autoUpdate.secondsLeft(now) : -1;
if (seconds != shownSeconds) {
shownSeconds = seconds;
autoRedraw = true;
}
}
static void handleKey(const KeyEvent& e) {
if (e.key == Key::Screenshot) return screenshotKey();
if (autoDialog) { // the question takes every key until it is answered
autoDialog->onKey(e);
return;
}
apps->handleKey(e);
}
@@ -1534,6 +1595,7 @@ static void loopPass() {
printListingWhenReady();
M5Cardputer.update();
if (M5Cardputer.Keyboard.isChange()) {
lastKeyMs = now;
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
keyMapper.setTextEntry(apps->foreground().textEntryActive());
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
@@ -1549,6 +1611,7 @@ static void loopPass() {
bus.dispatch();
notifier->update(now);
apps->update(now);
autoUpdateStep(now);
if (now - lastLog > 10000) {
lastLog = now;
@@ -1594,10 +1657,15 @@ static void loopPass() {
screen.invalidate();
redrawAfterUpdate = true;
}
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) {
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate || autoRedraw) {
lastStatus = status;
redrawAfterUpdate = false;
screen.render(*apps, status, toast);
autoRedraw = false;
if (autoDialog) {
Screen::Question question{"Update to " + autoUpdate.tag() + "?",
"It installs in " + std::to_string(autoUpdate.secondsLeft(now)) + " s unless you cancel, and the device restarts.", autoDialog.get()};
screen.render(*apps, status, toast, &question);
} else screen.render(*apps, status, toast);
update->firstFrameDrawn();
}
}
+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_;
+93 -11
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
@@ -94,6 +135,26 @@ void UpdateService::notify(const std::string& text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level)));
}
// A line in the record of updates on the card (issue #52): the firmware can change without
// anyone looking, so what was installed and when is written down.
void UpdateService::record(const std::string& what) {
time_t now = time(nullptr);
char when[24] = "undated";
if (now >= kClockSetAfter) {
struct tm t;
gmtime_r(&now, &t);
strftime(when, sizeof when, "%Y-%m-%d %H:%M UTC", &t);
}
storage_.appendLine(kRecordPath, std::string(when) + " " + what);
}
bool UpdateService::takeOffer(std::string& tag) {
if (!offer_) return false;
offer_ = false;
tag = offered_;
return true;
}
std::string UpdateService::incomingVersion() const { return incoming_; }
void UpdateService::start() {
@@ -108,6 +169,8 @@ void UpdateService::start() {
store_.getString("ota_from", from);
if (!pending.empty() && pending != versionString()) {
notify("Update to " + pending + " failed, back on " + versionString(), NotificationLevel::Warning);
record(pending + " failed and was undone: back on " + versionString());
store_.putString("ota_auto", "");
store_.putString("ota_failed", pending); // not announced again until there's a newer one (Q168)
store_.putString("ota_pending", "");
}
@@ -142,6 +205,14 @@ void UpdateService::tick(uint32_t nowMs) {
store_.putString("ota_pending", "");
store_.putInt("ota_attempts", 0);
notify(std::string("Updated to ") + versionString(), NotificationLevel::Info);
{
std::string from, automatic;
store_.getString("ota_from", from);
store_.getString("ota_auto", automatic);
record(std::string(versionString()) + " installed" + (automatic == versionString() ? " by itself" : "") +
(from.empty() ? "" : ", from " + from));
store_.putString("ota_auto", "");
}
break;
case Probation::Verdict::RollBack:
// ota_pending still names this version: the previous firmware will report the failure.
@@ -215,12 +286,13 @@ std::string UpdateService::failedVersion() const {
return failed;
}
std::string UpdateService::requestInstall(const std::string& tag) {
std::string UpdateService::requestInstall(const std::string& tag, bool automatic) {
if (phase_ != Phase::Idle || giteaBusy()) return "Busy with something else";
if (wifi_.state() != WifiController::State::Connected) return "No Wi-Fi";
release::Release r;
if (!gitea_.find(tag, r)) return "Look for releases first";
if (!release::trustedAssetUrl(r.otaUrl)) return "That download isn't on the project's server";
store_.putString("ota_auto", automatic ? tag : "");
installTag_ = tag;
request_ = Request::Install;
return "";
@@ -281,7 +353,14 @@ void UpdateService::serve() {
time_t now = time(nullptr);
if (now >= kClockSetAfter) store_.putInt("rel_day", static_cast<int32_t>(now / 86400));
release::Release latest;
if (r == Request::BackgroundCheck && gitea_.latest(latest) &&
// Installing by itself (issue #52): offered at each daily check while it is the newest,
// so a "not now" lasts a day. The main loop asks on the screen when the moment is quiet.
if (r == Request::BackgroundCheck && gitea_.latest(latest) && settings_.getBool(Setting::InstallUpdates) &&
release::shouldInstall(latest, runningVersion(), failedVersion())) {
announced_ = latest.tag;
offered_ = latest.tag;
offer_ = true;
} else if (r == Request::BackgroundCheck && gitea_.latest(latest) &&
release::shouldAnnounce(latest, runningVersion(), failedVersion(), announced_)) {
announced_ = latest.tag;
notify(latest.tag + " is out: Settings > System > Firmware", NotificationLevel::Info); // 48 bytes at most
@@ -325,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(); }
+11 -2
View File
@@ -51,7 +51,12 @@ class UpdateService : public Service {
void requestList() { request_ = Request::List; }
// Downloads `tag` (a release known from the last check or list) into the inactive slot and
// installs it. "" when it started, or why not.
std::string requestInstall(const std::string& tag);
// `automatic`: nobody asked for it (issue #52), and the record of updates says so.
std::string requestInstall(const std::string& tag, bool automatic = false);
// A release the daily check found that may be installed without being asked for (the setting
// is on): true once for each find, with its tag. The main loop picks the moment.
bool takeOffer(std::string& tag);
static constexpr const char* kRecordPath = "/system/updates.log"; // what was installed, and when
bool giteaBusy() const { return request_ != Request::None || serving_; }
// Asked to make room for a TLS connection (R1, Q172): IRC steps aside while the Update Service
// talks to Gitea. Set by the main loop; `true` to step aside, `false` to come back.
@@ -66,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
@@ -85,6 +91,7 @@ class UpdateService : public Service {
void listen();
void install(class UpdateSource& source, const char* via);
void notify(const std::string& text, NotificationLevel level);
void record(const std::string& what);
KeyValueStore& store_;
WifiService& wifi_;
@@ -103,11 +110,13 @@ class UpdateService : public Service {
volatile Request request_ = Request::None;
volatile bool serving_ = false;
bool held_ = false; // IRC was asked to step aside, on this task
std::string installTag_, fakeVersion_, announced_;
std::string installTag_, fakeVersion_, announced_, offered_;
volatile bool offer_ = false;
bool dailyCheck_ = false;
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 -2
View File
@@ -12,12 +12,12 @@ bool Screen::begin() {
return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr;
}
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) {
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question) {
const auto& area = theme::kContent;
App& app = apps.foreground();
// An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed
// or a Toast or the help panel, drawn over it, has just gone.
bool overlay = apps.help().isOpen() || toast, retains = app.retainsContent();
bool overlay = apps.help().isOpen() || toast || question, retains = app.retainsContent();
if (overlayWas_ && !overlay) lost_ = true;
if (retains && &app == lastApp_ && !lost_) {
canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground);
@@ -31,6 +31,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
canvas_.setClipRect(area.x, area.y, area.w, area.h);
app.draw(canvas_);
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
if (question) widgets::dialog(canvas_, question->title, question->message, *question->dialog);
canvas_.clearClipRect();
widgets::statusBar(canvas_, status);
if (toast) widgets::toast(canvas_, *toast);
+6 -1
View File
@@ -10,7 +10,12 @@ namespace roro {
class Screen {
public:
bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour)
void render(AppManager& apps, const StatusInfo& status, const Toast* toast);
// A question the device itself asks, over whatever App is open (issue #52).
struct Question {
std::string title, message;
const DialogModel* dialog;
};
void render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question = nullptr);
// Takes over the screen while a Firmware Update is received or about to restart.
void renderUpdate(const std::string& title, const std::string& detail, int percent);
Canvas& canvas() { return canvas_; }
+137
View File
@@ -0,0 +1,137 @@
#include <unity.h>
#include "auto_update.h"
#include "update_check.h"
using namespace roro::release;
using Action = AutoUpdate::Action;
void setUp() {}
void tearDown() {}
namespace {
constexpr uint32_t kIdle = AutoUpdate::kIdleMs, kAsk = AutoUpdate::kAskMs;
Release release(const char* tag) {
Release r;
r.tag = tag;
r.otaUrl = "https://git.twis.la/twisla/roro9stack/releases/download/x/x.ota";
r.otaSize = 1;
return r;
}
} // namespace
void test_nothing_happens_without_an_offer() {
AutoUpdate a;
TEST_ASSERT_TRUE(a.step(1000, false, kIdle) == Action::None);
TEST_ASSERT_FALSE(a.waiting());
TEST_ASSERT_FALSE(a.asking());
}
void test_it_waits_for_a_quiet_moment() {
AutoUpdate a;
a.offer("v1.2.0");
TEST_ASSERT_TRUE(a.waiting());
TEST_ASSERT_TRUE(a.step(1000, false, kIdle - 1) == Action::None); // a key not long ago
TEST_ASSERT_TRUE(a.step(2000, true, kIdle * 10) == Action::None); // a Track is recording
TEST_ASSERT_TRUE(a.step(3000, false, kIdle) == Action::Ask);
TEST_ASSERT_TRUE(a.asking());
TEST_ASSERT_EQUAL_STRING("v1.2.0", a.tag().c_str());
}
void test_unanswered_it_installs_after_30_seconds() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(5000, false, kIdle);
TEST_ASSERT_EQUAL(30, a.secondsLeft(5000));
TEST_ASSERT_EQUAL(30, a.secondsLeft(5001));
TEST_ASSERT_EQUAL(1, a.secondsLeft(5000 + kAsk - 1));
TEST_ASSERT_TRUE(a.step(5000 + kAsk - 1, false, kIdle) == Action::None);
TEST_ASSERT_TRUE(a.step(5000 + kAsk, false, kIdle) == Action::Install);
TEST_ASSERT_FALSE(a.asking());
TEST_ASSERT_TRUE(a.step(5000 + kAsk + 1, false, kIdle) == Action::None); // once
}
void test_accept_installs_at_once() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(0, false, kIdle);
a.answer(true);
TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Install);
}
void test_cancel_drops_it_until_the_next_offer() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(0, false, kIdle);
a.answer(false);
TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Dismiss);
TEST_ASSERT_TRUE(a.step(kAsk * 2, false, kIdle * 2) == Action::None);
a.offer("v1.2.0"); // the next day's check
TEST_ASSERT_TRUE(a.step(kAsk * 3, false, kIdle) == Action::Ask);
}
void test_something_starting_takes_the_question_down() {
AutoUpdate a;
a.offer("v1.2.0");
a.step(0, false, kIdle);
TEST_ASSERT_TRUE(a.step(1000, true, kIdle) == Action::Dismiss);
TEST_ASSERT_TRUE(a.waiting());
TEST_ASSERT_TRUE(a.step(2000, true, kIdle) == Action::None);
TEST_ASSERT_TRUE(a.step(90000, false, kIdle) == Action::Ask); // and it comes back, with a full count
TEST_ASSERT_EQUAL(30, a.secondsLeft(90000));
}
void test_the_setting_going_off_withdraws_it() {
AutoUpdate a;
a.offer("v1.2.0");
a.withdraw();
TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::None);
TEST_ASSERT_FALSE(a.waiting());
a.offer("v1.2.0");
a.step(0, false, kIdle);
a.withdraw();
TEST_ASSERT_TRUE(a.step(10, false, kIdle) == Action::Dismiss);
TEST_ASSERT_FALSE(a.asking());
}
void test_an_answer_is_only_for_a_question() {
AutoUpdate a;
a.offer("v1.2.0");
a.answer(true); // nothing was asked yet
TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::Ask);
TEST_ASSERT_TRUE(a.step(1, false, kIdle) == Action::None);
}
void test_a_newer_offer_replaces_the_tag() {
AutoUpdate a;
a.offer("v1.2.0");
a.offer("v1.3.0");
a.step(0, false, kIdle);
TEST_ASSERT_EQUAL_STRING("v1.3.0", a.tag().c_str());
}
void test_what_is_offered() {
// Newer than what runs, and not a version that rolled back here.
TEST_ASSERT_TRUE(shouldInstall(release("v1.2.0"), "v1.1.0", ""));
TEST_ASSERT_FALSE(shouldInstall(release("v1.1.0"), "v1.1.0", ""));
TEST_ASSERT_FALSE(shouldInstall(release("v1.0.0"), "v1.1.0", ""));
TEST_ASSERT_FALSE(shouldInstall(release("v1.2.0"), "v1.1.0", "v1.2.0"));
Release noFile = release("v1.2.0");
noFile.otaUrl.clear();
TEST_ASSERT_FALSE(shouldInstall(noFile, "v1.1.0", ""));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_nothing_happens_without_an_offer);
RUN_TEST(test_it_waits_for_a_quiet_moment);
RUN_TEST(test_unanswered_it_installs_after_30_seconds);
RUN_TEST(test_accept_installs_at_once);
RUN_TEST(test_cancel_drops_it_until_the_next_offer);
RUN_TEST(test_something_starting_takes_the_question_down);
RUN_TEST(test_the_setting_going_off_withdraws_it);
RUN_TEST(test_an_answer_is_only_for_a_question);
RUN_TEST(test_a_newer_offer_replaces_the_tag);
RUN_TEST(test_what_is_offered);
return UNITY_END();
}
+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);
+12 -1
View File
@@ -67,7 +67,7 @@ void test_every_setting_is_in_one_group() {
total += f.menu.count();
f.menu.close();
}
TEST_ASSERT_EQUAL(21, total);
TEST_ASSERT_EQUAL(22, total);
int i = -1;
TEST_ASSERT_TRUE(f.menu.reveal(SettingsMenu::Row::Vpn, i));
TEST_ASSERT_EQUAL_STRING("Network", f.menu.title().c_str());
@@ -204,6 +204,16 @@ void test_pause_gnss_for_lora_is_off_by_default() {
// R1, Q165: the device looks for a newer release once a day. It installs nothing by itself, so it
// is on unless switched off.
void test_installing_updates_by_itself_is_off_by_default() {
Fixture f;
int row = f.row(SettingsMenu::Row::InstallUpdates);
TEST_ASSERT_EQUAL_STRING("Install updates", f.menu.label(row).c_str());
TEST_ASSERT_EQUAL_STRING("When asked", f.menu.value(row).c_str());
f.menu.toggle(row);
TEST_ASSERT_TRUE(f.settings.getBool(Setting::InstallUpdates));
TEST_ASSERT_EQUAL_STRING("By itself", f.menu.value(row).c_str());
}
void test_check_for_updates_is_on_by_default() {
Fixture f;
int row = f.row(SettingsMenu::Row::CheckUpdates);
@@ -245,6 +255,7 @@ int main() {
RUN_TEST(test_names_are_text_rows_with_their_byte_limits);
RUN_TEST(test_pause_gnss_for_lora_is_off_by_default);
RUN_TEST(test_check_for_updates_is_on_by_default);
RUN_TEST(test_installing_updates_by_itself_is_off_by_default);
RUN_TEST(test_gnss_and_coordinate_rows_toggle);
RUN_TEST(test_the_debug_console_is_a_page_that_says_off);
return UNITY_END();