Public Access
Every screen's keys are constant tables in lib/core/src/app_keys.h (52 of them, each under an `// id: Title` comment). An App's help() picks the table of the state it is in. site/tools/gen_dev_docs.py reads the same file and writes site/data/keys.toml; the `keys` shortcode shows a screen's tables on its guide page, and /guide/keys/ shows all of them. The Site job fails when the data file is out of date or a page asks for a table that doesn't exist, and now also runs when app_keys.h changes. A key added to an App shows up on the website without anyone editing a page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
501 lines
21 KiB
C++
501 lines
21 KiB
C++
#include "app_keys.h"
|
|
#include "wifi_settings_page.h"
|
|
|
|
#include "ipv4.h"
|
|
#include "ui/fonts.h"
|
|
#include "ui/widgets.h"
|
|
|
|
namespace roro {
|
|
|
|
using State = WifiController::State;
|
|
|
|
void WifiSettingsPage::enter() {
|
|
view_ = View::Main;
|
|
forgetDialog_.reset();
|
|
refreshMain();
|
|
}
|
|
|
|
void WifiSettingsPage::refreshMain() { main_.setCount(kFixedRows + saved_.count()); }
|
|
|
|
// With Automatic, a network's page shows only the IP row and Forget.
|
|
WifiSettingsPage::NetworkRow WifiSettingsPage::networkRow(int i) const {
|
|
if (draftFixed_) return static_cast<NetworkRow>(i);
|
|
return i == 0 ? kIpMode : kForget;
|
|
}
|
|
|
|
void WifiSettingsPage::openNetwork(const SavedNetwork& n) {
|
|
netSsid_ = n.ssid;
|
|
draftFixed_ = n.fixed;
|
|
draftAddress_ = n.fixed ? net::formatIpv4(n.ip.address) : "";
|
|
draftPrefix_ = n.fixed ? std::to_string(n.ip.prefix) : "";
|
|
draftGateway_ = n.fixed && n.ip.gateway ? net::formatIpv4(n.ip.gateway) : "";
|
|
network_.setCount(draftFixed_ ? 5 : 2);
|
|
network_.select(0);
|
|
view_ = View::Network;
|
|
}
|
|
|
|
// Leaving a network's page applies its IP setting (Q114), if it passes the checks (Q111).
|
|
bool WifiSettingsPage::leaveNetwork() {
|
|
const SavedNetwork* n = saved_.find(netSsid_);
|
|
if (!n) return true; // forgotten meanwhile
|
|
std::string why;
|
|
if (draftFixed_) {
|
|
net::FixedIp fixed;
|
|
why = net::parseFixed(draftAddress_ + "/" + draftPrefix_ + (draftGateway_.empty() ? "" : " " + draftGateway_), fixed);
|
|
if (draftAddress_.empty()) why = "A Fixed setting needs an address";
|
|
if (!why.empty()) {
|
|
warn(why);
|
|
return false;
|
|
}
|
|
bool same = n->fixed && n->ip.address == fixed.address && n->ip.prefix == fixed.prefix && n->ip.gateway == fixed.gateway;
|
|
if (same) return true;
|
|
why = saved_.setIp(netSsid_, &fixed);
|
|
if (why.empty()) warn(netSsid_ + ": fixed, " + net::formatFixed(fixed), NotificationLevel::Info);
|
|
} else {
|
|
if (!n->fixed) return true;
|
|
why = saved_.setIp(netSsid_, nullptr);
|
|
if (why.empty()) warn(netSsid_ + ": automatic (DHCP)", NotificationLevel::Info);
|
|
}
|
|
if (!why.empty()) {
|
|
warn(why);
|
|
return false;
|
|
}
|
|
wifi_.ipSettingChanged(netSsid_);
|
|
return true;
|
|
}
|
|
|
|
void WifiSettingsPage::edit(Field field, const std::string& title, const std::string& value, View from) {
|
|
field_ = field;
|
|
editTitle_ = title;
|
|
editFrom_ = from;
|
|
editor_ = LineEditor(field == Field::Ntp1 || field == Field::Ntp2 ? 63 : 15);
|
|
for (char ch : value) editor_.insert(static_cast<uint8_t>(ch));
|
|
view_ = View::Edit;
|
|
}
|
|
|
|
void WifiSettingsPage::warn(const std::string& text, NotificationLevel level) {
|
|
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level)));
|
|
}
|
|
|
|
std::string WifiSettingsPage::statusText() const {
|
|
switch (wifi_.state()) {
|
|
case State::Off: return "Off";
|
|
case State::Scanning: return "Searching...";
|
|
case State::Waiting: return "No saved network in range";
|
|
case State::Connecting: return "Joining " + wifi_.ssid() + "...";
|
|
case State::Connected: return wifi_.ssid() + " (" + std::to_string(wifi_.rssi()) + " dBm)";
|
|
case State::Monitoring: return "Monitoring";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
bool WifiSettingsPage::onKey(const KeyEvent& e) {
|
|
if (forgetDialog_) {
|
|
forgetDialog_->onKey(e);
|
|
if (forgetDialog_->result() == 1) {
|
|
saved_.forget(netSsid_);
|
|
wifi_.ipSettingChanged(netSsid_); // it may be the one in use
|
|
enter();
|
|
}
|
|
if (forgetDialog_ && forgetDialog_->result() != DialogModel::kPending) forgetDialog_.reset();
|
|
return true;
|
|
}
|
|
|
|
switch (view_) {
|
|
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<ScanEntry> found;
|
|
for (auto& n : wifi_.listScan())
|
|
if (!n.ssid.empty()) found.push_back(n);
|
|
if (wifi_.listScanDone()) scan_.setCount(static_cast<int>(found.size()));
|
|
switch (e.key) {
|
|
case Key::Up: scan_.up(); break;
|
|
case Key::Down: scan_.down(); break;
|
|
case Key::Back: view_ = View::Main; break;
|
|
case Key::Select:
|
|
if (scan_.selected() < 0) break;
|
|
newSsid_ = found[scan_.selected()].ssid;
|
|
newHidden_ = false;
|
|
if (found[scan_.selected()].open) {
|
|
std::string error = saved_.add(newSsid_, "", false);
|
|
if (!error.empty()) warn(error);
|
|
wifi_.savedNetworksChanged();
|
|
enter();
|
|
} else {
|
|
editor_ = LineEditor(63);
|
|
view_ = View::Password;
|
|
}
|
|
break;
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
case View::Ssid:
|
|
case View::Password:
|
|
switch (e.key) {
|
|
case Key::Char: 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: enter(); break;
|
|
case Key::Select:
|
|
if (view_ == View::Ssid) {
|
|
newSsid_ = editor_.text();
|
|
editor_ = LineEditor(63);
|
|
view_ = View::Password;
|
|
} else {
|
|
std::string error = saved_.add(newSsid_, editor_.text(), newHidden_);
|
|
if (!error.empty()) {
|
|
warn(error);
|
|
} else {
|
|
warn("Saved " + newSsid_, NotificationLevel::Info);
|
|
wifi_.savedNetworksChanged();
|
|
enter();
|
|
}
|
|
}
|
|
break;
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool WifiSettingsPage::mainKey(const KeyEvent& e) {
|
|
switch (e.key) {
|
|
case Key::Up: main_.up(); return true;
|
|
case Key::Down: main_.down(); return true;
|
|
case Key::Back: return false;
|
|
case Key::Left:
|
|
case Key::Right:
|
|
if (main_.selected() != kToggle) return true;
|
|
[[fallthrough]];
|
|
case Key::Select:
|
|
switch (main_.selected()) {
|
|
case kToggle: settings_.setBool(Setting::WifiEnabled, !settings_.getBool(Setting::WifiEnabled)); break;
|
|
case kStatus: view_ = View::Details; break;
|
|
case kServers:
|
|
servers_.setCount(kServerRows);
|
|
view_ = View::Servers;
|
|
break;
|
|
case kAddScanned:
|
|
wifi_.startListScan();
|
|
scan_.setCount(0);
|
|
view_ = View::Scan;
|
|
break;
|
|
case kAddHidden:
|
|
newHidden_ = true;
|
|
editor_ = LineEditor(32);
|
|
view_ = View::Ssid;
|
|
break;
|
|
default: openNetwork(saved_.at(main_.selected() - kFixedRows)); break;
|
|
}
|
|
return true;
|
|
default: return true;
|
|
}
|
|
}
|
|
|
|
// DNS and NTP servers (Q108 to Q110).
|
|
bool WifiSettingsPage::serversKey(const KeyEvent& e) {
|
|
switch (e.key) {
|
|
case Key::Up: servers_.up(); break;
|
|
case Key::Down: servers_.down(); break;
|
|
case Key::Back: view_ = View::Main; break;
|
|
case Key::Left:
|
|
case Key::Right:
|
|
if (servers_.selected() != kDnsAlways) break;
|
|
[[fallthrough]];
|
|
case Key::Select:
|
|
switch (servers_.selected()) {
|
|
case kDns1: edit(Field::Dns1, "First DNS server", settings_.getString(Setting::Dns1), View::Servers); break;
|
|
case kDns2: edit(Field::Dns2, "Second DNS server (or empty)", settings_.getString(Setting::Dns2), View::Servers); break;
|
|
case kDnsAlways:
|
|
settings_.setBool(Setting::DnsAlways, !settings_.getBool(Setting::DnsAlways));
|
|
wifi_.serversChanged();
|
|
break;
|
|
case kNtp1: edit(Field::Ntp1, "First NTP server", settings_.getString(Setting::Ntp1), View::Servers); break;
|
|
case kNtp2: edit(Field::Ntp2, "Second NTP server (or empty)", settings_.getString(Setting::Ntp2), View::Servers); break;
|
|
}
|
|
break;
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool WifiSettingsPage::networkKey(const KeyEvent& e) {
|
|
switch (e.key) {
|
|
case Key::Up: network_.up(); break;
|
|
case Key::Down: network_.down(); break;
|
|
case Key::Back:
|
|
if (leaveNetwork()) enter();
|
|
break;
|
|
case Key::Left:
|
|
case Key::Right:
|
|
if (networkRow(network_.selected()) != kIpMode) break;
|
|
[[fallthrough]];
|
|
case Key::Select:
|
|
switch (networkRow(network_.selected())) {
|
|
case kIpMode: {
|
|
draftFixed_ = !draftFixed_;
|
|
// Fixed for the first time: start from what the network gave us, if we're on it.
|
|
WifiService::Connection now = wifi_.connection();
|
|
if (draftFixed_ && draftAddress_.empty() && now.connected && wifi_.ssid() == netSsid_) {
|
|
draftAddress_ = now.address;
|
|
draftPrefix_ = std::to_string(now.prefix);
|
|
draftGateway_ = now.gateway;
|
|
}
|
|
if (draftFixed_ && draftPrefix_.empty()) draftPrefix_ = "24";
|
|
network_.setCount(draftFixed_ ? 5 : 2);
|
|
network_.select(0);
|
|
break;
|
|
}
|
|
case kAddress: edit(Field::Address, "Address", draftAddress_, View::Network); break;
|
|
case kPrefix: edit(Field::Prefix, "Prefix (1 to 30; 24 = 255.255.255.0)", draftPrefix_, View::Network); break;
|
|
case kGateway: edit(Field::Gateway, "Gateway (or empty for none)", draftGateway_, View::Network); break;
|
|
case kForget: forgetDialog_.reset(new DialogModel({"Cancel", "Forget"})); break;
|
|
}
|
|
break;
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// One field. Addresses take digits and dots only (Q112); what's typed is checked on Enter (Q111).
|
|
bool WifiSettingsPage::editKey(const KeyEvent& e) {
|
|
bool name = field_ == Field::Ntp1 || field_ == Field::Ntp2;
|
|
switch (e.key) {
|
|
case Key::Char: {
|
|
bool digit = e.ch >= '0' && e.ch <= '9';
|
|
bool ok = name ? e.ch > ' ' && e.ch < 0x7F : field_ == Field::Prefix ? digit : digit || e.ch == '.';
|
|
if (ok) editor_.insert(e.ch);
|
|
break;
|
|
}
|
|
case Key::Delete: editor_.backspace(); break;
|
|
case Key::Left: editor_.left(); break;
|
|
case Key::Right: editor_.right(); break;
|
|
case Key::Back: view_ = editFrom_; break;
|
|
case Key::Select: {
|
|
std::string text = editor_.text(), why;
|
|
uint32_t address;
|
|
switch (field_) {
|
|
case Field::Address:
|
|
if (!net::parseIpv4(text, address)) why = "Four numbers from 0 to 255, like 10.39.39.13";
|
|
else draftAddress_ = text;
|
|
break;
|
|
case Field::Prefix: {
|
|
int p = text.empty() ? 0 : atoi(text.c_str());
|
|
if (p < 1 || p > 30) why = "The prefix must be 1 to 30";
|
|
else draftPrefix_ = std::to_string(p);
|
|
break;
|
|
}
|
|
case Field::Gateway:
|
|
if (!text.empty() && !net::parseIpv4(text, address)) why = "Four numbers from 0 to 255, or empty";
|
|
else draftGateway_ = text;
|
|
break;
|
|
case Field::Dns1:
|
|
case Field::Dns2:
|
|
if (!settings_.setString(field_ == Field::Dns1 ? Setting::Dns1 : Setting::Dns2, text))
|
|
why = field_ == Field::Dns1 ? "An IPv4 address, like 9.9.9.9" : "An IPv4 address, or empty";
|
|
else wifi_.serversChanged();
|
|
break;
|
|
case Field::Ntp1:
|
|
case Field::Ntp2:
|
|
if (!settings_.setString(field_ == Field::Ntp1 ? Setting::Ntp1 : Setting::Ntp2, text))
|
|
why = field_ == Field::Ntp1 ? "A host name or an IPv4 address" : "A host name, an IPv4 address, or empty";
|
|
else wifi_.serversChanged();
|
|
break;
|
|
}
|
|
if (why.empty()) view_ = editFrom_;
|
|
else warn(why);
|
|
break;
|
|
}
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void WifiSettingsPage::draw(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
switch (view_) {
|
|
case View::Main:
|
|
widgets::list(
|
|
c, main_, area,
|
|
[this](int i) -> std::string {
|
|
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: {
|
|
const auto& n = saved_.at(i - kFixedRows);
|
|
return " " + n.ssid + (n.hidden ? " (hidden)" : "");
|
|
}
|
|
}
|
|
},
|
|
[this](int i) -> std::string {
|
|
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_) 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});
|
|
break;
|
|
}
|
|
|
|
case View::Scan: {
|
|
if (!wifi_.listScanDone()) {
|
|
widgets::textLines(c, {"Scanning..."}, 0, area);
|
|
break;
|
|
}
|
|
std::vector<ScanEntry> found;
|
|
for (auto& n : wifi_.listScan())
|
|
if (!n.ssid.empty()) found.push_back(n);
|
|
if (scan_.count() != static_cast<int>(found.size())) scan_.setCount(static_cast<int>(found.size()));
|
|
if (found.empty()) {
|
|
widgets::textLines(c, {"No networks found."}, 0, area);
|
|
break;
|
|
}
|
|
widgets::list(
|
|
c, scan_, area, [&](int i) { return found[i].ssid + (found[i].open ? " (open)" : ""); },
|
|
[&](int i) { return std::to_string(found[i].rssi) + " dBm"; });
|
|
break;
|
|
}
|
|
|
|
case View::Ssid:
|
|
case View::Password: {
|
|
c.setFont(&fonts::body);
|
|
c.setTextColor(theme::kMuted);
|
|
std::string title = view_ == View::Ssid ? "Hidden network name" : "Password for " + newSsid_;
|
|
c.drawString(title.c_str(), 4, area.y + 4);
|
|
widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0});
|
|
if (view_ == View::Password) c.drawString("Leave it empty for an open network.", 4, area.y + 44);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void WifiSettingsPage::help(std::vector<KeyHelp>& out) const {
|
|
if (forgetDialog_) return keys::add(out, keys::kDialog);
|
|
switch (view_) {
|
|
case View::Main: keys::add(out, keys::kWifi); break;
|
|
case View::Servers: keys::add(out, keys::kWifiServers); break;
|
|
case View::Network: keys::add(out, keys::kWifiNetwork); break;
|
|
case View::Details: keys::add(out, keys::kWifiStatus); break;
|
|
case View::Scan: keys::add(out, keys::kWifiScan); break;
|
|
case View::Ssid: keys::add(out, keys::kWifiName); break;
|
|
case View::Password:
|
|
case View::Edit: keys::add(out, keys::kText); break;
|
|
}
|
|
}
|
|
|
|
const char* WifiSettingsPage::helpTitle() const {
|
|
switch (view_) {
|
|
case View::Servers: return "DNS and NTP";
|
|
case View::Network: return "A saved network";
|
|
case View::Details: return "Wi-Fi status";
|
|
case View::Scan: return "Add a network";
|
|
case View::Ssid: return "Hidden network name";
|
|
case View::Password: return "Wi-Fi password";
|
|
case View::Edit: return "Wi-Fi: a field";
|
|
default: return "Wi-Fi";
|
|
}
|
|
}
|
|
|
|
// What the device is using and where each part came from (Q113).
|
|
void WifiSettingsPage::drawDetails(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
WifiService::Connection n = wifi_.connection();
|
|
std::vector<std::string> lines;
|
|
if (!n.connected) {
|
|
lines = {statusText()};
|
|
} 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
|