diff --git a/CONTEXT.md b/CONTEXT.md index a6c083b..dfd6ef1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -69,7 +69,7 @@ The Service that owns the Wi-Fi radio. It's always in exactly one mode: *Off*, * _Avoid_: network manager **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 **IRC Service**: diff --git a/README.md b/README.md index fc9ee3f..2f40415 100644 --- a/README.md +++ b/README.md @@ -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. +## 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 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) | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | +| `wifi ip dhcp` / `wifi ip
/ [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Debug Builds: add `try ` to go back to the previous setting unless `wifi ip keep` follows | +| `wifi dns [b]` / `wifi dns always on\|off` / `wifi ntp [b]` | DNS servers (used on Fixed networks, or always), and NTP servers | | `log ` | Appends a line to a test IRC Log (`/irc/dev/#test/.log`) | | `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 | @@ -85,7 +93,7 @@ The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **neve | `gemini trust ` | Pins a certificate by hand (the Gemini App asks when one changes) | | `irc say ` | Types into a Buffer, commands included (`irc say 0 /join #test`) | | `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 | | `tasks` | FreeRTOS tasks: state, priority, lowest free stack, CPU share | | `reboot` / `boot other` | Restart, or restart into the other app slot (a manual Rollback) | diff --git a/docs/milestones/S1.md b/docs/milestones/S1.md new file mode 100644 index 0000000..41c6575 --- /dev/null +++ b/docs/milestones/S1.md @@ -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 dhcp`, `wifi ip
/ [gateway]`, `wifi dns [b]`, `wifi ntp [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. diff --git a/lib/net/src/ipv4.cpp b/lib/net/src/ipv4.cpp new file mode 100644 index 0000000..8b4384c --- /dev/null +++ b/lib/net/src/ipv4.cpp @@ -0,0 +1,97 @@ +#include "ipv4.h" + +#include + +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(a >> 24), static_cast(a >> 16 & 255), + static_cast(a >> 8 & 255), static_cast(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(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 diff --git a/lib/net/src/ipv4.h b/lib/net/src/ipv4.h new file mode 100644 index 0000000..379553e --- /dev/null +++ b/lib/net/src/ipv4.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +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 diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index c2f0c79..bd69a54 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -1,5 +1,7 @@ #include "settings.h" +#include "ipv4.h" + namespace roro { namespace { @@ -32,6 +34,11 @@ const Definition kDefinitions[] = { {"gnss_on", Kind::Bool, 1, nullptr, 0, 1}, {"coord_dms", Kind::Bool, 0, nullptr, 0, 1}, {"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(Setting::Count), "every Setting needs a definition"); @@ -84,6 +91,11 @@ bool Settings::validString(Setting s, const std::string& value) const { if (value == region) return true; 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; } diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index bad5cb3..5b589a7 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -24,6 +24,11 @@ enum class Setting : uint8_t { GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58) 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) + 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 }; diff --git a/lib/wifi/src/saved_networks.cpp b/lib/wifi/src/saved_networks.cpp index 27f9134..56bfc47 100644 --- a/lib/wifi/src/saved_networks.cpp +++ b/lib/wifi/src/saved_networks.cpp @@ -17,6 +17,8 @@ void SavedNetworks::load() { int32_t hidden = 0; store_.getInt(key(i, "hid").c_str(), hidden); 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); } } @@ -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"; - networks_.push_back({ssid, password, hidden}); + SavedNetwork added; + added.ssid = ssid; + added.password = password; + added.hidden = hidden; + networks_.push_back(added); save(); 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) { for (auto it = networks_.begin(); it != networks_.end(); ++it) { 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, "pass").c_str(), networks_[i].password); 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()); } diff --git a/lib/wifi/src/saved_networks.h b/lib/wifi/src/saved_networks.h index 9f04f59..41e7630 100644 --- a/lib/wifi/src/saved_networks.h +++ b/lib/wifi/src/saved_networks.h @@ -3,6 +3,7 @@ #include #include +#include "ipv4.h" #include "key_value_store.h" namespace roro { @@ -11,6 +12,8 @@ struct SavedNetwork { std::string ssid; std::string password; // empty for an open network 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. @@ -28,6 +31,8 @@ class SavedNetworks { // 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); 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: void save(); diff --git a/src/apps/wifi_settings_page.cpp b/src/apps/wifi_settings_page.cpp index 7e57ced..39e39a9 100644 --- a/src/apps/wifi_settings_page.cpp +++ b/src/apps/wifi_settings_page.cpp @@ -1,5 +1,6 @@ #include "wifi_settings_page.h" +#include "ipv4.h" #include "ui/fonts.h" #include "ui/widgets.h" @@ -15,6 +16,62 @@ void WifiSettingsPage::enter() { 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(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(ch)); + view_ = View::Edit; +} + void WifiSettingsPage::warn(const std::string& text, NotificationLevel level) { bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast(level))); } @@ -35,42 +92,22 @@ bool WifiSettingsPage::onKey(const KeyEvent& e) { if (forgetDialog_) { forgetDialog_->onKey(e); if (forgetDialog_->result() == 1) { - saved_.forget(saved_.at(main_.selected() - kFixedRows).ssid); - refreshMain(); + saved_.forget(netSsid_); + 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; } switch (view_) { - case View::Main: - 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: 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; - default: return true; - } + case View::Main: return mainKey(e); + case View::Servers: return serversKey(e); + case View::Network: return networkKey(e); + case View::Edit: return editKey(e); + case View::Details: + if (e.key == Key::Back || e.key == Key::Select) view_ = View::Main; + return true; case View::Scan: { std::vector found; @@ -131,6 +168,159 @@ bool WifiSettingsPage::onKey(const KeyEvent& e) { 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) { const auto& area = theme::kContent; switch (view_) { @@ -141,6 +331,7 @@ void WifiSettingsPage::draw(Canvas& c) { switch (i) { case kToggle: return "Wi-Fi"; case kStatus: return "Status"; + case kServers: return "DNS and NTP"; case kAddScanned: return "Add a network"; case kAddHidden: return "Add a hidden network"; default: { @@ -153,14 +344,78 @@ void WifiSettingsPage::draw(Canvas& c) { switch (i) { case kToggle: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off"; case kStatus: return statusText(); + case kServers: case kAddScanned: 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 ""; } }); - if (forgetDialog_ && main_.selected() >= kFixedRows) - widgets::dialog(c, "Forget network?", saved_.at(main_.selected() - kFixedRows).ssid, *forgetDialog_); + if (forgetDialog_) widgets::dialog(c, "Forget network?", netSsid_, *forgetDialog_); 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: { 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 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 diff --git a/src/apps/wifi_settings_page.h b/src/apps/wifi_settings_page.h index 6984fa1..d69f4cf 100644 --- a/src/apps/wifi_settings_page.h +++ b/src/apps/wifi_settings_page.h @@ -16,8 +16,9 @@ namespace roro { -// Settings → Wi-Fi: the On/Off switch, current connection, Saved Networks, and adding networks -// from a scan or by name (hidden). Owned by the Settings App. +// Settings → Wi-Fi: the On/Off switch, the connection and its details, DNS and NTP servers, and +// 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 { public: WifiSettingsPage(Settings& settings, SavedNetworks& saved, WifiService& wifi, EventBus& bus) @@ -25,17 +26,31 @@ class WifiSettingsPage { void enter(); bool onKey(const KeyEvent& e); // false: leave the page - bool textEntryActive() const { return view_ == View::Ssid || view_ == View::Password; } - bool live() const { return view_ == View::Main || view_ == View::Scan; } // redraw periodically + bool textEntryActive() const { return view_ == View::Ssid || view_ == View::Password || view_ == View::Edit; } + // 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); private: - enum class View { Main, Scan, Ssid, Password }; - enum Fixed { kToggle, kStatus, kAddScanned, kAddHidden, kFixedRows }; + enum class View { Main, Scan, Ssid, Password, Details, Servers, Network, Edit }; + 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 warn(const std::string& text, NotificationLevel level = NotificationLevel::Warning); 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_; SavedNetworks& saved_; @@ -44,10 +59,21 @@ class WifiSettingsPage { View view_ = View::Main; ListModel main_{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}; std::string newSsid_; bool newHidden_ = false; std::unique_ptr 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 diff --git a/src/main.cpp b/src/main.cpp index 36beec4..5ee043b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include "apps/setup_app.h" #include "event_bus.h" #include "file_receiver.h" +#include "ipv4.h" #include "key_mapper.h" #include "platform/console.h" #include "platform/crash_report.h" @@ -359,6 +360,27 @@ static void uploadStep() { } } +#ifdef RORO_DEBUG +// `wifi ip ... try `: 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(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 = "info firmware, uptime, memory, Wi-Fi, app slots\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" "key press a key: up down left right select back home del tab space, or one character\n" "wifi status | wifi add \n" + "wifi ip dhcp | wifi ip
/ [gateway] a Saved Network's IP setting\n" + "wifi dns [b] | wifi dns always on|off | wifi ntp [b] DNS and NTP servers\n" "gemini get fetch a Gemini page and report header, size, certificate, heap\n" "irc start | irc stop | irc dump | irc say \n" "ls [folder] | rm | install (Update from SD)\n" "sd card | sd list | cat | log | burst | sound on|off | short | normal\n" #ifdef RORO_DEBUG "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" + "wifi ip ... try | wifi ip keep a trial IP setting: back to the previous one unless kept\n" "lora inject [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" "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", (int)wifi->state(), wifi->ssid().c_str(), wifi->rssi(), wifi->ip().c_str(), clockService->displayTime().c_str(), ESP.getFreeHeap(), ESP.getMinFreeHeap(), versionString(), 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 dhcp |
/ [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 dhcp |
/ [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 ")) { // [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 on") settings.setBool(Setting::Sound, true); if (line == "short") { @@ -671,6 +762,9 @@ void loop() { serialCommands(); remoteCommands(); +#ifdef RORO_DEBUG + ipTrialStep(); +#endif noteStableOnce(now); uploadStep(); printListingWhenReady(); diff --git a/src/services/wifi_service.cpp b/src/services/wifi_service.cpp index 536c1f0..f7971a9 100644 --- a/src/services/wifi_service.cpp +++ b/src/services/wifi_service.cpp @@ -3,10 +3,121 @@ #include #include #include +#include + +#include + +#include "ipv4.h" 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(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 { 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.setAutoReconnect(false); // the controller decides when to reconnect esp_wifi_set_country_code("EU", true); + esp_sntp_servermode_dhcp(true); // before DHCP runs: take the NTP servers it offers (Q110) radioInitialised_ = true; } if (scanRunning_) return; @@ -87,13 +199,7 @@ void WifiService::finishScan(uint32_t nowMs) { void WifiService::startNtp() { // Not configTime(): it would overwrite the POSIX TZ the Clock applies. - if (!esp_sntp_enabled()) { - esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL); - esp_sntp_setservername(0, "pool.ntp.org"); - esp_sntp_init(); - } else { - esp_sntp_restart(); - } + applyServers(Why::Joined); ntpWaiting_ = true; } @@ -102,7 +208,7 @@ void WifiService::apply(const WifiController::Step& step) { switch (step.action) { case Action::None: 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::RadioOff: WiFi.disconnect(true); @@ -143,6 +249,8 @@ void WifiService::tick(uint32_t nowMs) { 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) { ntpWaiting_ = false; clock_.set(static_cast(time(nullptr)), TimeSource::Ntp); diff --git a/src/services/wifi_service.h b/src/services/wifi_service.h index a5c2910..53be347 100644 --- a/src/services/wifi_service.h +++ b/src/services/wifi_service.h @@ -36,6 +36,27 @@ class WifiService : public Service { // Done with list scans: switch the radio back off if Wi-Fi itself is off. 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. void savedNetworksChanged() { controller_.retryNow(millis()); } @@ -47,6 +68,9 @@ class WifiService : public Service { void startScan(int channel = 0); void finishScan(uint32_t nowMs); 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_; SavedNetworks& saved_; @@ -58,6 +82,10 @@ class WifiService : public Service { uint32_t listScanSeq_ = 0; bool ntpWaiting_ = 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 diff --git a/test/test_ipv4/test_ipv4.cpp b/test/test_ipv4/test_ipv4.cpp new file mode 100644 index 0000000..843f99c --- /dev/null +++ b/test/test_ipv4/test_ipv4.cpp @@ -0,0 +1,101 @@ +#include + +#include + +#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(); +} diff --git a/test/test_saved_networks/test_saved_networks.cpp b/test/test_saved_networks/test_saved_networks.cpp index 648d5d6..72d0ac7 100644 --- a/test/test_saved_networks/test_saved_networks.cpp +++ b/test/test_saved_networks/test_saved_networks.cpp @@ -97,6 +97,76 @@ void test_find() { 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() { UNITY_BEGIN(); RUN_TEST(test_empty_store_has_no_networks); @@ -107,5 +177,10 @@ int main() { RUN_TEST(test_validation_follows_wifi_limits); RUN_TEST(test_hidden_flag_survives_reload); 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(); } diff --git a/test/test_settings/test_settings.cpp b/test/test_settings/test_settings.cpp index 1939e71..07c1ee3 100644 --- a/test/test_settings/test_settings.cpp +++ b/test/test_settings/test_settings.cpp @@ -154,6 +154,37 @@ void test_storage_keys_fit_nvs_limit() { TEST_ASSERT_TRUE(std::strlen(Settings::key(static_cast(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() { UNITY_BEGIN(); 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_transmit_requires_confirmed_region); 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(); }