Public Access
S1 #7 steps 1-2: IPv4 checks, the IP setting per Saved Network, DNS and NTP settings
The plan and decisions Q105 to Q116 (docs/milestones/S1.md). lib/net: strict IPv4 parsing, prefix and mask, and the checks a Fixed setting must pass, each refusal with its reason. A Saved Network is Automatic or Fixed (address/prefix and an optional gateway), kept with it in flash. Settings: two DNS servers (9.9.9.9, 1.1.1.1), "Always use my DNS", two NTP servers (pool.ntp.org, time.cloudflare.com). Host-tested: 383 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
# 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 being built. 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).
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user