Merge branch 's1': fixed IPv4 addresses, DNS and NTP servers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 00:48:41 +02:00
co-authored by Claude Opus 5.5
17 changed files with 1032 additions and 51 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ The Service that owns the Wi-Fi radio. It's always in exactly one mode: *Off*, *
_Avoid_: network manager _Avoid_: network manager
**Saved Network**: **Saved Network**:
A Wi-Fi network the device may join on its own (name, password). When several are in range, the strongest wins. A Wi-Fi network the device may join on its own: its name, its password, and how it gets its address, *Automatic* (DHCP) or *Fixed* (an address, a prefix and an optional gateway typed in Settings). When several are in range, the strongest wins.
_Avoid_: profile, known network _Avoid_: profile, known network
**IRC Service**: **IRC Service**:
+9 -1
View File
@@ -54,6 +54,12 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
**The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB. **The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB.
## Networks without DHCP
Each Saved Network gets its address automatically (DHCP) or has a Fixed one (docs/milestones/S1.md): in Settings > Wi-Fi, Enter on a network opens its page, where "IP address" switches between Automatic and Fixed, with an address, a prefix length (24 is 255.255.255.0) and an optional gateway. Switching to Fixed starts from what the network is giving the device at that moment. The setting is checked and applied when you leave the page. IPv4 only.
"DNS and NTP" on the same screen holds two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on every network with "Always use my DNS"; and two NTP servers (pool.ntp.org and time.cloudflare.com), used after any the network's DHCP offers. Enter on "Status" shows what's in use and where each value came from.
## Gemini ## Gemini
The Gemini App browses Geminispace (docs/milestones/G1.md): Tab and Shift+Tab pick a link, Enter follows it, Back returns (to where the page was scrolled), Space pages down, `g` types an address. Certificates are trusted on first use; a changed one stops the page and asks. The Gemini App browses Geminispace (docs/milestones/G1.md): Tab and Shift+Tab pick a link, Enter follows it, Back returns (to where the page was scrolled), Space pages down, `g` types an address. Certificates are trusted on first use; a changed one stops the page and asks.
@@ -75,6 +81,8 @@ The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **neve
| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) |
| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s |
| `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi add <ssid><TAB><password>` | Adds a Saved Network (so credentials stay out of the repo) |
| `wifi ip <ssid> dhcp` / `wifi ip <ssid> <address>/<prefix> [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Debug Builds: add `try <seconds>` to go back to the previous setting unless `wifi ip keep` follows |
| `wifi dns <a> [b]` / `wifi dns always on\|off` / `wifi ntp <a> [b]` | DNS servers (used on Fixed networks, or always), and NTP servers |
| `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.log`) | | `log <text>` | Appends a line to a test IRC Log (`/irc/dev/#test/<date>.log`) |
| `sd card` | What the SD card says it is: type, size, and its identity register (maker, name, revision, serial, date) | | `sd card` | What the SD card says it is: type, size, and its identity register (maker, name, revision, serial, date) |
| `sd list` | Lists the files of each Storage Clean-up category | | `sd list` | Lists the files of each Storage Clean-up category |
@@ -85,7 +93,7 @@ The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **neve
| `gemini trust <host> <port> <sha256>` | Pins a certificate by hand (the Gemini App asks when one changes) | | `gemini trust <host> <port> <sha256>` | Pins a certificate by hand (the Gemini App asks when one changes) |
| `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) | | `irc say <buffer> <text>` | Types into a Buffer, commands included (`irc say 0 /join #test`) |
| `irc dump` | Prints IRC status, memory, and the last lines of each Buffer | | `irc dump` | Prints IRC status, memory, and the last lines of each Buffer |
| `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap | | `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap, then the address, gateway, DNS and NTP servers in use and where each came from |
| `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states | | `info` | Firmware, uptime, last start reason, memory, Wi-Fi, the SD card and its write faults since boot, and both app slots with their versions and OTA states |
| `tasks` | FreeRTOS tasks: state, priority, lowest free stack, CPU share | | `tasks` | FreeRTOS tasks: state, priority, lowest free stack, CPU share |
| `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) |
+61
View File
@@ -0,0 +1,61 @@
# S1 — System basics
**Status:** in progress (branch `s1`). The SD driver fix shipped in v0.6.1 (issue #21, ADR 0007). Fixed IPv4 settings (issue #7) are done and checked on the device. The System Monitor (#11) hasn't started.
**Goal:** the device works on any network, the card can be trusted, and you can see what the system is doing. A side milestone, like G1.
## Fixed IPv4, DNS and NTP (issue #7)
Not every network has a DHCP server: a lab bench, a direct link to a router, a network where addresses are handed out by hand. Until now every Saved Network used DHCP, DNS always came from DHCP, and the NTP server was `pool.ntp.org`, hard-coded.
**IPv4 only.** IPv6 isn't part of this, now or as a planned follow-up.
### Decisions (design round 2026-10-05)
| # | Decision |
|---|---|
| Q105 | The IP setting is **per Saved Network**: *Automatic* (DHCP, as before) or *Fixed*, with its own address, prefix and gateway. New networks start Automatic. |
| Q106 | The subnet is entered as a **prefix length** (`24`), with the mask shown next to it. |
| Q107 | The **gateway is optional**: left empty, the device talks to its own subnet only. |
| Q108 | **DNS is global:** two servers in Settings, used on every Fixed network. On Automatic networks DHCP's DNS is used, unless **"Always use my DNS"** is on. |
| Q109 | DNS defaults: **9.9.9.9** (Quad9), then **1.1.1.1** (Cloudflare). |
| Q110 | **NTP is global:** two servers in Settings, names or addresses, defaulting to `pool.ntp.org` and `time.cloudflare.com`. NTP servers offered by DHCP are used first. GNSS still outranks NTP for the clock. |
| Q111 | What's typed is checked, host-tested in `lib/wifi`: an address is four numbers from 0 to 255; a prefix is 1 to 30; the address isn't the subnet's network or broadcast address; the gateway is inside the subnet and isn't the device's own address. Refusals say why. |
| Q112 | Addresses are typed in the line editor, limited to digits and dots. |
| Q113 | Enter on a Saved Network opens **its page** (IP, Address, Prefix, Gateway, Forget) instead of asking to forget it. Settings > Wi-Fi gains DNS servers, "Always use my DNS" and NTP servers. The Status row opens **connection details**: address, mask, gateway, DNS and NTP in use, and where each came from. |
| Q114 | A change applies **at once**: the network in use reconnects with the new settings. No automatic way back; the keyboard still works if Wi-Fi is cut. |
| Q115 | Console: `wifi status` shows address, gateway, DNS, NTP and their sources; `wifi ip <ssid> dhcp`, `wifi ip <ssid> <address>/<prefix> [gateway]`, `wifi dns <a> [b]`, `wifi ntp <a> [b]`. Debug Builds: `wifi ip … try 60` goes back to the previous setting after 60 s unless confirmed with `wifi ip keep`. |
| Q116 | Left out: checking whether the address is already taken, and per-network DNS. |
The SDK already allows 3 NTP servers and 3 DNS servers and can take NTP servers from DHCP (`CONFIG_LWIP_SNTP_MAX_SERVERS=3`, `CONFIG_LWIP_DHCP_GET_NTP_SRV=y`), so the framework isn't rebuilt for this.
### Done when
- A Saved Network set to Fixed joins with that address, mask and gateway, and the device reaches the internet (IRC, Gemini, NTP) through the DNS servers from Settings.
- Set back to Automatic, it gets its address from DHCP again.
- With "Always use my DNS" on, an Automatic network resolves through the servers from Settings.
- The NTP servers from Settings set the clock.
- Wrong entries are refused with a reason, in Settings and on the console.
- Connection details show what's in use and where it came from.
- Tested on `knbg-guests` with 10.39.39.12 (the device's DHCP lease) and 10.39.39.13 (free: the device is alone on that network).
### Measured (2026-10-05 and 06, on `knbg-guests`)
The network is 10.39.39.0/24, gateway 10.39.39.1; DHCP gives 10.39.39.1 as DNS and offers no NTP server.
- **Fixed 10.39.39.12/24** (the device's own lease) and **Fixed 10.39.39.13/24**, gateway 10.39.39.1: the device joins with that address, DNS is 9.9.9.9 and 1.1.1.1 from Settings, and a Gemini page loads (name resolution, routing, TLS). On .13, .12 no longer answers.
- **A wrong gateway** (10.39.39.254) on a 60 s trial: the device stops answering from another subnet, and comes back by itself with the previous setting.
- **Back to Automatic:** 10.39.39.12 by DHCP again, DNS 10.39.39.1 from DHCP.
- **"Always use my DNS"** on an Automatic network: DNS becomes 9.9.9.9 and 1.1.1.1; switched off, the device joins again and has DHCP's DNS back.
- **NTP:** `pool.ntp.org` answers; set to `time.cloudflare.com` alone, that one answers within 25 s.
- **Refusals**, on the console and in Settings: the network's own address, a gateway outside the subnet, a prefix of 31 or 99, 10.39.39.300, an unknown network, a DNS name where an address is needed, a host name with an underscore.
- **In Settings:** the network's page pre-fills Fixed with the address, prefix and gateway in use; leaving the page applies it; connection details show each value and where it came from.
- **Not tested:** NTP servers offered by DHCP (this network offers none), and a Fixed network with no gateway.
### Work breakdown
1. **IPv4 logic** (host-tested): parsing and formatting addresses, prefix and mask, the checks of Q111.
2. **Storage:** the IP setting in each Saved Network; DNS, "Always use my DNS" and NTP in Settings.
3. **Wi-Fi Service:** apply it when joining; DNS and NTP; `wifi status` and the console commands.
4. **Settings:** the network page, the DNS and NTP rows, connection details.
5. **Tests on the device**, recorded here.
+97
View File
@@ -0,0 +1,97 @@
#include "ipv4.h"
#include <cstdio>
namespace roro::net {
bool parseIpv4(const std::string& text, uint32_t& out) {
uint32_t value = 0;
int parts = 0, digits = 0, part = 0;
for (char c : text) {
if (c >= '0' && c <= '9') {
if (++digits > 3) return false;
part = part * 10 + (c - '0');
if (part > 255) return false;
} else if (c == '.') {
if (digits == 0 || ++parts > 3) return false;
value = value << 8 | part;
part = digits = 0;
} else {
return false;
}
}
if (digits == 0 || parts != 3) return false;
out = value << 8 | part;
return true;
}
std::string formatIpv4(uint32_t a) {
char s[16];
std::snprintf(s, sizeof s, "%u.%u.%u.%u", static_cast<unsigned>(a >> 24), static_cast<unsigned>(a >> 16 & 255),
static_cast<unsigned>(a >> 8 & 255), static_cast<unsigned>(a & 255));
return s;
}
uint32_t maskOf(int prefix) { return prefix <= 0 ? 0 : prefix >= 32 ? 0xFFFFFFFFu : ~0u << (32 - prefix); }
std::string checkFixed(const FixedIp& f) {
if (f.prefix < 1 || f.prefix > 30) return "The prefix must be 1 to 30";
if (f.address == 0) return "0.0.0.0 isn't an address a device can have";
uint32_t mask = maskOf(f.prefix), network = f.address & mask, broadcast = network | ~mask;
if (f.address == network) return formatIpv4(f.address) + " is the network's own address";
if (f.address == broadcast) return formatIpv4(f.address) + " is the broadcast address";
if (f.gateway == 0) return "";
if (f.gateway == f.address) return "The gateway can't be this device's address";
if ((f.gateway & mask) != network)
return "The gateway " + formatIpv4(f.gateway) + " isn't in " + formatIpv4(network) + "/" + std::to_string(f.prefix);
if (f.gateway == broadcast) return "The gateway " + formatIpv4(f.gateway) + " is the broadcast address";
if (f.gateway == network) return "The gateway " + formatIpv4(f.gateway) + " is the network's own address";
return "";
}
std::string parseFixed(const std::string& text, FixedIp& out) {
size_t slash = text.find('/');
if (slash == std::string::npos) return "Write it as address/prefix, then the gateway if there is one";
size_t space = text.find(' ', slash);
std::string address = text.substr(0, slash);
std::string prefix = text.substr(slash + 1, space == std::string::npos ? std::string::npos : space - slash - 1);
std::string gateway = space == std::string::npos ? "" : text.substr(space + 1);
FixedIp f;
if (!parseIpv4(address, f.address)) return address + " isn't an IPv4 address";
int p = 0;
if (prefix.empty() || prefix.size() > 2) return "The prefix must be 1 to 30";
for (char c : prefix) {
if (c < '0' || c > '9') return "The prefix must be 1 to 30";
p = p * 10 + (c - '0');
}
f.prefix = static_cast<uint8_t>(p);
if (!gateway.empty() && !parseIpv4(gateway, f.gateway)) return gateway + " isn't an IPv4 address";
std::string why = checkFixed(f);
if (why.empty()) out = f;
return why;
}
std::string formatFixed(const FixedIp& f) {
std::string s = formatIpv4(f.address) + "/" + std::to_string(f.prefix);
if (f.gateway) s += " " + formatIpv4(f.gateway);
return s;
}
bool validHost(const std::string& text) {
if (text.empty() || text.size() > 63) return false;
uint32_t ignored;
if (parseIpv4(text, ignored)) return true;
bool allNumeric = true; // digits and dots only, but not an address: "1.2.3", "999.1.1.1"
char previous = '.';
for (char c : text) {
bool letter = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'), digit = c >= '0' && c <= '9';
if (!letter && !digit && c != '-' && c != '.') return false;
if (c == '.' && (previous == '.' || previous == '-')) return false; // empty label, or one ending in '-'
if (c == '-' && previous == '.') return false; // a label starting with '-'
if (letter || c == '-') allNumeric = false;
previous = c;
}
return previous != '.' && previous != '-' && !allNumeric;
}
} // namespace roro::net
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <cstdint>
#include <string>
namespace roro::net {
// IPv4 addresses as 32-bit numbers, most significant byte first: 10.39.39.12 is 0x0A27270C.
bool parseIpv4(const std::string& text, uint32_t& out); // strict: four decimal numbers, 0 to 255
std::string formatIpv4(uint32_t address);
uint32_t maskOf(int prefix); // 24 -> 255.255.255.0
// A Saved Network's Fixed setting (S1, Q105 to Q107). gateway 0: none.
struct FixedIp {
uint32_t address = 0;
uint8_t prefix = 24;
uint32_t gateway = 0;
};
// "" when a device can use it, otherwise why not, for a human (Q111).
std::string checkFixed(const FixedIp& f);
// "address/prefix [gateway]", as typed on the console and kept in flash. parseFixed() also checks.
std::string parseFixed(const std::string& text, FixedIp& out);
std::string formatFixed(const FixedIp& f);
// An IPv4 address or a host name, as an NTP server may be (Q110).
bool validHost(const std::string& text);
} // namespace roro::net
+12
View File
@@ -1,5 +1,7 @@
#include "settings.h" #include "settings.h"
#include "ipv4.h"
namespace roro { namespace roro {
namespace { namespace {
@@ -32,6 +34,11 @@ const Definition kDefinitions[] = {
{"gnss_on", Kind::Bool, 1, nullptr, 0, 1}, {"gnss_on", Kind::Bool, 1, nullptr, 0, 1},
{"coord_dms", Kind::Bool, 0, nullptr, 0, 1}, {"coord_dms", Kind::Bool, 0, nullptr, 0, 1},
{"lora_preset", Kind::Int, 0, nullptr, 0, 6}, // LongFast first {"lora_preset", Kind::Int, 0, nullptr, 0, 6}, // LongFast first
{"dns1", Kind::String, 0, "9.9.9.9", 7, 15}, // Quad9
{"dns2", Kind::String, 0, "1.1.1.1", 0, 15}, // Cloudflare
{"dns_always", Kind::Bool, 0, nullptr, 0, 1},
{"ntp1", Kind::String, 0, "pool.ntp.org", 1, 63},
{"ntp2", Kind::String, 0, "time.cloudflare.com", 0, 63},
}; };
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count), static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition"); "every Setting needs a definition");
@@ -84,6 +91,11 @@ bool Settings::validString(Setting s, const std::string& value) const {
if (value == region) return true; if (value == region) return true;
return false; return false;
} }
uint32_t address;
if (s == Setting::Dns1) return net::parseIpv4(value, address);
if (s == Setting::Dns2) return value.empty() || net::parseIpv4(value, address);
if (s == Setting::Ntp1) return net::validHost(value);
if (s == Setting::Ntp2) return value.empty() || net::validHost(value);
return true; return true;
} }
+5
View File
@@ -24,6 +24,11 @@ enum class Setting : uint8_t {
GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58) GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58)
CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64) CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64)
LoraPreset, // int: the LoRa Scanner's Meshtastic preset, an index into the EU868 list (M3, Q95) LoraPreset, // int: the LoRa Scanner's Meshtastic preset, an index into the EU868 list (M3, Q95)
Dns1, // string: the first DNS server, an IPv4 address (S1, Q108, Q109)
Dns2, // string: the second, or empty
DnsAlways, // bool: use them on Automatic (DHCP) networks too, instead of DHCP's
Ntp1, // string: the first NTP server, a host name or an IPv4 address (S1, Q110)
Ntp2, // string: the second, or empty
Count Count
}; };
+23 -1
View File
@@ -17,6 +17,8 @@ void SavedNetworks::load() {
int32_t hidden = 0; int32_t hidden = 0;
store_.getInt(key(i, "hid").c_str(), hidden); store_.getInt(key(i, "hid").c_str(), hidden);
net.hidden = hidden != 0; net.hidden = hidden != 0;
std::string ip; // "address/prefix [gateway]", or empty for Automatic
net.fixed = store_.getString(key(i, "ip").c_str(), ip) && !ip.empty() && net::parseFixed(ip, net.ip).empty();
networks_.push_back(net); networks_.push_back(net);
} }
} }
@@ -40,11 +42,30 @@ std::string SavedNetworks::add(const std::string& ssid, const std::string& passw
} }
} }
if (count() >= kMax) return "Already 8 saved networks: forget one first"; if (count() >= kMax) return "Already 8 saved networks: forget one first";
networks_.push_back({ssid, password, hidden}); SavedNetwork added;
added.ssid = ssid;
added.password = password;
added.hidden = hidden;
networks_.push_back(added);
save(); save();
return ""; return "";
} }
std::string SavedNetworks::setIp(const std::string& ssid, const net::FixedIp* fixed) {
for (auto& n : networks_) {
if (n.ssid != ssid) continue;
if (fixed) {
std::string why = net::checkFixed(*fixed);
if (!why.empty()) return why;
n.ip = *fixed;
}
n.fixed = fixed != nullptr;
save();
return "";
}
return "Not a saved network";
}
void SavedNetworks::forget(const std::string& ssid) { void SavedNetworks::forget(const std::string& ssid) {
for (auto it = networks_.begin(); it != networks_.end(); ++it) { for (auto it = networks_.begin(); it != networks_.end(); ++it) {
if (it->ssid == ssid) { if (it->ssid == ssid) {
@@ -60,6 +81,7 @@ void SavedNetworks::save() {
store_.putString(key(i, "ssid").c_str(), networks_[i].ssid); store_.putString(key(i, "ssid").c_str(), networks_[i].ssid);
store_.putString(key(i, "pass").c_str(), networks_[i].password); store_.putString(key(i, "pass").c_str(), networks_[i].password);
store_.putInt(key(i, "hid").c_str(), networks_[i].hidden ? 1 : 0); store_.putInt(key(i, "hid").c_str(), networks_[i].hidden ? 1 : 0);
store_.putString(key(i, "ip").c_str(), networks_[i].fixed ? net::formatFixed(networks_[i].ip) : "");
} }
store_.putInt("net_count", count()); store_.putInt("net_count", count());
} }
+5
View File
@@ -3,6 +3,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "ipv4.h"
#include "key_value_store.h" #include "key_value_store.h"
namespace roro { namespace roro {
@@ -11,6 +12,8 @@ struct SavedNetwork {
std::string ssid; std::string ssid;
std::string password; // empty for an open network std::string password; // empty for an open network
bool hidden = false; // doesn't broadcast its name, so never shows up in scans bool hidden = false; // doesn't broadcast its name, so never shows up in scans
bool fixed = false; // S1, Q105: Fixed address (`ip`), or Automatic (DHCP)
net::FixedIp ip;
}; };
// The Saved Networks the Wi-Fi Service may join (see CONTEXT.md), persisted in internal flash. // The Saved Networks the Wi-Fi Service may join (see CONTEXT.md), persisted in internal flash.
@@ -28,6 +31,8 @@ class SavedNetworks {
// Adds, or updates the password of an existing SSID. Empty on success, otherwise why not. // Adds, or updates the password of an existing SSID. Empty on success, otherwise why not.
std::string add(const std::string& ssid, const std::string& password, bool hidden = false); std::string add(const std::string& ssid, const std::string& password, bool hidden = false);
void forget(const std::string& ssid); void forget(const std::string& ssid);
// The IP setting: Fixed with these values, or Automatic (DHCP) for nullptr. Empty, or why not.
std::string setIp(const std::string& ssid, const net::FixedIp* fixed);
private: private:
void save(); void save();
+308 -32
View File
@@ -1,5 +1,6 @@
#include "wifi_settings_page.h" #include "wifi_settings_page.h"
#include "ipv4.h"
#include "ui/fonts.h" #include "ui/fonts.h"
#include "ui/widgets.h" #include "ui/widgets.h"
@@ -15,6 +16,62 @@ void WifiSettingsPage::enter() {
void WifiSettingsPage::refreshMain() { main_.setCount(kFixedRows + saved_.count()); } void WifiSettingsPage::refreshMain() { main_.setCount(kFixedRows + saved_.count()); }
// With Automatic, a network's page shows only the IP row and Forget.
WifiSettingsPage::NetworkRow WifiSettingsPage::networkRow(int i) const {
if (draftFixed_) return static_cast<NetworkRow>(i);
return i == 0 ? kIpMode : kForget;
}
void WifiSettingsPage::openNetwork(const SavedNetwork& n) {
netSsid_ = n.ssid;
draftFixed_ = n.fixed;
draftAddress_ = n.fixed ? net::formatIpv4(n.ip.address) : "";
draftPrefix_ = n.fixed ? std::to_string(n.ip.prefix) : "";
draftGateway_ = n.fixed && n.ip.gateway ? net::formatIpv4(n.ip.gateway) : "";
network_.setCount(draftFixed_ ? 5 : 2);
network_.select(0);
view_ = View::Network;
}
// Leaving a network's page applies its IP setting (Q114), if it passes the checks (Q111).
bool WifiSettingsPage::leaveNetwork() {
const SavedNetwork* n = saved_.find(netSsid_);
if (!n) return true; // forgotten meanwhile
std::string why;
if (draftFixed_) {
net::FixedIp fixed;
why = net::parseFixed(draftAddress_ + "/" + draftPrefix_ + (draftGateway_.empty() ? "" : " " + draftGateway_), fixed);
if (draftAddress_.empty()) why = "A Fixed setting needs an address";
if (!why.empty()) {
warn(why);
return false;
}
bool same = n->fixed && n->ip.address == fixed.address && n->ip.prefix == fixed.prefix && n->ip.gateway == fixed.gateway;
if (same) return true;
why = saved_.setIp(netSsid_, &fixed);
if (why.empty()) warn(netSsid_ + ": fixed, " + net::formatFixed(fixed), NotificationLevel::Info);
} else {
if (!n->fixed) return true;
why = saved_.setIp(netSsid_, nullptr);
if (why.empty()) warn(netSsid_ + ": automatic (DHCP)", NotificationLevel::Info);
}
if (!why.empty()) {
warn(why);
return false;
}
wifi_.ipSettingChanged(netSsid_);
return true;
}
void WifiSettingsPage::edit(Field field, const std::string& title, const std::string& value, View from) {
field_ = field;
editTitle_ = title;
editFrom_ = from;
editor_ = LineEditor(field == Field::Ntp1 || field == Field::Ntp2 ? 63 : 15);
for (char ch : value) editor_.insert(static_cast<uint8_t>(ch));
view_ = View::Edit;
}
void WifiSettingsPage::warn(const std::string& text, NotificationLevel level) { void WifiSettingsPage::warn(const std::string& text, NotificationLevel level) {
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level))); bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level)));
} }
@@ -35,42 +92,22 @@ bool WifiSettingsPage::onKey(const KeyEvent& e) {
if (forgetDialog_) { if (forgetDialog_) {
forgetDialog_->onKey(e); forgetDialog_->onKey(e);
if (forgetDialog_->result() == 1) { if (forgetDialog_->result() == 1) {
saved_.forget(saved_.at(main_.selected() - kFixedRows).ssid); saved_.forget(netSsid_);
refreshMain(); wifi_.ipSettingChanged(netSsid_); // it may be the one in use
enter();
} }
if (forgetDialog_->result() != DialogModel::kPending) forgetDialog_.reset(); if (forgetDialog_ && forgetDialog_->result() != DialogModel::kPending) forgetDialog_.reset();
return true; return true;
} }
switch (view_) { switch (view_) {
case View::Main: case View::Main: return mainKey(e);
switch (e.key) { case View::Servers: return serversKey(e);
case Key::Up: main_.up(); return true; case View::Network: return networkKey(e);
case Key::Down: main_.down(); return true; case View::Edit: return editKey(e);
case Key::Back: return false; case View::Details:
case Key::Left: if (e.key == Key::Back || e.key == Key::Select) view_ = View::Main;
case Key::Right:
if (main_.selected() != kToggle) return true;
[[fallthrough]];
case Key::Select:
switch (main_.selected()) {
case kToggle: settings_.setBool(Setting::WifiEnabled, !settings_.getBool(Setting::WifiEnabled)); break;
case kStatus: break;
case kAddScanned:
wifi_.startListScan();
scan_.setCount(0);
view_ = View::Scan;
break;
case kAddHidden:
newHidden_ = true;
editor_ = LineEditor(32);
view_ = View::Ssid;
break;
default: forgetDialog_.reset(new DialogModel({"Cancel", "Forget"})); break;
}
return true; return true;
default: return true;
}
case View::Scan: { case View::Scan: {
std::vector<ScanEntry> found; std::vector<ScanEntry> found;
@@ -131,6 +168,159 @@ bool WifiSettingsPage::onKey(const KeyEvent& e) {
return true; return true;
} }
bool WifiSettingsPage::mainKey(const KeyEvent& e) {
switch (e.key) {
case Key::Up: main_.up(); return true;
case Key::Down: main_.down(); return true;
case Key::Back: return false;
case Key::Left:
case Key::Right:
if (main_.selected() != kToggle) return true;
[[fallthrough]];
case Key::Select:
switch (main_.selected()) {
case kToggle: settings_.setBool(Setting::WifiEnabled, !settings_.getBool(Setting::WifiEnabled)); break;
case kStatus: view_ = View::Details; break;
case kServers:
servers_.setCount(kServerRows);
view_ = View::Servers;
break;
case kAddScanned:
wifi_.startListScan();
scan_.setCount(0);
view_ = View::Scan;
break;
case kAddHidden:
newHidden_ = true;
editor_ = LineEditor(32);
view_ = View::Ssid;
break;
default: openNetwork(saved_.at(main_.selected() - kFixedRows)); break;
}
return true;
default: return true;
}
}
// DNS and NTP servers (Q108 to Q110).
bool WifiSettingsPage::serversKey(const KeyEvent& e) {
switch (e.key) {
case Key::Up: servers_.up(); break;
case Key::Down: servers_.down(); break;
case Key::Back: view_ = View::Main; break;
case Key::Left:
case Key::Right:
if (servers_.selected() != kDnsAlways) break;
[[fallthrough]];
case Key::Select:
switch (servers_.selected()) {
case kDns1: edit(Field::Dns1, "First DNS server", settings_.getString(Setting::Dns1), View::Servers); break;
case kDns2: edit(Field::Dns2, "Second DNS server (or empty)", settings_.getString(Setting::Dns2), View::Servers); break;
case kDnsAlways:
settings_.setBool(Setting::DnsAlways, !settings_.getBool(Setting::DnsAlways));
wifi_.serversChanged();
break;
case kNtp1: edit(Field::Ntp1, "First NTP server", settings_.getString(Setting::Ntp1), View::Servers); break;
case kNtp2: edit(Field::Ntp2, "Second NTP server (or empty)", settings_.getString(Setting::Ntp2), View::Servers); break;
}
break;
default: break;
}
return true;
}
bool WifiSettingsPage::networkKey(const KeyEvent& e) {
switch (e.key) {
case Key::Up: network_.up(); break;
case Key::Down: network_.down(); break;
case Key::Back:
if (leaveNetwork()) enter();
break;
case Key::Left:
case Key::Right:
if (networkRow(network_.selected()) != kIpMode) break;
[[fallthrough]];
case Key::Select:
switch (networkRow(network_.selected())) {
case kIpMode: {
draftFixed_ = !draftFixed_;
// Fixed for the first time: start from what the network gave us, if we're on it.
WifiService::Connection now = wifi_.connection();
if (draftFixed_ && draftAddress_.empty() && now.connected && wifi_.ssid() == netSsid_) {
draftAddress_ = now.address;
draftPrefix_ = std::to_string(now.prefix);
draftGateway_ = now.gateway;
}
if (draftFixed_ && draftPrefix_.empty()) draftPrefix_ = "24";
network_.setCount(draftFixed_ ? 5 : 2);
network_.select(0);
break;
}
case kAddress: edit(Field::Address, "Address", draftAddress_, View::Network); break;
case kPrefix: edit(Field::Prefix, "Prefix (1 to 30; 24 = 255.255.255.0)", draftPrefix_, View::Network); break;
case kGateway: edit(Field::Gateway, "Gateway (or empty for none)", draftGateway_, View::Network); break;
case kForget: forgetDialog_.reset(new DialogModel({"Cancel", "Forget"})); break;
}
break;
default: break;
}
return true;
}
// One field. Addresses take digits and dots only (Q112); what's typed is checked on Enter (Q111).
bool WifiSettingsPage::editKey(const KeyEvent& e) {
bool name = field_ == Field::Ntp1 || field_ == Field::Ntp2;
switch (e.key) {
case Key::Char: {
bool digit = e.ch >= '0' && e.ch <= '9';
bool ok = name ? e.ch > ' ' && e.ch < 0x7F : field_ == Field::Prefix ? digit : digit || e.ch == '.';
if (ok) editor_.insert(e.ch);
break;
}
case Key::Delete: editor_.backspace(); break;
case Key::Left: editor_.left(); break;
case Key::Right: editor_.right(); break;
case Key::Back: view_ = editFrom_; break;
case Key::Select: {
std::string text = editor_.text(), why;
uint32_t address;
switch (field_) {
case Field::Address:
if (!net::parseIpv4(text, address)) why = "Four numbers from 0 to 255, like 10.39.39.13";
else draftAddress_ = text;
break;
case Field::Prefix: {
int p = text.empty() ? 0 : atoi(text.c_str());
if (p < 1 || p > 30) why = "The prefix must be 1 to 30";
else draftPrefix_ = std::to_string(p);
break;
}
case Field::Gateway:
if (!text.empty() && !net::parseIpv4(text, address)) why = "Four numbers from 0 to 255, or empty";
else draftGateway_ = text;
break;
case Field::Dns1:
case Field::Dns2:
if (!settings_.setString(field_ == Field::Dns1 ? Setting::Dns1 : Setting::Dns2, text))
why = field_ == Field::Dns1 ? "An IPv4 address, like 9.9.9.9" : "An IPv4 address, or empty";
else wifi_.serversChanged();
break;
case Field::Ntp1:
case Field::Ntp2:
if (!settings_.setString(field_ == Field::Ntp1 ? Setting::Ntp1 : Setting::Ntp2, text))
why = field_ == Field::Ntp1 ? "A host name or an IPv4 address" : "A host name, an IPv4 address, or empty";
else wifi_.serversChanged();
break;
}
if (why.empty()) view_ = editFrom_;
else warn(why);
break;
}
default: break;
}
return true;
}
void WifiSettingsPage::draw(Canvas& c) { void WifiSettingsPage::draw(Canvas& c) {
const auto& area = theme::kContent; const auto& area = theme::kContent;
switch (view_) { switch (view_) {
@@ -141,6 +331,7 @@ void WifiSettingsPage::draw(Canvas& c) {
switch (i) { switch (i) {
case kToggle: return "Wi-Fi"; case kToggle: return "Wi-Fi";
case kStatus: return "Status"; case kStatus: return "Status";
case kServers: return "DNS and NTP";
case kAddScanned: return "Add a network"; case kAddScanned: return "Add a network";
case kAddHidden: return "Add a hidden network"; case kAddHidden: return "Add a hidden network";
default: { default: {
@@ -153,14 +344,78 @@ void WifiSettingsPage::draw(Canvas& c) {
switch (i) { switch (i) {
case kToggle: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off"; case kToggle: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
case kStatus: return statusText(); case kStatus: return statusText();
case kServers:
case kAddScanned: case kAddScanned:
case kAddHidden: return ">"; case kAddHidden: return ">";
default: return saved_.at(i - kFixedRows).fixed ? "fixed" : "";
}
});
break;
case View::Details: drawDetails(c); break;
case View::Servers:
widgets::list(
c, servers_, area,
[](int i) -> std::string {
switch (i) {
case kDns1: return "DNS 1";
case kDns2: return "DNS 2";
case kDnsAlways: return "Always use my DNS";
case kNtp1: return "NTP 1";
default: return "NTP 2";
}
},
[this](int i) -> std::string {
switch (i) {
case kDns1: return settings_.getString(Setting::Dns1);
case kDns2: return settings_.getString(Setting::Dns2).empty() ? "none" : settings_.getString(Setting::Dns2);
case kDnsAlways: return settings_.getBool(Setting::DnsAlways) ? "On" : "Off";
case kNtp1: return settings_.getString(Setting::Ntp1);
default: return settings_.getString(Setting::Ntp2).empty() ? "none" : settings_.getString(Setting::Ntp2);
}
});
break;
case View::Network: {
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString(netSsid_.c_str(), 4, area.y + 2);
widgets::list(
c, network_, {area.x, area.y + 12, area.w, area.h - 12},
[this](int i) -> std::string {
switch (networkRow(i)) {
case kIpMode: return "IP address";
case kAddress: return " Address";
case kPrefix: return " Prefix";
case kGateway: return " Gateway";
default: return "Forget this network";
}
},
[this](int i) -> std::string {
switch (networkRow(i)) {
case kIpMode: return draftFixed_ ? "Fixed" : "Automatic";
case kAddress: return draftAddress_.empty() ? "not set" : draftAddress_;
case kPrefix: {
int p = atoi(draftPrefix_.c_str());
return draftPrefix_ + " (" + net::formatIpv4(net::maskOf(p)) + ")";
}
case kGateway: return draftGateway_.empty() ? "none" : draftGateway_;
default: return ""; default: return "";
} }
}); });
if (forgetDialog_ && main_.selected() >= kFixedRows) if (forgetDialog_) widgets::dialog(c, "Forget network?", netSsid_, *forgetDialog_);
widgets::dialog(c, "Forget network?", saved_.at(main_.selected() - kFixedRows).ssid, *forgetDialog_);
break; break;
}
case View::Edit: {
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString(editTitle_.c_str(), 4, area.y + 4);
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
c.drawString("Enter: save `: cancel", 4, area.y + 44);
break;
}
case View::Scan: { case View::Scan: {
if (!wifi_.listScanDone()) { if (!wifi_.listScanDone()) {
@@ -195,4 +450,25 @@ void WifiSettingsPage::draw(Canvas& c) {
} }
} }
// What the device is using and where each part came from (Q113).
void WifiSettingsPage::drawDetails(Canvas& c) {
const auto& area = theme::kContent;
WifiService::Connection n = wifi_.connection();
std::vector<std::string> lines;
if (!n.connected) {
lines = {statusText(), "", "`: back"};
} else {
lines.push_back(wifi_.ssid() + ", " + std::to_string(wifi_.rssi()) + " dBm");
lines.push_back("Address " + n.address + "/" + std::to_string(n.prefix) + (n.fixed ? " (fixed)" : " (DHCP)"));
lines.push_back("Mask " + n.mask);
lines.push_back("Gateway " + (n.gateway.empty() ? std::string("none") : n.gateway));
std::string from = n.dnsFromSettings ? " (Settings)" : " (DHCP)";
lines.push_back("DNS " + (n.dns[0].empty() ? std::string("none") : n.dns[0] + from));
if (!n.dns[1].empty()) lines.push_back("DNS " + n.dns[1] + from);
for (int i = 0; i < n.ntpCount; i++)
lines.push_back("NTP " + n.ntp[i].server + (n.ntp[i].fromDhcp ? " (DHCP)" : "") + (n.ntp[i].answered ? ", answered" : ""));
}
widgets::textLines(c, lines, 0, {area.x + 2, area.y + 2, area.w - 2, area.h - 2});
}
} // namespace roro } // namespace roro
+32 -6
View File
@@ -16,8 +16,9 @@
namespace roro { namespace roro {
// Settings → Wi-Fi: the On/Off switch, current connection, Saved Networks, and adding networks // Settings → Wi-Fi: the On/Off switch, the connection and its details, DNS and NTP servers, and
// from a scan or by name (hidden). Owned by the Settings App. // the Saved Networks: adding one from a scan or by name (hidden), and each one's own page with its
// IP setting, Automatic or Fixed (docs/milestones/S1.md, Q113). Owned by the Settings App.
class WifiSettingsPage { class WifiSettingsPage {
public: public:
WifiSettingsPage(Settings& settings, SavedNetworks& saved, WifiService& wifi, EventBus& bus) WifiSettingsPage(Settings& settings, SavedNetworks& saved, WifiService& wifi, EventBus& bus)
@@ -25,17 +26,31 @@ class WifiSettingsPage {
void enter(); void enter();
bool onKey(const KeyEvent& e); // false: leave the page bool onKey(const KeyEvent& e); // false: leave the page
bool textEntryActive() const { return view_ == View::Ssid || view_ == View::Password; } bool textEntryActive() const { return view_ == View::Ssid || view_ == View::Password || view_ == View::Edit; }
bool live() const { return view_ == View::Main || view_ == View::Scan; } // redraw periodically // Redraw periodically: the status, a scan, the connection details.
bool live() const { return view_ == View::Main || view_ == View::Scan || view_ == View::Details; }
void draw(Canvas& c); void draw(Canvas& c);
private: private:
enum class View { Main, Scan, Ssid, Password }; enum class View { Main, Scan, Ssid, Password, Details, Servers, Network, Edit };
enum Fixed { kToggle, kStatus, kAddScanned, kAddHidden, kFixedRows }; enum Fixed { kToggle, kStatus, kServers, kAddScanned, kAddHidden, kFixedRows };
enum ServerRow { kDns1, kDns2, kDnsAlways, kNtp1, kNtp2, kServerRows };
// A Saved Network's page: with Automatic, only kIpMode and kForget are shown.
enum NetworkRow { kIpMode, kAddress, kPrefix, kGateway, kForget };
enum class Field { Address, Prefix, Gateway, Dns1, Dns2, Ntp1, Ntp2 };
void refreshMain(); void refreshMain();
void warn(const std::string& text, NotificationLevel level = NotificationLevel::Warning); void warn(const std::string& text, NotificationLevel level = NotificationLevel::Warning);
std::string statusText() const; std::string statusText() const;
bool mainKey(const KeyEvent& e);
bool serversKey(const KeyEvent& e);
bool networkKey(const KeyEvent& e);
bool editKey(const KeyEvent& e);
void openNetwork(const SavedNetwork& n);
bool leaveNetwork(); // checks and applies the draft; false (with a Toast) when it's refused
NetworkRow networkRow(int i) const;
void edit(Field field, const std::string& title, const std::string& value, View from);
void drawDetails(Canvas& c);
Settings& settings_; Settings& settings_;
SavedNetworks& saved_; SavedNetworks& saved_;
@@ -44,10 +59,21 @@ class WifiSettingsPage {
View view_ = View::Main; View view_ = View::Main;
ListModel main_{theme::kContent.h / theme::kLineHeight}; ListModel main_{theme::kContent.h / theme::kLineHeight};
ListModel scan_{theme::kContent.h / theme::kLineHeight}; ListModel scan_{theme::kContent.h / theme::kLineHeight};
ListModel servers_{theme::kContent.h / theme::kLineHeight};
ListModel network_{theme::kContent.h / theme::kLineHeight - 1};
LineEditor editor_{63}; LineEditor editor_{63};
std::string newSsid_; std::string newSsid_;
bool newHidden_ = false; bool newHidden_ = false;
std::unique_ptr<DialogModel> forgetDialog_; std::unique_ptr<DialogModel> forgetDialog_;
// The Saved Network whose page is open, and its IP setting as being typed (a draft: applied on
// leaving the page, so a half-typed address is never used).
std::string netSsid_;
bool draftFixed_ = false;
std::string draftAddress_, draftPrefix_, draftGateway_;
// The field being edited, and where Enter and Back return to.
Field field_ = Field::Address;
std::string editTitle_;
View editFrom_ = View::Main;
}; };
} // namespace roro } // namespace roro
+95 -1
View File
@@ -18,6 +18,7 @@
#include "apps/setup_app.h" #include "apps/setup_app.h"
#include "event_bus.h" #include "event_bus.h"
#include "file_receiver.h" #include "file_receiver.h"
#include "ipv4.h"
#include "key_mapper.h" #include "key_mapper.h"
#include "platform/console.h" #include "platform/console.h"
#include "platform/crash_report.h" #include "platform/crash_report.h"
@@ -359,6 +360,27 @@ static void uploadStep() {
} }
} }
#ifdef RORO_DEBUG
// `wifi ip ... try <seconds>`: a trial IP setting that reverts unless `wifi ip keep` arrives, so a
// wrong address tried over Wi-Fi doesn't cut the device off for good.
static struct {
bool active = false;
uint32_t untilMs = 0;
std::string ssid;
bool wasFixed = false;
net::FixedIp was;
} ipTrial;
static void ipTrialStep() {
if (!ipTrial.active || static_cast<int32_t>(millis() - ipTrial.untilMs) < 0) return;
ipTrial.active = false;
savedNetworks->setIp(ipTrial.ssid, ipTrial.wasFixed ? &ipTrial.was : nullptr);
console.printf("wifi ip: trial over, %s is back to %s\n", ipTrial.ssid.c_str(),
ipTrial.wasFixed ? net::formatFixed(ipTrial.was).c_str() : "Automatic (DHCP)");
wifi->ipSettingChanged(ipTrial.ssid);
}
#endif
static const char* const kHelp = static const char* const kHelp =
"info firmware, uptime, memory, Wi-Fi, app slots\n" "info firmware, uptime, memory, Wi-Fi, app slots\n"
"tasks FreeRTOS tasks: state, priority, free stack, CPU\n" "tasks FreeRTOS tasks: state, priority, free stack, CPU\n"
@@ -374,12 +396,15 @@ static const char* const kHelp =
"coredump erase forget the core dump in flash\n" "coredump erase forget the core dump in flash\n"
"key <name|char> press a key: up down left right select back home del tab space, or one character\n" "key <name|char> press a key: up down left right select back home del tab space, or one character\n"
"wifi status | wifi add <ssid><TAB><password>\n" "wifi status | wifi add <ssid><TAB><password>\n"
"wifi ip <ssid> dhcp | wifi ip <ssid> <address>/<prefix> [gateway] a Saved Network's IP setting\n"
"wifi dns <a> [b] | wifi dns always on|off | wifi ntp <a> [b] DNS and NTP servers\n"
"gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n" "gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n"
"irc start | irc stop | irc dump | irc say <buffer> <text>\n" "irc start | irc stop | irc dump | irc say <buffer> <text>\n"
"ls [folder] | rm <path> | install <path.ota> (Update from SD)\n" "ls [folder] | rm <path> | install <path.ota> (Update from SD)\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n" "sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
#ifdef RORO_DEBUG #ifdef RORO_DEBUG
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
"wifi ip ... try <seconds> | wifi ip keep a trial IP setting: back to the previous one unless kept\n"
"lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (nothing is sent)\n" "lora inject <hex> [rssi] [snr] a packet into the LoRa Scanner as if received (nothing is sent)\n"
"coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n" "coredump get (Debug Console only) send the raw core dump: use scripts/rdbg.py coredump\n"
"reset (Debug Console only) restart at once, even if the main loop is stuck\n" "reset (Debug Console only) restart at once, even if the main loop is stuck\n"
@@ -588,11 +613,77 @@ static void runCommand(String line) {
} }
}); });
} }
if (line == "wifi status") if (line == "wifi status") {
console.printf("wifi: state %d ssid '%s' rssi %d ip %s clock %s heap %u min %u | fw %s%s\n", console.printf("wifi: state %d ssid '%s' rssi %d ip %s clock %s heap %u min %u | fw %s%s\n",
(int)wifi->state(), wifi->ssid().c_str(), wifi->rssi(), wifi->ip().c_str(), (int)wifi->state(), wifi->ssid().c_str(), wifi->rssi(), wifi->ip().c_str(),
clockService->displayTime().c_str(), ESP.getFreeHeap(), ESP.getMinFreeHeap(), versionString(), clockService->displayTime().c_str(), ESP.getFreeHeap(), ESP.getMinFreeHeap(), versionString(),
update->onProbation() ? " (on probation)" : ""); update->onProbation() ? " (on probation)" : "");
WifiService::Connection c = wifi->connection(); // S1, Q115: what's in use and where it came from
if (c.connected) {
console.printf("wifi: address %s/%d (%s), gateway %s\n", c.address.c_str(), c.prefix, c.fixed ? "fixed" : "DHCP",
c.gateway.empty() ? "none" : c.gateway.c_str());
console.printf("wifi: dns %s %s (%s)\n", c.dns[0].empty() ? "none" : c.dns[0].c_str(), c.dns[1].c_str(),
c.dnsFromSettings ? "Settings" : "DHCP");
console.print("wifi: ntp");
for (int i = 0; i < c.ntpCount; i++) console.printf(" %s (%s%s)", c.ntp[i].server.c_str(), c.ntp[i].fromDhcp ? "DHCP" : "Settings", c.ntp[i].answered ? ", answered" : "");
console.println(c.ntpCount ? "" : " none");
}
}
if (line.startsWith("wifi ip ")) { // wifi ip <ssid> dhcp | <address>/<prefix> [gateway] (the SSID may hold spaces)
std::string rest = line.substring(8).c_str();
#ifdef RORO_DEBUG
if (rest == "keep") { // the trial setting stays
console.println(ipTrial.active ? "wifi ip: kept" : "wifi ip: no trial running");
ipTrial.active = false;
return;
}
uint32_t trialS = 0;
size_t tryAt = rest.rfind(" try ");
if (tryAt != std::string::npos) {
trialS = strtoul(rest.c_str() + tryAt + 5, nullptr, 10);
rest = rest.substr(0, tryAt);
}
#endif
std::string ssid, why;
net::FixedIp fixed;
bool dhcp = rest.size() > 5 && rest.compare(rest.size() - 5, 5, " dhcp") == 0;
if (dhcp) ssid = rest.substr(0, rest.size() - 5);
else {
size_t slash = rest.rfind('/'), space = slash == std::string::npos ? slash : rest.rfind(' ', slash);
if (space == std::string::npos) why = "usage: wifi ip <ssid> dhcp | <address>/<prefix> [gateway]";
else {
ssid = rest.substr(0, space);
why = net::parseFixed(rest.substr(space + 1), fixed);
}
}
const SavedNetwork* before = why.empty() ? savedNetworks->find(ssid) : nullptr;
#ifdef RORO_DEBUG
if (before && trialS) ipTrial = {true, millis() + trialS * 1000, ssid, before->fixed, before->ip};
#endif
if (why.empty()) why = savedNetworks->setIp(ssid, dhcp ? nullptr : &fixed);
if (!why.empty()) return (void)console.printf("wifi ip: %s\n", why.c_str());
console.printf("wifi ip: %s is now %s\n", ssid.c_str(), dhcp ? "Automatic (DHCP)" : net::formatFixed(fixed).c_str());
wifi->ipSettingChanged(ssid);
}
if ((line.startsWith("wifi dns ") && !line.startsWith("wifi dns always")) || line.startsWith("wifi ntp ")) { // <a> [b]
bool dns = line.startsWith("wifi dns ");
std::string rest = line.substring(9).c_str();
size_t space = rest.find(' ');
std::string first = rest.substr(0, space), second = space == std::string::npos ? "" : rest.substr(space + 1);
Setting a = dns ? Setting::Dns1 : Setting::Ntp1, b = dns ? Setting::Dns2 : Setting::Ntp2;
std::string oldFirst = settings.getString(a);
// Both or neither: a refused second entry leaves the first as it was.
bool ok = settings.setString(a, first) && (settings.setString(b, second) || (settings.setString(a, oldFirst), false));
if (!ok) console.printf("wifi %s: %s\n", dns ? "dns" : "ntp", dns ? "an IPv4 address, then a second one if you like" : "a host name or an IPv4 address, then a second one if you like");
else {
console.printf("wifi %s: %s %s\n", dns ? "dns" : "ntp", first.c_str(), second.c_str());
wifi->serversChanged();
}
}
if (line == "wifi dns always on" || line == "wifi dns always off") {
settings.setBool(Setting::DnsAlways, line.endsWith("on"));
wifi->serversChanged();
}
if (line == "sound off") settings.setBool(Setting::Sound, false); if (line == "sound off") settings.setBool(Setting::Sound, false);
if (line == "sound on") settings.setBool(Setting::Sound, true); if (line == "sound on") settings.setBool(Setting::Sound, true);
if (line == "short") { if (line == "short") {
@@ -671,6 +762,9 @@ void loop() {
serialCommands(); serialCommands();
remoteCommands(); remoteCommands();
#ifdef RORO_DEBUG
ipTrialStep();
#endif
noteStableOnce(now); noteStableOnce(now);
uploadStep(); uploadStep();
printListingWhenReady(); printListingWhenReady();
+116 -8
View File
@@ -3,10 +3,121 @@
#include <WiFi.h> #include <WiFi.h>
#include <esp_sntp.h> #include <esp_sntp.h>
#include <esp_wifi.h> #include <esp_wifi.h>
#include <lwip/ip_addr.h>
#include <cstring>
#include "ipv4.h"
namespace roro { namespace roro {
namespace {
IPAddress toIp(uint32_t a) { return IPAddress(a >> 24, a >> 16 & 255, a >> 8 & 255, a & 255); }
IPAddress toIp(const std::string& text) {
uint32_t a = 0;
net::parseIpv4(text, a);
return toIp(a);
}
const IPAddress kNoAddress(static_cast<uint32_t>(0));
// A slot lwIP filled from DHCP has an address and no name; ours are set by name.
bool sntpSlotFromDhcp(int i) {
const ip_addr_t* a = esp_sntp_getserver(i);
return !esp_sntp_getservername(i) && a && !ip_addr_isany(a);
}
constexpr int kNtpSlots = 3; // CONFIG_LWIP_SNTP_MAX_SERVERS
constexpr uint32_t kServersEveryMs = 30000;
} // namespace
// Joins a Saved Network with its IP setting (S1, Q105): Fixed, or DHCP.
void WifiService::join(const std::string& ssid, const std::string& password) {
const SavedNetwork* n = saved_.find(ssid);
fixed_ = n && n->fixed;
if (fixed_) {
// Nothing comes from DHCP here: forget the NTP servers a previous network offered.
for (int i = 0; i < kNtpSlots; i++)
if (sntpSlotFromDhcp(i)) esp_sntp_setserver(i, nullptr);
WiFi.config(toIp(n->ip.address), toIp(n->ip.gateway), toIp(net::maskOf(n->ip.prefix)),
toIp(settings_.getString(Setting::Dns1)), toIp(settings_.getString(Setting::Dns2)));
} else {
WiFi.config(kNoAddress, kNoAddress, kNoAddress); // DHCP
}
WiFi.begin(ssid.c_str(), password.empty() ? nullptr : password.c_str());
}
void WifiService::ipSettingChanged(const std::string& ssid) {
if (controller_.state() != WifiController::State::Connected || controller_.ssid() != ssid) return;
WiFi.disconnect();
apply(controller_.disconnected(millis()));
controller_.retryNow(millis());
}
// DNS and NTP as decided in Q108 and Q110. Run when connected, when a setting changes, and now
// and then: a DHCP renewal puts DHCP's DNS back and clears the NTP slots it didn't fill.
void WifiService::applyServers(Why why) {
if (controller_.state() != WifiController::State::Connected) return;
serversCheckedMs_ = millis();
bool changed = why != Why::Check;
bool wasFromSettings = dnsFromSettings_;
dnsFromSettings_ = fixed_ || settings_.getBool(Setting::DnsAlways);
if (dnsFromSettings_) {
IPAddress dns1 = toIp(settings_.getString(Setting::Dns1)), dns2 = toIp(settings_.getString(Setting::Dns2));
if (WiFi.dnsIP(0) != dns1 || WiFi.dnsIP(1) != dns2) WiFi.setDNS(dns1, dns2);
} else if (why == Why::SettingsChanged && wasFromSettings) {
// "Always use my DNS" was switched off: only a new lease brings DHCP's servers back.
ipSettingChanged(controller_.ssid());
return;
}
// NTP: the servers DHCP offered stay first; ours follow.
int fromDhcp = 0;
while (!fixed_ && fromDhcp < kNtpSlots && sntpSlotFromDhcp(fromDhcp)) fromDhcp++;
std::string wanted[2] = {settings_.getString(Setting::Ntp1), settings_.getString(Setting::Ntp2)};
bool touched = false;
for (int i = fromDhcp, mine = 0; i < kNtpSlots; i++, mine++) {
const char* current = esp_sntp_getservername(i);
if (mine < 2 && !wanted[mine].empty()) {
if (current && wanted[mine] == current) continue;
ntpNames_[mine] = wanted[mine];
esp_sntp_setservername(i, ntpNames_[mine].c_str());
touched = true;
} else if (current || sntpSlotFromDhcp(i)) {
esp_sntp_setserver(i, nullptr);
touched = true;
}
}
if (!esp_sntp_enabled()) {
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_init();
} else if (touched || changed) {
esp_sntp_restart();
}
if (touched || changed) ntpWaiting_ = true;
}
WifiService::Connection WifiService::connection() const {
Connection c;
c.connected = controller_.state() == WifiController::State::Connected;
if (!c.connected) return c;
c.fixed = fixed_;
c.dnsFromSettings = dnsFromSettings_;
c.address = WiFi.localIP().toString().c_str();
c.mask = WiFi.subnetMask().toString().c_str();
c.prefix = WiFi.subnetCIDR();
IPAddress gateway = WiFi.gatewayIP();
if (gateway != kNoAddress) c.gateway = gateway.toString().c_str();
for (int i = 0; i < 2; i++)
if (WiFi.dnsIP(i) != kNoAddress) c.dns[i] = WiFi.dnsIP(i).toString().c_str();
for (int i = 0; i < kNtpSlots; i++) {
const char* name = esp_sntp_getservername(i);
bool answered = esp_sntp_getreachability(i) != 0;
if (name) c.ntp[c.ntpCount++] = {name, false, answered};
else if (sntpSlotFromDhcp(i)) c.ntp[c.ntpCount++] = {ipaddr_ntoa(esp_sntp_getserver(i)), true, answered};
}
return c;
}
std::string WifiService::ip() const { std::string WifiService::ip() const {
return controller_.state() == WifiController::State::Connected ? WiFi.localIP().toString().c_str() : ""; return controller_.state() == WifiController::State::Connected ? WiFi.localIP().toString().c_str() : "";
} }
@@ -20,6 +131,7 @@ void WifiService::startScan(int channel) {
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(false); // the controller decides when to reconnect WiFi.setAutoReconnect(false); // the controller decides when to reconnect
esp_wifi_set_country_code("EU", true); esp_wifi_set_country_code("EU", true);
esp_sntp_servermode_dhcp(true); // before DHCP runs: take the NTP servers it offers (Q110)
radioInitialised_ = true; radioInitialised_ = true;
} }
if (scanRunning_) return; if (scanRunning_) return;
@@ -87,13 +199,7 @@ void WifiService::finishScan(uint32_t nowMs) {
void WifiService::startNtp() { void WifiService::startNtp() {
// Not configTime(): it would overwrite the POSIX TZ the Clock applies. // Not configTime(): it would overwrite the POSIX TZ the Clock applies.
if (!esp_sntp_enabled()) { applyServers(Why::Joined);
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "pool.ntp.org");
esp_sntp_init();
} else {
esp_sntp_restart();
}
ntpWaiting_ = true; ntpWaiting_ = true;
} }
@@ -102,7 +208,7 @@ void WifiService::apply(const WifiController::Step& step) {
switch (step.action) { switch (step.action) {
case Action::None: break; case Action::None: break;
case Action::StartScan: startScan(); break; case Action::StartScan: startScan(); break;
case Action::Connect: WiFi.begin(step.ssid.c_str(), step.password.empty() ? nullptr : step.password.c_str()); break; case Action::Connect: join(step.ssid, step.password); break;
case Action::Disconnect: WiFi.disconnect(); break; case Action::Disconnect: WiFi.disconnect(); break;
case Action::RadioOff: case Action::RadioOff:
WiFi.disconnect(true); WiFi.disconnect(true);
@@ -143,6 +249,8 @@ void WifiService::tick(uint32_t nowMs) {
apply(controller_.update(nowMs, settings_.getBool(Setting::WifiEnabled))); apply(controller_.update(nowMs, settings_.getBool(Setting::WifiEnabled)));
if (controller_.state() == State::Connected && nowMs - serversCheckedMs_ >= kServersEveryMs) applyServers(Why::Check);
if (ntpWaiting_ && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) { if (ntpWaiting_ && sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) {
ntpWaiting_ = false; ntpWaiting_ = false;
clock_.set(static_cast<int64_t>(time(nullptr)), TimeSource::Ntp); clock_.set(static_cast<int64_t>(time(nullptr)), TimeSource::Ntp);
+28
View File
@@ -36,6 +36,27 @@ class WifiService : public Service {
// Done with list scans: switch the radio back off if Wi-Fi itself is off. // Done with list scans: switch the radio back off if Wi-Fi itself is off.
void endListScans(); void endListScans();
// What the device is using and where each part came from (S1, Q113).
struct Connection {
bool connected = false;
bool fixed = false; // the address: Fixed, or from DHCP
bool dnsFromSettings = false; // DNS: from Settings, or from DHCP
std::string address, mask, gateway;
int prefix = 0;
std::string dns[2];
struct Ntp {
std::string server;
bool fromDhcp = false;
bool answered = false; // it has replied since the last (re)start
} ntp[3];
int ntpCount = 0;
};
Connection connection() const;
// A Saved Network's IP setting changed: if it's the one in use, join it again with it (Q114).
void ipSettingChanged(const std::string& ssid);
// The DNS or NTP settings changed: use them now.
void serversChanged() { applyServers(Why::SettingsChanged); }
// A Saved Network was added: try it now rather than after the retry delay. // A Saved Network was added: try it now rather than after the retry delay.
void savedNetworksChanged() { controller_.retryNow(millis()); } void savedNetworksChanged() { controller_.retryNow(millis()); }
@@ -47,6 +68,9 @@ class WifiService : public Service {
void startScan(int channel = 0); void startScan(int channel = 0);
void finishScan(uint32_t nowMs); void finishScan(uint32_t nowMs);
void startNtp(); void startNtp();
void join(const std::string& ssid, const std::string& password);
enum class Why { Joined, SettingsChanged, Check };
void applyServers(Why why); // DNS and NTP, as Settings and the network say
Settings& settings_; Settings& settings_;
SavedNetworks& saved_; SavedNetworks& saved_;
@@ -58,6 +82,10 @@ class WifiService : public Service {
uint32_t listScanSeq_ = 0; uint32_t listScanSeq_ = 0;
bool ntpWaiting_ = false; bool ntpWaiting_ = false;
bool radioInitialised_ = false; bool radioInitialised_ = false;
bool fixed_ = false; // the network in use has a Fixed address
bool dnsFromSettings_ = false;
std::string ntpNames_[2]; // lwIP keeps the pointers, so the names live here
uint32_t serversCheckedMs_ = 0;
}; };
} // namespace roro } // namespace roro
+101
View File
@@ -0,0 +1,101 @@
#include <unity.h>
#include <string>
#include "ipv4.h"
using namespace roro::net;
void setUp() {}
void tearDown() {}
static uint32_t ip(const char* s) {
uint32_t a = 0;
TEST_ASSERT_TRUE_MESSAGE(parseIpv4(s, a), s);
return a;
}
void test_parse_and_format() {
TEST_ASSERT_EQUAL_HEX32(0x0A27270C, ip("10.39.39.12"));
TEST_ASSERT_EQUAL_HEX32(0x00000000, ip("0.0.0.0"));
TEST_ASSERT_EQUAL_HEX32(0xFFFFFFFF, ip("255.255.255.255"));
TEST_ASSERT_EQUAL_STRING("10.39.39.12", formatIpv4(0x0A27270C).c_str());
TEST_ASSERT_EQUAL_STRING("192.168.1.254", formatIpv4(ip("192.168.1.254")).c_str());
}
void test_parse_refuses() {
uint32_t a;
for (const char* bad : {"", "10.39.39", "10.39.39.12.1", "10.39.39.256", "10..39.12", "10.39.39.", ".10.39.39",
"10.39.39.1a", " 10.39.39.12", "10.39.39.12 ", "1.2.3.-4", "01234.1.1.1", "1.2.3.4/24"})
TEST_ASSERT_FALSE_MESSAGE(parseIpv4(bad, a), bad);
}
void test_prefix_and_mask() {
TEST_ASSERT_EQUAL_HEX32(0xFFFFFF00, maskOf(24));
TEST_ASSERT_EQUAL_HEX32(0xFFFFFFFC, maskOf(30));
TEST_ASSERT_EQUAL_HEX32(0x80000000, maskOf(1));
TEST_ASSERT_EQUAL_HEX32(0xFFFF0000, maskOf(16));
TEST_ASSERT_EQUAL_STRING("255.255.255.0", formatIpv4(maskOf(24)).c_str());
}
// Q111: what a Fixed setting must satisfy, and the reason given when it doesn't.
void test_fixed_setting_accepted() {
TEST_ASSERT_EQUAL_STRING("", checkFixed({ip("10.39.39.13"), 24, ip("10.39.39.1")}).c_str());
TEST_ASSERT_EQUAL_STRING("", checkFixed({ip("192.168.4.2"), 30, ip("192.168.4.1")}).c_str());
TEST_ASSERT_EQUAL_STRING("", checkFixed({ip("10.0.0.5"), 8, 0}).c_str()); // Q107: no gateway is fine
}
void test_fixed_setting_refused() {
TEST_ASSERT_EQUAL_STRING("The prefix must be 1 to 30", checkFixed({ip("10.39.39.13"), 0, 0}).c_str());
TEST_ASSERT_EQUAL_STRING("The prefix must be 1 to 30", checkFixed({ip("10.39.39.13"), 31, 0}).c_str());
TEST_ASSERT_EQUAL_STRING("10.39.39.0 is the network's own address", checkFixed({ip("10.39.39.0"), 24, 0}).c_str());
TEST_ASSERT_EQUAL_STRING("10.39.39.255 is the broadcast address", checkFixed({ip("10.39.39.255"), 24, 0}).c_str());
TEST_ASSERT_EQUAL_STRING("The gateway 10.39.40.1 isn't in 10.39.39.0/24",
checkFixed({ip("10.39.39.13"), 24, ip("10.39.40.1")}).c_str());
TEST_ASSERT_EQUAL_STRING("The gateway can't be this device's address",
checkFixed({ip("10.39.39.13"), 24, ip("10.39.39.13")}).c_str());
TEST_ASSERT_EQUAL_STRING("The gateway 10.39.39.255 is the broadcast address",
checkFixed({ip("10.39.39.13"), 24, ip("10.39.39.255")}).c_str());
TEST_ASSERT_EQUAL_STRING("0.0.0.0 isn't an address a device can have", checkFixed({0, 24, 0}).c_str());
}
// How a Fixed setting is typed on the console and kept in flash: "address/prefix [gateway]".
void test_fixed_setting_as_text() {
FixedIp f;
TEST_ASSERT_EQUAL_STRING("", parseFixed("10.39.39.13/24 10.39.39.1", f).c_str());
TEST_ASSERT_EQUAL_HEX32(ip("10.39.39.13"), f.address);
TEST_ASSERT_EQUAL_UINT8(24, f.prefix);
TEST_ASSERT_EQUAL_HEX32(ip("10.39.39.1"), f.gateway);
TEST_ASSERT_EQUAL_STRING("10.39.39.13/24 10.39.39.1", formatFixed(f).c_str());
TEST_ASSERT_EQUAL_STRING("", parseFixed("192.168.4.2/30", f).c_str());
TEST_ASSERT_EQUAL_HEX32(0, f.gateway);
TEST_ASSERT_EQUAL_STRING("192.168.4.2/30", formatFixed(f).c_str());
TEST_ASSERT_EQUAL_STRING("Write it as address/prefix, then the gateway if there is one", parseFixed("10.39.39.13", f).c_str());
TEST_ASSERT_EQUAL_STRING("10.39.39.300 isn't an IPv4 address", parseFixed("10.39.39.300/24", f).c_str());
TEST_ASSERT_EQUAL_STRING("The prefix must be 1 to 30", parseFixed("10.39.39.13/abc", f).c_str());
TEST_ASSERT_EQUAL_STRING("gw isn't an IPv4 address", parseFixed("10.39.39.13/24 gw", f).c_str());
TEST_ASSERT_EQUAL_STRING("The gateway 10.39.40.1 isn't in 10.39.39.0/24", parseFixed("10.39.39.13/24 10.39.40.1", f).c_str());
}
// An NTP server is an address or a host name (Q110).
void test_host_names() {
for (const char* good : {"pool.ntp.org", "time.cloudflare.com", "10.39.39.1", "ntp", "a-b.example", "0.be.pool.ntp.org"})
TEST_ASSERT_TRUE_MESSAGE(validHost(good), good);
for (const char* bad : {"", "pool .ntp.org", "-ntp.org", "ntp-.org", "ntp..org", ".ntp.org", "ntp.org.", "n_tp.org", "häst.se",
"999.1.1.1", "1.2.3"})
TEST_ASSERT_FALSE_MESSAGE(validHost(bad), bad);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_parse_and_format);
RUN_TEST(test_parse_refuses);
RUN_TEST(test_prefix_and_mask);
RUN_TEST(test_fixed_setting_accepted);
RUN_TEST(test_fixed_setting_refused);
RUN_TEST(test_fixed_setting_as_text);
RUN_TEST(test_host_names);
return UNITY_END();
}
@@ -97,6 +97,76 @@ void test_find() {
TEST_ASSERT_NULL(n.find("other")); TEST_ASSERT_NULL(n.find("other"));
} }
// S1, Q105: each Saved Network is Automatic (DHCP) or Fixed, and remembers which.
void test_new_network_is_automatic() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("home", "password1");
TEST_ASSERT_FALSE(nets.find("home")->fixed);
}
void test_ip_setting_survives_reload() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("home", "password1");
nets.add("bench", "");
roro::net::FixedIp f;
TEST_ASSERT_EQUAL_STRING("", roro::net::parseFixed("10.39.39.13/24 10.39.39.1", f).c_str());
TEST_ASSERT_EQUAL_STRING("", nets.setIp("bench", &f).c_str());
SavedNetworks again(store);
again.load();
TEST_ASSERT_FALSE(again.find("home")->fixed);
TEST_ASSERT_TRUE(again.find("bench")->fixed);
TEST_ASSERT_EQUAL_STRING("10.39.39.13/24 10.39.39.1", roro::net::formatFixed(again.find("bench")->ip).c_str());
TEST_ASSERT_EQUAL_STRING("", again.setIp("bench", nullptr).c_str()); // back to Automatic
SavedNetworks third(store);
third.load();
TEST_ASSERT_FALSE(third.find("bench")->fixed);
}
void test_ip_setting_is_checked() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("bench", "");
roro::net::FixedIp bad{0x0A272700, 24, 0}; // 10.39.39.0: the network's own address
TEST_ASSERT_EQUAL_STRING("10.39.39.0 is the network's own address", nets.setIp("bench", &bad).c_str());
TEST_ASSERT_FALSE(nets.find("bench")->fixed);
roro::net::FixedIp ok{0x0A27270D, 24, 0};
TEST_ASSERT_EQUAL_STRING("Not a saved network", nets.setIp("nowhere", &ok).c_str());
}
// Forgetting a network moves the ones after it: each keeps its own setting.
void test_ip_setting_follows_its_network() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("a", "");
nets.add("b", "");
nets.add("c", "");
roro::net::FixedIp f{0x0A27270D, 24, 0};
nets.setIp("c", &f);
nets.forget("a");
SavedNetworks again(store);
again.load();
TEST_ASSERT_FALSE(again.find("b")->fixed);
TEST_ASSERT_TRUE(again.find("c")->fixed);
// And changing the password keeps it.
again.add("c", "password2");
TEST_ASSERT_TRUE(again.find("c")->fixed);
}
// A stored setting that no longer passes the checks falls back to Automatic.
void test_bad_stored_ip_setting_is_ignored() {
MemoryStore store;
SavedNetworks nets(store);
nets.add("bench", "");
store.strings["net0_ip"] = "10.39.39.13/99";
SavedNetworks again(store);
again.load();
TEST_ASSERT_FALSE(again.find("bench")->fixed);
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_empty_store_has_no_networks); RUN_TEST(test_empty_store_has_no_networks);
@@ -107,5 +177,10 @@ int main() {
RUN_TEST(test_validation_follows_wifi_limits); RUN_TEST(test_validation_follows_wifi_limits);
RUN_TEST(test_hidden_flag_survives_reload); RUN_TEST(test_hidden_flag_survives_reload);
RUN_TEST(test_find); RUN_TEST(test_find);
RUN_TEST(test_new_network_is_automatic);
RUN_TEST(test_ip_setting_survives_reload);
RUN_TEST(test_ip_setting_is_checked);
RUN_TEST(test_ip_setting_follows_its_network);
RUN_TEST(test_bad_stored_ip_setting_is_ignored);
return UNITY_END(); return UNITY_END();
} }
+33
View File
@@ -154,6 +154,37 @@ void test_storage_keys_fit_nvs_limit() {
TEST_ASSERT_TRUE(std::strlen(Settings::key(static_cast<Setting>(i))) <= 15); TEST_ASSERT_TRUE(std::strlen(Settings::key(static_cast<Setting>(i))) <= 15);
} }
// S1, Q108 to Q110: DNS and NTP servers, with public defaults.
void test_dns_and_ntp_defaults() {
MemoryStore store;
EventBus bus;
Settings s(store, bus);
s.load();
TEST_ASSERT_EQUAL_STRING("9.9.9.9", s.getString(Setting::Dns1).c_str());
TEST_ASSERT_EQUAL_STRING("1.1.1.1", s.getString(Setting::Dns2).c_str());
TEST_ASSERT_FALSE(s.getBool(Setting::DnsAlways));
TEST_ASSERT_EQUAL_STRING("pool.ntp.org", s.getString(Setting::Ntp1).c_str());
TEST_ASSERT_EQUAL_STRING("time.cloudflare.com", s.getString(Setting::Ntp2).c_str());
}
void test_dns_and_ntp_are_checked() {
MemoryStore store;
EventBus bus;
Settings s(store, bus);
s.load();
TEST_ASSERT_TRUE(s.setString(Setting::Dns1, "10.39.39.1"));
TEST_ASSERT_FALSE(s.setString(Setting::Dns1, "dns.example")); // an address, not a name
TEST_ASSERT_FALSE(s.setString(Setting::Dns1, "")); // the first one is required
TEST_ASSERT_EQUAL_STRING("10.39.39.1", s.getString(Setting::Dns1).c_str());
TEST_ASSERT_TRUE(s.setString(Setting::Dns2, "")); // the second is optional
TEST_ASSERT_FALSE(s.setString(Setting::Dns2, "1.1.1"));
TEST_ASSERT_TRUE(s.setString(Setting::Ntp1, "10.39.39.1"));
TEST_ASSERT_TRUE(s.setString(Setting::Ntp1, "0.be.pool.ntp.org"));
TEST_ASSERT_FALSE(s.setString(Setting::Ntp1, "time server"));
TEST_ASSERT_FALSE(s.setString(Setting::Ntp1, ""));
TEST_ASSERT_TRUE(s.setString(Setting::Ntp2, ""));
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_defaults_when_store_is_empty); RUN_TEST(test_defaults_when_store_is_empty);
@@ -168,5 +199,7 @@ int main() {
RUN_TEST(test_change_publishes_setting_changed_once); RUN_TEST(test_change_publishes_setting_changed_once);
RUN_TEST(test_transmit_requires_confirmed_region); RUN_TEST(test_transmit_requires_confirmed_region);
RUN_TEST(test_storage_keys_fit_nvs_limit); RUN_TEST(test_storage_keys_fit_nvs_limit);
RUN_TEST(test_dns_and_ntp_defaults);
RUN_TEST(test_dns_and_ntp_are_checked);
return UNITY_END(); return UNITY_END();
} }