diff --git a/CONTEXT.md b/CONTEXT.md index c9011ff..298cf9c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -91,7 +91,7 @@ The `opt` key used as a dead key. Pressing it and then a base letter types an ac _Avoid_: modifier, alt **Log**: -History the device records automatically in the background: mesh message history, IRC logs, probe-request logs. +History the device records automatically in the background: mesh message history, IRC logs, Wi-Fi scan logs. _Avoid_: history file, dump **Capture**: diff --git a/README.md b/README.md index e1aad6f..ddd1e58 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,13 @@ sudo usermod -aG dialout "$USER" | Command | Effect | |---|---| | `burst` | Publishes 5 Notifications at once | -| `key up\|down\|left\|right\|select\|back\|home` | Injects a logical key press | +| `key up\|down\|left\|right\|select\|back\|home`, or `key ` | Injects a key press | | `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) | | `log ` | Appends a line to a test IRC Log (`/irc/dev/#test/.log`) | | `sd list` | Lists the files of each Storage Clean-up category | +| `cat ` | Prints the first ~1.2 KB of a file on the SD card | | `irc start` | Starts the IRC Service (normally done by opening the IRC App) | | `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 | diff --git a/docs/milestones/M1.md b/docs/milestones/M1.md index 3d468e8..b4e50de 100644 --- a/docs/milestones/M1.md +++ b/docs/milestones/M1.md @@ -10,7 +10,7 @@ The "Done when" items below work on the device, against irc.libera.chat over TLS - **Wi-Fi Tools is built from ordinary scans only:** networks nearby, channel occupancy and a signal tracker. Monitoring-mode views and captures are deferred (revised Q42). The Monitoring mode stays in the Wi-Fi Service and the IRC pause logic, unused for now. - **The frame buffer is 8-bit colour** (Q46), applied as soon as IRC on TLS measured a 51 KB low. - **Not done, carried over:** - - **Probe-request logs and Wi-Fi captures** are still listed as Storage Clean-up categories, but nothing writes to them yet. + - **Wi-Fi scan logs** (a CSV of the access points each scan sees) can be recorded from Wi-Fi Tools; monitoring-mode captures remain deferred. - **IRC has no input history** (up-arrow recall). Each Buffer keeps only 50 lines in RAM; the full history is in the Logs. - **Measured on the device:** - With Wi-Fi and IRC on TLS: about 92 KB free heap, about 77–81 KB at the lowest. The floor was 40 KB. diff --git a/lib/irc/src/irc_config.cpp b/lib/irc/src/irc_config.cpp index 6a380f1..ff8d7a4 100644 --- a/lib/irc/src/irc_config.cpp +++ b/lib/irc/src/irc_config.cpp @@ -36,7 +36,8 @@ std::string IrcConfig::validate() const { if (port < 1 || port > 65535) return "Port must be 1 to 65535"; if (!validNick(nick)) return "Nick: letters, digits and []\\`_^{|}- only, not starting with a digit"; for (auto& c : autojoin) - if (c.size() < 2 || (c[0] != '#' && c[0] != '&')) return "IRC channels start with # or &"; + if (c.channel.size() < 2 || (c.channel[0] != '#' && c.channel[0] != '&')) + return "Auto-join: IRC channels start with # or &, each followed by its key if it has one"; return ""; } @@ -56,23 +57,27 @@ std::string IrcConfig::save() { return ""; } -std::vector IrcConfig::parseChannels(const std::string& text) { - std::vector out; - std::string current; - for (char c : text + " ") { - if (c == ' ' || c == ',') { - if (!current.empty()) out.push_back(current); - current.clear(); - } else { - current += c; - } +std::vector IrcConfig::parseChannels(const std::string& text) { + std::vector out; + std::string word; + auto take = [&]() { + if (word.empty()) return; + bool channel = word[0] == '#' || word[0] == '&'; + if (!channel && !out.empty() && out.back().key.empty()) out.back().key = word; + else out.push_back({word, ""}); // a stray word fails validation as a channel + word.clear(); + }; + for (char c : text) { + if (c == ' ' || c == ',') take(); + else word += c; } + take(); return out; } -std::string IrcConfig::formatChannels(const std::vector& channels) { +std::string IrcConfig::formatChannels(const std::vector& channels) { std::string out; - for (auto& c : channels) out += (out.empty() ? "" : " ") + c; + for (auto& c : channels) out += (out.empty() ? "" : ", ") + c.channel + (c.key.empty() ? "" : " " + c.key); return out; } diff --git a/lib/irc/src/irc_config.h b/lib/irc/src/irc_config.h index 144e130..3b48b57 100644 --- a/lib/irc/src/irc_config.h +++ b/lib/irc/src/irc_config.h @@ -7,6 +7,11 @@ namespace roro { +struct IrcChannel { + std::string channel; // "#roro" + std::string key; // empty when the IRC channel has none +}; + // The one IRC server the IRC Service connects to, persisted in internal flash. class IrcConfig { public: @@ -20,7 +25,7 @@ class IrcConfig { std::string nick; std::string saslUser, saslPassword; // SASL PLAIN when both are set std::string nickservPassword; // otherwise IDENTIFY with NickServ, if set - std::vector autojoin; + std::vector autojoin; // A copy is a draft the UI can edit freely; copySettingsFrom() applies one. IrcConfig(const IrcConfig&) = default; @@ -41,8 +46,10 @@ class IrcConfig { std::string save(); // empty on success, otherwise why it was refused std::string validate() const; - static std::vector parseChannels(const std::string& text); - static std::string formatChannels(const std::vector& channels); + // "#private key, #public": a word starting with # or & is an IRC channel, the word after it + // (if it doesn't) its key. Spaces or commas separate entries, so "#a #b" also reads. + static std::vector parseChannels(const std::string& text); + static std::string formatChannels(const std::vector& channels); private: KeyValueStore& store_; diff --git a/lib/irc/src/irc_session.cpp b/lib/irc/src/irc_session.cpp index b74f5c7..4f698ee 100644 --- a/lib/irc/src/irc_session.cpp +++ b/lib/irc/src/irc_session.cpp @@ -37,6 +37,13 @@ std::pair firstWord(const std::string& text) { IrcSession::IrcSession(const IrcConfig& config) : config_(config), nick_(config.nick) { buffers_.push_back({config.host, IrcBuffer::Type::Server, {}, 0, false, true, ""}); + for (auto& c : config.autojoin) + if (!c.key.empty()) keys_[lower(c.channel)] = c.key; +} + +void IrcSession::tick(uint32_t nowMs) { + nowMs_ = nowMs; + if (joinsHeld_ && nowMs - heldSinceMs_ >= kNickservWaitMs) joinChannels(); } int IrcSession::totalUnread() const { @@ -108,6 +115,8 @@ void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const s void IrcSession::connected(int64_t) { registered_ = false; + joinsHeld_ = false; + saslFailed_ = false; quit_ = false; nick_ = config_.nick; if (!config_.saslUser.empty() && !config_.saslPassword.empty()) send("CAP REQ :sasl"); @@ -130,18 +139,49 @@ void IrcSession::onWelcome(const IrcMessage& m, int64_t utc) { registered_ = true; nick_ = m.param(0); info(0, "Connected to " + config_.host + " as " + nick_, utc); - if (!config_.nickservPassword.empty() && config_.saslUser.empty()) - send(IrcMessage::serialize("PRIVMSG", {"NickServ", "IDENTIFY " + config_.nickservPassword})); - - std::vector channels = config_.autojoin; - for (auto& c : rejoin_) - if (std::find(channels.begin(), channels.end(), c) == channels.end()) channels.push_back(c); - rejoin_.clear(); - if (!channels.empty()) { - std::string list; - for (auto& c : channels) list += (list.empty() ? "" : ",") + c; - send(IrcMessage::serialize("JOIN", {list})); + // NickServ when it's the configured login, or as the fallback when SASL failed. + // The account is named explicitly: we may be on a fallback nick if a stale session holds ours. + bool sasl = !config_.saslUser.empty() && !config_.saslPassword.empty(); + std::string account = sasl ? config_.saslUser : config_.nick; + std::string identify; + if (!config_.nickservPassword.empty() && (!sasl || saslFailed_)) identify = account + " " + config_.nickservPassword; + else if (sasl && saslFailed_) identify = account + " " + config_.saslPassword; + if (!identify.empty()) { + // IRC channels for registered users only would refuse us until NickServ has logged us in. + send(IrcMessage::serialize("PRIVMSG", {"NickServ", "IDENTIFY " + identify})); + joinsHeld_ = true; + heldSinceMs_ = nowMs_; + return; } + joinChannels(); +} + +void IrcSession::joinChannels() { + joinsHeld_ = false; + std::vector channels; + auto addOnce = [&](const std::string& c) { + for (auto& existing : channels) + if (lower(existing) == lower(c)) return; + channels.push_back(c); + }; + for (auto& c : config_.autojoin) addOnce(c.channel); + for (auto& c : rejoin_) addOnce(c); + rejoin_.clear(); + if (channels.empty()) return; + + // JOIN #keyed,#open key: IRC pairs keys with the first channels listed. + std::string keyed, open, keys; + for (auto& c : channels) { + auto k = keys_.find(lower(c)); + if (k != keys_.end()) { + keyed += (keyed.empty() ? "" : ",") + c; + keys += (keys.empty() ? "" : ",") + k->second; + } else { + open += (open.empty() ? "" : ",") + c; + } + } + std::string list = keyed + (!keyed.empty() && !open.empty() ? "," : "") + open; + send(keys.empty() ? IrcMessage::serialize("JOIN", {list}) : IrcMessage::serialize("JOIN", {list, keys})); } void IrcSession::receive(const std::string& raw, int64_t utc) { @@ -163,11 +203,17 @@ void IrcSession::receive(const std::string& raw, int64_t utc) { } else if (cmd == "AUTHENTICATE" && m.param(0) == "+") { std::string user = config_.saslUser; send("AUTHENTICATE " + base64Encode(user + '\0' + user + '\0' + config_.saslPassword)); + } else if (cmd == "900") { + // Logged in. Take our nick back from a stale session, then join. + if (lower(nick_) != lower(config_.nick)) + send(IrcMessage::serialize("PRIVMSG", {"NickServ", "REGAIN " + config_.nick})); + if (joinsHeld_) joinChannels(); } else if (cmd == "903") { info(0, "SASL login succeeded", utc); send("CAP END"); } else if (cmd == "904" || cmd == "905" || cmd == "906" || cmd == "902") { - info(0, "SASL login failed: " + m.param(m.params.size() - 1), utc); + info(0, "SASL login failed: " + m.param(m.params.size() - 1) + " (trying NickServ instead)", utc); + saslFailed_ = true; send("CAP END"); } else if (cmd == "PRIVMSG" || cmd == "NOTICE") { onPrivmsg(m, utc, cmd == "NOTICE"); @@ -280,10 +326,11 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) { const IrcBuffer& buf = buffers_[b]; bool inChannel = buf.type == IrcBuffer::Type::Channel; - if (verb == "join") { + if (verb == "join" || verb == "j") { auto [channel, key] = firstWord(rest); if (channel.empty()) return info(b, "Usage: /join #channel", utc); if (!isChannel(channel)) channel = "#" + channel; + if (!key.empty()) keys_[lower(channel)] = key; // reused when rejoining send(key.empty() ? IrcMessage::serialize("JOIN", {channel}) : IrcMessage::serialize("JOIN", {channel, key})); } else if (verb == "part") { std::string channel = inChannel ? buf.name : ""; @@ -315,7 +362,7 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) { } else if (verb == "raw" || verb == "quote") { if (!rest.empty()) send(rest); } else { - info(b, "Unknown command /" + verb + " (try /join /part /msg /me /nick /topic /names /quit /raw)", utc); + info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /quit /raw)", utc); } } diff --git a/lib/irc/src/irc_session.h b/lib/irc/src/irc_session.h index 7d2aba6..2724414 100644 --- a/lib/irc/src/irc_session.h +++ b/lib/irc/src/irc_session.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -53,6 +54,12 @@ class IrcSession { explicit IrcSession(const IrcConfig& config); + // Joins wait this long for NickServ to confirm a login before going ahead anyway. + static constexpr uint32_t kNickservWaitMs = 2000; + + // Uptime in ms, called regularly: releases joins held back for NickServ. + void tick(uint32_t nowMs); + void connected(int64_t utc); void disconnected(int64_t utc, const std::string& reason); void receive(const std::string& raw, int64_t utc); @@ -83,6 +90,7 @@ class IrcSession { bool mentionsMe(const std::string& text) const; void onPrivmsg(const IrcMessage& m, int64_t utc, bool notice); void onWelcome(const IrcMessage& m, int64_t utc); + void joinChannels(); void command(int buffer, const std::string& text, int64_t utc); void say(int buffer, const std::string& text, int64_t utc, bool action); @@ -94,6 +102,11 @@ class IrcSession { int viewing_ = -1; std::set namesRequested_; std::vector rejoin_; // IRC channels to join again after a reconnect + std::map keys_; // lower-case IRC channel -> key, from config and /join + bool joinsHeld_ = false; // waiting for NickServ before joining + bool saslFailed_ = false; + uint32_t heldSinceMs_ = 0; + uint32_t nowMs_ = 0; IrcEffects effects_; uint32_t revision_ = 0; }; diff --git a/lib/storage_model/src/cleanup_plan.h b/lib/storage_model/src/cleanup_plan.h index 68c2605..c548e7e 100644 --- a/lib/storage_model/src/cleanup_plan.h +++ b/lib/storage_model/src/cleanup_plan.h @@ -18,7 +18,7 @@ struct CleanupCategory { }; inline const CleanupCategory kCleanupCategories[] = { {"IRC logs", "/irc"}, - {"Probe-request logs", "/wifi/probes"}, + {"Wi-Fi scan logs", "/wifi/scans"}, {"Wi-Fi captures", "/captures/wifi"}, }; diff --git a/lib/ui/src/input_history.h b/lib/ui/src/input_history.h new file mode 100644 index 0000000..29e79f3 --- /dev/null +++ b/lib/ui/src/input_history.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +namespace roro { + +// Lines already entered, browsed shell-style: up() goes back in time, down() forward, and past the +// newest line the draft being typed comes back. +class InputHistory { + public: + explicit InputHistory(size_t capacity) : capacity_(capacity) {} + + void add(const std::string& line) { + browsing_ = -1; + if (line.empty() || (!lines_.empty() && lines_.back() == line)) return; + lines_.push_back(line); + if (lines_.size() > capacity_) lines_.pop_front(); + } + + // `current` is what the editor holds now (kept as the draft when browsing starts). + bool up(const std::string& current, std::string& out) { + if (lines_.empty() || browsing_ == 0) return false; + if (browsing_ < 0) { + draft_ = current; + browsing_ = static_cast(lines_.size()); + } + out = lines_[--browsing_]; + return true; + } + + bool down(std::string& out) { + if (browsing_ < 0) return false; + if (++browsing_ >= static_cast(lines_.size())) { + browsing_ = -1; + out = draft_; + } else { + out = lines_[browsing_]; + } + return true; + } + + private: + size_t capacity_; + std::deque lines_; + int browsing_ = -1; // index being shown, -1 when editing the draft + std::string draft_; +}; + +} // namespace roro diff --git a/lib/wifi/src/network_list_view.cpp b/lib/wifi/src/network_list_view.cpp new file mode 100644 index 0000000..aa3b0ff --- /dev/null +++ b/lib/wifi/src/network_list_view.cpp @@ -0,0 +1,59 @@ +#include "network_list_view.h" + +#include +#include + +namespace roro { + +namespace { +std::string lower(std::string s) { + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} +} // namespace + +std::vector orderNetworks(const std::vector& entries, WifiSort sort, const NetworkFilter& filter) { + std::vector idx; + for (int i = 0; i < static_cast(entries.size()); i++) { + const ScanEntry& e = entries[i]; + if (filter.openOnly && !e.open) continue; + if (filter.hideHidden && e.ssid.empty()) continue; + if (e.rssi < filter.minRssi) continue; + idx.push_back(i); + } + std::stable_sort(idx.begin(), idx.end(), [&](int a, int b) { + const ScanEntry& x = entries[a]; + const ScanEntry& y = entries[b]; + switch (sort) { + case WifiSort::Signal: return x.rssi > y.rssi; // strongest first + case WifiSort::Channel: return x.channel != y.channel ? x.channel < y.channel : x.rssi > y.rssi; + case WifiSort::Name: { + // Hidden networks (no name) sort to the end. + if (x.ssid.empty() != y.ssid.empty()) return y.ssid.empty(); + return lower(x.ssid) < lower(y.ssid); + } + } + return false; + }); + return idx; +} + +const char* sortName(WifiSort sort) { + switch (sort) { + case WifiSort::Signal: return "signal"; + case WifiSort::Channel: return "channel"; + case WifiSort::Name: return "name"; + } + return ""; +} + +WifiSort nextSort(WifiSort sort) { + switch (sort) { + case WifiSort::Signal: return WifiSort::Channel; + case WifiSort::Channel: return WifiSort::Name; + case WifiSort::Name: return WifiSort::Signal; + } + return WifiSort::Signal; +} + +} // namespace roro diff --git a/lib/wifi/src/network_list_view.h b/lib/wifi/src/network_list_view.h new file mode 100644 index 0000000..83973c2 --- /dev/null +++ b/lib/wifi/src/network_list_view.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include "scan_entry.h" + +namespace roro { + +enum class WifiSort { Signal, Channel, Name }; + +struct NetworkFilter { + bool openOnly = false; + bool hideHidden = false; + int minRssi = -100; // drop anything weaker +}; + +// The order and selection the network list shows: indices into `entries`, sorted and filtered. +// Stable, so equal rows keep their scan order. +std::vector orderNetworks(const std::vector& entries, WifiSort sort, const NetworkFilter& filter); + +const char* sortName(WifiSort sort); +WifiSort nextSort(WifiSort sort); + +} // namespace roro diff --git a/lib/wifi/src/scan_entry.h b/lib/wifi/src/scan_entry.h new file mode 100644 index 0000000..b8ba7b4 --- /dev/null +++ b/lib/wifi/src/scan_entry.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace roro { + +// One access point an ordinary Wi-Fi scan reported (a network announcing itself). No client or +// monitor-mode data: just what a beacon broadcasts. +struct ScanEntry { + std::string ssid; // empty for a hidden network + std::string bssid; // the access point's own address, from its beacon + int channel; + int rssi; + bool open; + std::string security; // "open", "WPA2", "WPA3"... +}; + +} // namespace roro diff --git a/lib/wifi/src/scan_log.cpp b/lib/wifi/src/scan_log.cpp new file mode 100644 index 0000000..0cb5cbf --- /dev/null +++ b/lib/wifi/src/scan_log.cpp @@ -0,0 +1,27 @@ +#include "scan_log.h" + +namespace roro::scanlog { + +std::string csvField(const std::string& value) { + bool needsQuote = false; + for (char c : value) + if (c == ',' || c == '"' || c == '\n' || c == '\r') needsQuote = true; + if (!needsQuote) return value; + std::string out = "\""; + for (char c : value) { + if (c == '"') out += '"'; // escape by doubling + out += c; + } + out += '"'; + return out; +} + +std::string header() { return "time,bssid,channel,rssi,security,ssid"; } + +std::string row(const std::string& timestamp, const ScanEntry& ap) { + return csvField(timestamp) + "," + csvField(ap.bssid) + "," + std::to_string(ap.channel) + "," + + std::to_string(ap.rssi) + "," + csvField(ap.security) + "," + + csvField(ap.ssid.empty() ? "(hidden)" : ap.ssid); +} + +} // namespace roro::scanlog diff --git a/lib/wifi/src/scan_log.h b/lib/wifi/src/scan_log.h new file mode 100644 index 0000000..fedd6a8 --- /dev/null +++ b/lib/wifi/src/scan_log.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include + +#include "scan_entry.h" + +namespace roro { + +// Writes what Wi-Fi Tools already sees (access points, channel, signal) to a CSV Log, so the user +// can track their own Wi-Fi environment over time. One row per access point per logged scan. +namespace scanlog { + +// A CSV field, quoted when it contains a comma, quote or newline. +std::string csvField(const std::string& value); + +// The header row (no trailing newline). +std::string header(); + +// One data row for an access point seen at `timestamp` (a preformatted local time string). +std::string row(const std::string& timestamp, const ScanEntry& ap); + +} // namespace scanlog + +// Decides when to write a scan to the Log: at most once per interval. +class ScanLogThrottle { + public: + explicit ScanLogThrottle(uint32_t intervalMs) : intervalMs_(intervalMs) {} + bool due(uint32_t nowMs) const { return !logged_ || nowMs - lastMs_ >= intervalMs_; } + void logged(uint32_t nowMs) { + lastMs_ = nowMs; + logged_ = true; + } + void reset() { logged_ = false; } + + private: + uint32_t intervalMs_; + uint32_t lastMs_ = 0; + bool logged_ = false; +}; + +} // namespace roro diff --git a/src/apps/irc_app.cpp b/src/apps/irc_app.cpp index c36c8cf..cff1930 100644 --- a/src/apps/irc_app.cpp +++ b/src/apps/irc_app.cpp @@ -80,13 +80,24 @@ bool IrcApp::onKey(const KeyEvent& e) { } bool IrcApp::onChatKey(const KeyEvent& e) { + // Alt + ; / Alt + . scroll the Buffer; Up / Down (Fn + ; / Fn + .) recall sent lines. + if (e.key == Key::Char && e.alt && (e.ch == ';' || e.ch == '.')) { + if (e.ch == ';') scroll_++; // older + else if (scroll_ > 0) scroll_--; + return true; + } + std::string recalled; switch (e.key) { case Key::Char: input_.insert(e.ch); break; case Key::Delete: input_.backspace(); break; case Key::Left: input_.left(); break; case Key::Right: input_.right(); break; - case Key::Up: scroll_++; break; // older - case Key::Down: if (scroll_ > 0) scroll_--; break; + case Key::Up: + if (history_.up(input_.text(), recalled)) input_.setText(recalled); + break; + case Key::Down: + if (history_.down(recalled)) input_.setText(recalled); + break; case Key::Tab: { int count = irc_.withSession([](IrcSession& s) { return s.bufferCount(); }); view((current_ + 1) % count); @@ -95,6 +106,7 @@ bool IrcApp::onChatKey(const KeyEvent& e) { case Key::Select: { std::string text = input_.text(); if (text.empty()) break; + history_.add(text); input_.setText(""); scroll_ = 0; if (lowered(text) == "/settings") { @@ -294,7 +306,7 @@ void IrcApp::drawSettings(Canvas& c) { c.setTextColor(theme::kMuted); c.drawString(fieldLabel(fields_.selected()).c_str(), 4, area.y + 4); widgets::lineEditor(c, fieldEditor_, {4, area.y + 22, area.w - 8, 0}); - c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro #meshtastic" : "Enter: OK `: cancel", 4, + c.drawString(fields_.selected() == kAutojoin ? "e.g. #roro, #private key" : "Enter: OK `: cancel", 4, area.y + 44); return; } diff --git a/src/apps/irc_app.h b/src/apps/irc_app.h index 77e8215..6bcd375 100644 --- a/src/apps/irc_app.h +++ b/src/apps/irc_app.h @@ -5,6 +5,7 @@ #include "app.h" #include "event_bus.h" +#include "input_history.h" #include "line_editor.h" #include "list_model.h" #include "services/irc_service.h" @@ -48,6 +49,7 @@ class IrcApp : public App { uint32_t seenRevision_ = 0; IrcService::Status seenStatus_ = IrcService::Status::Stopped; LineEditor input_{400}; + InputHistory history_{30}; ListModel fields_{theme::kContent.h / theme::kLineHeight}; bool editing_ = false; LineEditor fieldEditor_{63}; diff --git a/src/apps/wifi_tools_app.cpp b/src/apps/wifi_tools_app.cpp index d03c000..d26daa9 100644 --- a/src/apps/wifi_tools_app.cpp +++ b/src/apps/wifi_tools_app.cpp @@ -14,6 +14,7 @@ namespace { const char* const kMenu[] = {"Networks nearby", "Channel occupancy", "Signal tracker"}; constexpr uint32_t kListRescanMs = 4000; constexpr uint32_t kTrackRescanMs = 400; +constexpr int kWeakRssi = -75; } // namespace void WifiToolsApp::onEnter() { @@ -21,7 +22,10 @@ void WifiToolsApp::onEnter() { menu_.setCount(3); } -void WifiToolsApp::onExit() { wifi_.endListScans(); } +void WifiToolsApp::onExit() { + logging_ = false; // logging only runs while this foreground App is scanning + wifi_.endListScans(); +} void WifiToolsApp::open(View v) { view_ = v; @@ -29,10 +33,9 @@ void WifiToolsApp::open(View v) { requestRedraw(); } -std::vector WifiToolsApp::sortedScan() const { - auto list = wifi_.listScan(); - std::sort(list.begin(), list.end(), [](const ScanEntry& a, const ScanEntry& b) { return a.rssi > b.rssi; }); - return list; +void WifiToolsApp::refreshOrder() { + order_ = orderNetworks(wifi_.listScan(), sort_, filter_); + networks_.setCount(static_cast(order_.size())); } bool WifiToolsApp::onKey(const KeyEvent& e) { @@ -42,36 +45,42 @@ bool WifiToolsApp::onKey(const KeyEvent& e) { switch (e.key) { case Key::Up: menu_.up(); return true; case Key::Down: menu_.down(); return true; - case Key::Select: - if (menu_.selected() == 2) { - // The tracker follows an access point picked from the list. - open(View::Networks); - } else { - open(menu_.selected() == 0 ? View::Networks : View::Channels); - } - return true; + case Key::Select: open(menu_.selected() == 1 ? View::Channels : View::Networks); return true; default: return false; } case View::Networks: switch (e.key) { - case Key::Up: networks_.up(); break; - case Key::Down: networks_.down(); break; - case Key::Back: view_ = View::Menu; break; + case Key::Up: networks_.up(); return true; + case Key::Down: networks_.down(); return true; + case Key::Back: view_ = View::Menu; return true; case Key::Select: { - auto list = sortedScan(); - if (networks_.selected() < 0 || networks_.selected() >= static_cast(list.size())) break; - const ScanEntry& ap = list[networks_.selected()]; + if (networks_.selected() < 0 || networks_.selected() >= static_cast(order_.size())) return true; + const ScanEntry& ap = wifi_.listScan()[order_[networks_.selected()]]; targetBssid_ = ap.bssid; targetName_ = ap.ssid.empty() ? "(hidden network)" : ap.ssid; targetChannel_ = ap.channel; tracker_ = SignalTracker(110); tracker_.seen(ap.rssi, millis()); open(View::Tracker); - break; + return true; } - default: break; + case Key::Char: + switch (e.ch) { + case 's': sort_ = nextSort(sort_); break; + case 'o': filter_.openOnly = !filter_.openOnly; break; + case 'h': filter_.hideHidden = !filter_.hideHidden; break; + case 'w': filter_.minRssi = filter_.minRssi == kWeakRssi ? -100 : kWeakRssi; break; + case 'l': + logging_ = !logging_; + logThrottle_.reset(); + if (logging_) loggedRows_ = 0; + break; + default: return true; + } + refreshOrder(); + return true; + default: return true; } - return true; case View::Channels: if (e.key == Key::Back) view_ = View::Menu; return true; @@ -83,6 +92,23 @@ bool WifiToolsApp::onKey(const KeyEvent& e) { return false; } +void WifiToolsApp::logScan(uint32_t nowMs) { + std::string date = clock_.localDate(); + if (date.empty()) return; // no timestamp without a clock + if (!logThrottle_.due(nowMs)) return; + std::string path = "/wifi/scans/" + date + ".csv"; + if (date != headerDate_) { + storage_.appendLine(path, scanlog::header()); + headerDate_ = date; + } + std::string stamp = date + " " + clock_.displayTime(); + for (auto& ap : wifi_.listScan()) { + storage_.appendLine(path, scanlog::row(stamp, ap)); + loggedRows_++; + } + logThrottle_.logged(nowMs); +} + void WifiToolsApp::update(uint32_t nowMs) { if (view_ == View::Menu) return; @@ -90,23 +116,22 @@ void WifiToolsApp::update(uint32_t nowMs) { seenSeq_ = wifi_.listScanSeq(); if (view_ == View::Tracker) { bool found = false; - for (auto& ap : wifi_.listScan()) { + for (auto& ap : wifi_.listScan()) if (ap.bssid == targetBssid_) { tracker_.seen(ap.rssi, nowMs); targetChannel_ = ap.channel; found = true; } - } if (!found) tracker_.missed(); } else { - networks_.setCount(static_cast(wifi_.listScan().size())); + refreshOrder(); + if (logging_) logScan(nowMs); } requestRedraw(); } if (wifi_.listScanDone() && static_cast(nowMs - nextScanMs_) >= 0) { bool tracking = view_ == View::Tracker; - // A lost access point may have moved channel: fall back to a full scan. wifi_.startListScan(tracking && !tracker_.lost(nowMs) ? targetChannel_ : 0); nextScanMs_ = nowMs + (tracking ? kTrackRescanMs : kListRescanMs); } @@ -129,17 +154,43 @@ void WifiToolsApp::draw(Canvas& c) { } void WifiToolsApp::drawNetworks(Canvas& c) { - auto list = sortedScan(); - if (list.empty()) { - widgets::textLines(c, {wifi_.listScanDone() ? "No networks found yet." : "Scanning..."}, 0, theme::kContent); + const auto& area = theme::kContent; + const auto& scan = wifi_.listScan(); + + // Status line: sort, filters, logging. + c.setFont(&fonts::small); + std::string top = std::string("sort:") + sortName(sort_); + if (filter_.openOnly) top += " open"; + if (filter_.hideHidden) top += " !hid"; + if (filter_.minRssi != -100) top += " strong"; + c.setTextColor(theme::kMuted); + c.drawString(top.c_str(), 4, area.y + 1); + if (logging_) { + c.setTextColor(theme::kWarning); + c.setTextDatum(top_right); + c.drawString(("LOG " + std::to_string(loggedRows_)).c_str(), area.w - 4, area.y + 1); + c.setTextDatum(top_left); + } + + theme::Rect list{area.x, area.y + 13, area.w, area.h - 13}; + if (order_.empty()) { + bool any = !scan.empty(); + widgets::textLines(c, + {wifi_.listScanDone() ? (any ? "Nothing matches the filter." : "No networks found yet.") + : "Scanning...", + "", "s sort o open h hide-hidden w strong l log"}, + 0, list); return; } - if (networks_.count() != static_cast(list.size())) networks_.setCount(static_cast(list.size())); widgets::list( - c, networks_, theme::kContent, - [&](int i) { return list[i].ssid.empty() ? std::string("(hidden)") : list[i].ssid; }, + c, networks_, list, [&](int i) { - return "ch" + std::to_string(list[i].channel) + " " + std::to_string(list[i].rssi) + " " + list[i].security; + const ScanEntry& ap = scan[order_[i]]; + return ap.ssid.empty() ? std::string("(hidden)") : ap.ssid; + }, + [&](int i) { + const ScanEntry& ap = scan[order_[i]]; + return "ch" + std::to_string(ap.channel) + " " + std::to_string(ap.rssi) + " " + ap.security; }); } @@ -185,7 +236,6 @@ void WifiToolsApp::drawTracker(Canvas& c) { std::string sub = targetBssid_ + " ch" + std::to_string(targetChannel_) + " m: clicks " + (clicks_ ? "on" : "off"); c.drawString(sub.c_str(), 4, area.y + 17); - // Big reading c.setFont(&fonts::bold); c.setTextSize(2); c.setTextColor(lost ? theme::kWarning : theme::kText); @@ -193,13 +243,11 @@ void WifiToolsApp::drawTracker(Canvas& c) { c.drawString(reading.c_str(), 4, area.y + 30); c.setTextSize(1); - // Strength bar: -95 dBm empty, -35 dBm full int strength = lost ? 0 : std::min(100, std::max(0, (tracker_.latest() + 95) * 100 / 60)); int barW = area.w - 8; c.drawRect(4, area.y + 60, barW, 8, theme::kMuted); c.fillRect(5, area.y + 61, (barW - 2) * strength / 100, 6, theme::kMessage); - // History, newest at the right const int gy = area.y + 74, gh = area.h - 76; const auto& h = tracker_.history(); int n = static_cast(h.size()); diff --git a/src/apps/wifi_tools_app.h b/src/apps/wifi_tools_app.h index 75586f4..2fee3bf 100644 --- a/src/apps/wifi_tools_app.h +++ b/src/apps/wifi_tools_app.h @@ -5,17 +5,23 @@ #include "app.h" #include "list_model.h" +#include "network_list_view.h" +#include "scan_log.h" +#include "services/clock_service.h" +#include "services/storage_service.h" #include "services/wifi_service.h" #include "signal_tracker.h" #include "ui/theme.h" namespace roro { -// Wi-Fi diagnostics from ordinary scans: nearby access points, how crowded each channel is, and -// a signal tracker for finding one access point. None of it interrupts the connection or IRC. +// Wi-Fi diagnostics from ordinary scans: nearby access points (sortable, filterable, optionally +// logged to CSV), how crowded each channel is, and a signal tracker. None of it interrupts the +// connection or IRC, and none of it captures anyone's traffic. class WifiToolsApp : public App { public: - explicit WifiToolsApp(WifiService& wifi) : wifi_(wifi) {} + WifiToolsApp(WifiService& wifi, StorageService& storage, ClockService& clock) + : wifi_(wifi), storage_(storage), clock_(clock) {} void onEnter() override; void onExit() override; bool onKey(const KeyEvent& e) override; @@ -26,18 +32,30 @@ class WifiToolsApp : public App { enum class View { Menu, Networks, Channels, Tracker }; void open(View v); + void refreshOrder(); + void logScan(uint32_t nowMs); void drawNetworks(Canvas& c); void drawChannels(Canvas& c); void drawTracker(Canvas& c); - std::vector sortedScan() const; WifiService& wifi_; + StorageService& storage_; + ClockService& clock_; View view_ = View::Menu; ListModel menu_{theme::kContent.h / theme::kLineHeight}; - ListModel networks_{theme::kContent.h / theme::kLineHeight}; + ListModel networks_{(theme::kContent.h - 14) / theme::kLineHeight}; + std::vector order_; // indices into the scan, after sort and filter + WifiSort sort_ = WifiSort::Signal; + NetworkFilter filter_; uint32_t seenSeq_ = 0; uint32_t nextScanMs_ = 0; + // Scan logging + bool logging_ = false; + ScanLogThrottle logThrottle_{30000}; + std::string headerDate_; + uint32_t loggedRows_ = 0; + // Tracker std::string targetBssid_, targetName_; int targetChannel_ = 0; diff --git a/src/main.cpp b/src/main.cpp index 8dc1b21..61ab354 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,6 @@ // roro9stack firmware entry point: wires Services, Apps and the UI loop together. #include +#include #include "app_manager.h" #include "apps/demo_app.h" @@ -116,7 +117,7 @@ void setup() { apps = new AppManager(launcher); launcher.setManager(*apps); apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)}); - apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi)}); + apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)}); apps->registerApp({"settings", "Settings", false, new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks})}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); @@ -163,7 +164,8 @@ static void serialCommands() { String k = line.substring(4); Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left : k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home : Key::Select; - if (!power->onKey(millis())) apps->handleKey(KeyEvent::of(key)); + KeyEvent ev = k.length() == 1 && k[0] > ' ' ? KeyEvent::character((unsigned char)k[0]) : KeyEvent::of(key); + if (!power->onKey(millis())) apps->handleKey(ev); } if (line.startsWith("wifi add ")) { // wifi add \t: credentials never touch the repo int tab = line.indexOf('\t'); @@ -178,6 +180,18 @@ static void serialCommands() { storageService->appendLine(path, clockService->displayTime() + " " + line.substring(4).c_str()); Serial.printf("log: queued to %s\n", path.c_str()); } + if (line.startsWith("cat ")) { + std::string path = line.substring(4).c_str(); + File f = SD.open(path.c_str()); + if (!f) Serial.printf("cat: cannot open %s\n", path.c_str()); + else { + Serial.printf("--- %s (%u B) ---\n", path.c_str(), (unsigned)f.size()); + int shown = 0; + while (f.available() && shown < 1200) { Serial.write(f.read()); shown++; } + Serial.println("\n--- end ---"); + f.close(); + } + } if (line == "sd list") { storageService->requestListing(); listingWanted = true; @@ -193,11 +207,15 @@ static void serialCommands() { if (line == "irc dump") { Serial.printf("irc: status %d, unread %d, heap %u min %u\n", (int)irc->status(), irc->totalUnread(), ESP.getFreeHeap(), ESP.getMinFreeHeap()); + IrcConfig c = irc->draftConfig(); // which login is configured, never the secrets + Serial.printf("irc: nick %s, sasl %s, nickserv %s, autojoin %u channels\n", c.nick.c_str(), + c.saslUser.empty() || c.saslPassword.empty() ? "off" : "on", + c.nickservPassword.empty() ? "off" : "on", (unsigned)c.autojoin.size()); irc->withSession([](IrcSession& s) { for (int i = 0; i < s.bufferCount(); i++) { const auto& b = s.buffer(i); Serial.printf("irc buffer %d %s unread %d%s\n", i, b.name.c_str(), b.unread, b.joined ? " joined" : ""); - size_t from = b.lines.size() > 6 ? b.lines.size() - 6 : 0; + size_t from = 0; for (size_t j = from; j < b.lines.size(); j++) Serial.printf(" <%s> %s\n", b.lines[j].nick.c_str(), b.lines[j].text.c_str()); } diff --git a/src/services/irc_service.cpp b/src/services/irc_service.cpp index d69d23f..64e87e1 100644 --- a/src/services/irc_service.cpp +++ b/src/services/irc_service.cpp @@ -229,6 +229,10 @@ void IrcService::loop() { pingSent_ = true; } } + { + Lock l(lock_); + session_->tick(millis()); + } flushEffects(); vTaskDelay(pdMS_TO_TICKS(open_ ? 30 : 250)); } diff --git a/src/services/wifi_service.h b/src/services/wifi_service.h index 1bb7ec6..2aba9d0 100644 --- a/src/services/wifi_service.h +++ b/src/services/wifi_service.h @@ -4,6 +4,7 @@ #include "event_bus.h" #include "saved_networks.h" +#include "scan_entry.h" #include "service.h" #include "services/clock_service.h" #include "settings.h" @@ -11,15 +12,6 @@ namespace roro { -struct ScanEntry { - std::string ssid; // empty for a hidden network - std::string bssid; - int channel; - int rssi; - bool open; - std::string security; // "open", "WPA2", "WPA3"... -}; - // Owns the Wi-Fi radio: carries out the WifiController's decisions with the ESP32 Wi-Fi stack, // syncs the clock over NTP once Connected, and runs scans for the Settings network list. class WifiService : public Service { diff --git a/test/test_input_history/test_input_history.cpp b/test/test_input_history/test_input_history.cpp new file mode 100644 index 0000000..3375d91 --- /dev/null +++ b/test/test_input_history/test_input_history.cpp @@ -0,0 +1,73 @@ +#include + +#include "input_history.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_up_brings_back_previous_lines_newest_first() { + InputHistory h(10); + h.add("one"); + h.add("two"); + std::string line; + TEST_ASSERT_TRUE(h.up("", line)); + TEST_ASSERT_EQUAL_STRING("two", line.c_str()); + TEST_ASSERT_TRUE(h.up("two", line)); + TEST_ASSERT_EQUAL_STRING("one", line.c_str()); + TEST_ASSERT_FALSE(h.up("one", line)); // nothing older +} + +void test_down_returns_to_the_draft_being_typed() { + InputHistory h(10); + h.add("one"); + std::string line; + h.up("half typed", line); + TEST_ASSERT_TRUE(h.down(line)); + TEST_ASSERT_EQUAL_STRING("half typed", line.c_str()); + TEST_ASSERT_FALSE(h.down(line)); // already at the draft +} + +void test_adding_resets_browsing_and_skips_repeats_and_empty_lines() { + InputHistory h(10); + h.add("same"); + h.add("same"); + h.add(""); + std::string line; + h.up("", line); + TEST_ASSERT_FALSE(h.up(line, line)); // only one entry kept + h.add("next"); + TEST_ASSERT_TRUE(h.up("", line)); + TEST_ASSERT_EQUAL_STRING("next", line.c_str()); +} + +void test_keeps_only_the_most_recent_lines() { + InputHistory h(2); + h.add("a"); + h.add("b"); + h.add("c"); + std::string line; + h.up("", line); + h.up(line, line); + TEST_ASSERT_EQUAL_STRING("b", line.c_str()); + TEST_ASSERT_FALSE(h.up(line, line)); +} + +void test_empty_history_does_nothing() { + InputHistory h(5); + std::string line = "draft"; + TEST_ASSERT_FALSE(h.up("draft", line)); + TEST_ASSERT_FALSE(h.down(line)); + TEST_ASSERT_EQUAL_STRING("draft", line.c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_up_brings_back_previous_lines_newest_first); + RUN_TEST(test_down_returns_to_the_draft_being_typed); + RUN_TEST(test_adding_resets_browsing_and_skips_repeats_and_empty_lines); + RUN_TEST(test_keeps_only_the_most_recent_lines); + RUN_TEST(test_empty_history_does_nothing); + return UNITY_END(); +} diff --git a/test/test_irc_config/test_irc_config.cpp b/test/test_irc_config/test_irc_config.cpp index 5773032..6286b9a 100644 --- a/test/test_irc_config/test_irc_config.cpp +++ b/test/test_irc_config/test_irc_config.cpp @@ -28,7 +28,7 @@ void test_save_and_reload() { c.nick = "clement"; c.saslUser = "clement"; c.saslPassword = "s3cret"; - c.autojoin = {"#roro", "#meshtastic"}; + c.autojoin = {{"#roro", ""}, {"#meshtastic", "k3y"}}; TEST_ASSERT_TRUE(c.save().empty()); } IrcConfig again(store); @@ -36,7 +36,8 @@ void test_save_and_reload() { TEST_ASSERT_EQUAL_STRING("clement", again.nick.c_str()); TEST_ASSERT_EQUAL_STRING("s3cret", again.saslPassword.c_str()); TEST_ASSERT_EQUAL(2, again.autojoin.size()); - TEST_ASSERT_EQUAL_STRING("#meshtastic", again.autojoin[1].c_str()); + TEST_ASSERT_EQUAL_STRING("#meshtastic", again.autojoin[1].channel.c_str()); + TEST_ASSERT_EQUAL_STRING("k3y", again.autojoin[1].key.c_str()); } void test_invalid_values_are_refused_with_a_reason() { @@ -54,14 +55,32 @@ void test_invalid_values_are_refused_with_a_reason() { c.host = ""; TEST_ASSERT_FALSE(c.save().empty()); c.host = "irc.libera.chat"; - c.autojoin = {"roro"}; // IRC channels start with # or & + c.autojoin = {{"roro", ""}}; // IRC channels start with # or & TEST_ASSERT_FALSE(c.save().empty()); } void test_autojoin_text_round_trip() { TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a, #b").size()); - TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a #b").size()); - TEST_ASSERT_EQUAL_STRING("#a #b", IrcConfig::formatChannels({"#a", "#b"}).c_str()); + TEST_ASSERT_EQUAL(2, IrcConfig::parseChannels("#a #b").size()); // the older, space-separated form + TEST_ASSERT_EQUAL_STRING("#a, #b", IrcConfig::formatChannels({{"#a", ""}, {"#b", ""}}).c_str()); +} + +void test_autojoin_entries_can_carry_a_key() { + auto list = IrcConfig::parseChannels("#private s3cret, #public &local"); + TEST_ASSERT_EQUAL(3, list.size()); + TEST_ASSERT_EQUAL_STRING("#private", list[0].channel.c_str()); + TEST_ASSERT_EQUAL_STRING("s3cret", list[0].key.c_str()); + TEST_ASSERT_EQUAL_STRING("", list[1].key.c_str()); + TEST_ASSERT_EQUAL_STRING("&local", list[2].channel.c_str()); + TEST_ASSERT_EQUAL_STRING("#private s3cret, #public, &local", IrcConfig::formatChannels(list).c_str()); +} + +void test_a_key_without_a_channel_is_refused() { + MemoryStore store; + IrcConfig c(store); + c.load("x"); + c.autojoin = IrcConfig::parseChannels("orphan #a"); + TEST_ASSERT_FALSE(c.validate().empty()); } void test_copy_settings_from_a_draft() { @@ -70,7 +89,7 @@ void test_copy_settings_from_a_draft() { live.load("x"); IrcConfig draft = live; draft.host = "irc.example.org"; - draft.autojoin = {"#a"}; + draft.autojoin = {{"#a", ""}}; TEST_ASSERT_EQUAL_STRING("irc.libera.chat", live.host.c_str()); // the draft is independent live.copySettingsFrom(draft); TEST_ASSERT_EQUAL_STRING("irc.example.org", live.host.c_str()); @@ -84,5 +103,7 @@ int main() { RUN_TEST(test_invalid_values_are_refused_with_a_reason); RUN_TEST(test_autojoin_text_round_trip); RUN_TEST(test_copy_settings_from_a_draft); + RUN_TEST(test_autojoin_entries_can_carry_a_key); + RUN_TEST(test_a_key_without_a_channel_is_refused); return UNITY_END(); } diff --git a/test/test_irc_session/test_irc_session.cpp b/test/test_irc_session/test_irc_session.cpp index fcdbd7b..8d7563c 100644 --- a/test/test_irc_session/test_irc_session.cpp +++ b/test/test_irc_session/test_irc_session.cpp @@ -20,7 +20,7 @@ struct Fixture { explicit Fixture(bool sasl = false) { config.load("roro"); config.nick = "roro"; - config.autojoin = {"#roro"}; + config.autojoin = {{"#roro", ""}}; if (sasl) { config.saslUser = "acct"; config.saslPassword = "pw"; @@ -28,6 +28,7 @@ struct Fixture { session.reset(new IrcSession(config)); } void recv(const std::string& line) { session->receive(line, 1000); } + void tick(uint32_t ms) { session->tick(ms); } IrcEffects& take() { fx = session->takeEffects(); return fx; @@ -65,15 +66,127 @@ void test_welcome_registers_and_joins_autojoin_channels() { TEST_ASSERT_TRUE(f.sent("JOIN #roro")); } -void test_nickserv_identify_after_welcome() { +void test_nickserv_identify_after_welcome_and_joins_wait_for_it() { Fixture f; f.config.nickservPassword = "pw"; f.session.reset(new IrcSession(f.config)); - f.registerNow(); + f.session->connected(0); + f.tick(0); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY roro pw")); + TEST_ASSERT_FALSE(f.sent("JOIN #roro")); // not before NickServ has had its say + f.tick(1000); + TEST_ASSERT_TRUE(f.take().send.empty()); +} + +void test_joins_follow_the_logged_in_reply() { + Fixture f; + f.config.nickservPassword = "pw"; + f.session.reset(new IrcSession(f.config)); + f.session->connected(0); + f.tick(0); + f.recv(":srv 001 roro :Welcome"); + f.take(); + f.tick(300); + f.recv(":srv 900 roro roro!u@h roro :You are now logged in as roro"); + f.take(); + TEST_ASSERT_TRUE(f.sent("JOIN #roro")); + f.tick(5000); + TEST_ASSERT_TRUE(f.take().send.empty()); // and only once +} + +void test_joins_go_ahead_after_two_seconds_without_a_reply() { + Fixture f; + f.config.nickservPassword = "pw"; + f.session.reset(new IrcSession(f.config)); + f.session->connected(0); + f.tick(10000); + f.recv(":srv 001 roro :Welcome"); + f.take(); + f.tick(11999); + TEST_ASSERT_TRUE(f.take().send.empty()); + f.tick(12000); + f.take(); + TEST_ASSERT_TRUE(f.sent("JOIN #roro")); +} + +void test_failed_sasl_falls_back_to_nickserv_and_holds_joins() { + Fixture f(true); + f.config.nickservPassword = "nspw"; + f.session.reset(new IrcSession(f.config)); + f.session->connected(0); + f.recv(":srv CAP * ACK :sasl"); + f.recv("AUTHENTICATE +"); + f.recv(":srv 904 roro :SASL authentication failed"); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY acct nspw")); + TEST_ASSERT_FALSE(f.sent("JOIN #roro")); + f.recv(":srv 900 roro roro!u@h roro :You are now logged in as roro"); + f.take(); + TEST_ASSERT_TRUE(f.sent("JOIN #roro")); +} + +void test_failed_sasl_without_nickserv_password_identifies_with_the_sasl_account() { + Fixture f(true); // SASL user "acct", password "pw" + f.session->connected(0); + f.recv(":srv 904 roro :SASL authentication failed"); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY acct pw")); +} + +void test_successful_sasl_joins_at_once() { + Fixture f(true); + f.config.nickservPassword = "nspw"; + f.session.reset(new IrcSession(f.config)); + f.session->connected(0); + f.recv(":srv 903 roro :SASL authentication successful"); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("JOIN #roro")); + TEST_ASSERT_FALSE(f.sent("PRIVMSG NickServ :IDENTIFY acct nspw")); +} + +void test_identify_names_the_account_even_on_a_fallback_nick_then_regains_it() { + Fixture f; + f.config.nickservPassword = "pw"; + f.session.reset(new IrcSession(f.config)); + f.session->connected(0); + f.recv(":srv 433 * roro :Nickname is already in use"); // a stale session still holds it + f.recv(":srv 001 roro_ :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY roro pw")); + f.recv(":srv 900 roro_ roro_!u@h roro :You are now logged in as roro"); + f.take(); + TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :REGAIN roro")); + TEST_ASSERT_TRUE(f.sent("JOIN #roro")); +} + +void test_autojoin_sends_keyed_channels_first_with_their_keys() { + Fixture f; + f.config.autojoin = IrcConfig::parseChannels("#public, #private s3cret"); + f.session.reset(new IrcSession(f.config)); f.session->connected(0); f.recv(":srv 001 roro :Welcome"); f.take(); - TEST_ASSERT_TRUE(f.sent("PRIVMSG NickServ :IDENTIFY pw")); + TEST_ASSERT_TRUE(f.sent("JOIN #private,#public s3cret")); +} + +void test_keys_used_with_join_are_reused_on_rejoin() { + Fixture f; + f.registerNow(); + f.recv(":roro!u@h JOIN #roro"); + f.session->input(1, "/join #vault k3y", 0); + f.take(); + TEST_ASSERT_TRUE(f.sent("JOIN #vault k3y")); + f.recv(":roro!u@h JOIN #vault"); + f.session->disconnected(0, "Wi-Fi lost"); + f.session->connected(0); + f.recv(":srv 001 roro :Welcome"); + f.take(); + TEST_ASSERT_TRUE(f.sent("JOIN #vault,#roro k3y")); } void test_sasl_plain_flow() { @@ -253,6 +366,16 @@ void test_input_message_and_commands() { TEST_ASSERT_EQUAL_STRING("/not a command", f.session->buffer(b).lines.back().text.c_str()); } +void test_j_is_short_for_join() { + Fixture f; + f.registerNow(); + f.session->input(0, "/j #short", 0); + f.session->input(0, "/j #locked k3y", 0); + f.take(); + TEST_ASSERT_TRUE(f.sent("JOIN #short")); + TEST_ASSERT_TRUE(f.sent("JOIN #locked k3y")); +} + void test_msg_opens_a_query_buffer() { Fixture f; f.registerNow(); @@ -347,7 +470,15 @@ int main() { UNITY_BEGIN(); RUN_TEST(test_plain_registration_sends_nick_and_user); RUN_TEST(test_welcome_registers_and_joins_autojoin_channels); - RUN_TEST(test_nickserv_identify_after_welcome); + RUN_TEST(test_nickserv_identify_after_welcome_and_joins_wait_for_it); + RUN_TEST(test_joins_follow_the_logged_in_reply); + RUN_TEST(test_joins_go_ahead_after_two_seconds_without_a_reply); + RUN_TEST(test_failed_sasl_falls_back_to_nickserv_and_holds_joins); + RUN_TEST(test_failed_sasl_without_nickserv_password_identifies_with_the_sasl_account); + RUN_TEST(test_successful_sasl_joins_at_once); + RUN_TEST(test_identify_names_the_account_even_on_a_fallback_nick_then_regains_it); + RUN_TEST(test_autojoin_sends_keyed_channels_first_with_their_keys); + RUN_TEST(test_keys_used_with_join_are_reused_on_rejoin); RUN_TEST(test_sasl_plain_flow); RUN_TEST(test_sasl_failure_still_ends_negotiation_and_says_so); RUN_TEST(test_nick_in_use_during_registration_tries_another); @@ -362,6 +493,7 @@ int main() { RUN_TEST(test_action_and_ctcp_version); RUN_TEST(test_topic_is_kept); RUN_TEST(test_input_message_and_commands); + RUN_TEST(test_j_is_short_for_join); RUN_TEST(test_msg_opens_a_query_buffer); RUN_TEST(test_text_in_the_server_buffer_explains_instead_of_sending); RUN_TEST(test_quit_sends_and_is_remembered); diff --git a/test/test_network_list_view/test_network_list_view.cpp b/test/test_network_list_view/test_network_list_view.cpp new file mode 100644 index 0000000..785968f --- /dev/null +++ b/test/test_network_list_view/test_network_list_view.cpp @@ -0,0 +1,87 @@ +#include + +#include "network_list_view.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static std::vector sample() { + return { + {"beta", "b", 6, -70, false, "WPA2"}, + {"alpha", "a", 11, -50, true, "open"}, + {"", "h", 1, -60, false, "WPA2"}, // hidden + {"Gamma", "g", 6, -45, false, "WPA3"}, + }; +} + +static std::vector names(const std::vector& e, const std::vector& idx) { + std::vector out; + for (int i : idx) out.push_back(e[i].ssid); + return out; +} + +void test_sort_by_signal_strongest_first() { + auto e = sample(); + auto idx = orderNetworks(e, WifiSort::Signal, {}); + TEST_ASSERT_EQUAL(4, idx.size()); + TEST_ASSERT_EQUAL_STRING("Gamma", e[idx[0]].ssid.c_str()); // -45 + TEST_ASSERT_EQUAL_STRING("alpha", e[idx[1]].ssid.c_str()); // -50 + TEST_ASSERT_EQUAL_STRING("beta", e[idx[3]].ssid.c_str()); // -70 +} + +void test_sort_by_channel_then_signal() { + auto e = sample(); + auto idx = orderNetworks(e, WifiSort::Channel, {}); + TEST_ASSERT_EQUAL(1, e[idx[0]].channel); // channel 1 first + TEST_ASSERT_EQUAL(6, e[idx[1]].channel); // then channel 6... + TEST_ASSERT_EQUAL_STRING("Gamma", e[idx[1]].ssid.c_str()); // -45 beats -70 on ch 6 + TEST_ASSERT_EQUAL(11, e[idx[3]].channel); +} + +void test_sort_by_name_case_insensitive_hidden_last() { + auto e = sample(); + auto idx = orderNetworks(e, WifiSort::Name, {}); + auto n = names(e, idx); + TEST_ASSERT_EQUAL_STRING("alpha", n[0].c_str()); + TEST_ASSERT_EQUAL_STRING("beta", n[1].c_str()); + TEST_ASSERT_EQUAL_STRING("Gamma", n[2].c_str()); + TEST_ASSERT_EQUAL_STRING("", n[3].c_str()); // hidden last +} + +void test_filter_open_only() { + auto e = sample(); + auto idx = orderNetworks(e, WifiSort::Signal, {true, false, -100}); + TEST_ASSERT_EQUAL(1, idx.size()); + TEST_ASSERT_EQUAL_STRING("alpha", e[idx[0]].ssid.c_str()); +} + +void test_filter_hide_hidden_and_weak() { + auto e = sample(); + NetworkFilter f; + f.hideHidden = true; + f.minRssi = -60; + auto idx = orderNetworks(e, WifiSort::Signal, f); + // keeps Gamma(-45), alpha(-50); drops beta(-70) and the hidden(-60 is not < -60, but hidden) + for (int i : idx) TEST_ASSERT_FALSE(e[i].ssid.empty()); + TEST_ASSERT_EQUAL(2, idx.size()); +} + +void test_sort_cycles() { + TEST_ASSERT_EQUAL(static_cast(WifiSort::Channel), static_cast(nextSort(WifiSort::Signal))); + TEST_ASSERT_EQUAL(static_cast(WifiSort::Name), static_cast(nextSort(WifiSort::Channel))); + TEST_ASSERT_EQUAL(static_cast(WifiSort::Signal), static_cast(nextSort(WifiSort::Name))); + TEST_ASSERT_EQUAL_STRING("signal", sortName(WifiSort::Signal)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_sort_by_signal_strongest_first); + RUN_TEST(test_sort_by_channel_then_signal); + RUN_TEST(test_sort_by_name_case_insensitive_hidden_last); + RUN_TEST(test_filter_open_only); + RUN_TEST(test_filter_hide_hidden_and_weak); + RUN_TEST(test_sort_cycles); + return UNITY_END(); +} diff --git a/test/test_scan_log/test_scan_log.cpp b/test/test_scan_log/test_scan_log.cpp new file mode 100644 index 0000000..13ea890 --- /dev/null +++ b/test/test_scan_log/test_scan_log.cpp @@ -0,0 +1,68 @@ +#include + +#include "scan_log.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static ScanEntry ap(std::string ssid, std::string bssid, int ch, int rssi, std::string sec) { + return {ssid, bssid, ch, rssi, sec == "open", sec}; +} + +void test_plain_fields_are_unquoted() { + TEST_ASSERT_EQUAL_STRING("home", scanlog::csvField("home").c_str()); + TEST_ASSERT_EQUAL_STRING("WPA2", scanlog::csvField("WPA2").c_str()); +} + +void test_fields_with_commas_or_quotes_are_quoted() { + TEST_ASSERT_EQUAL_STRING("\"a,b\"", scanlog::csvField("a,b").c_str()); + TEST_ASSERT_EQUAL_STRING("\"say \"\"hi\"\"\"", scanlog::csvField("say \"hi\"").c_str()); +} + +void test_header_and_row() { + TEST_ASSERT_EQUAL_STRING("time,bssid,channel,rssi,security,ssid", scanlog::header().c_str()); + auto r = scanlog::row("2026-10-03 20:45:01", ap("home", "aa:bb:cc:dd:ee:ff", 6, -55, "WPA2")); + TEST_ASSERT_EQUAL_STRING("2026-10-03 20:45:01,aa:bb:cc:dd:ee:ff,6,-55,WPA2,home", r.c_str()); +} + +void test_hidden_network_row() { + auto r = scanlog::row("t", ap("", "aa:bb:cc:dd:ee:ff", 1, -70, "WPA2")); + TEST_ASSERT_TRUE(r.find(",(hidden)") != std::string::npos); +} + +void test_ssid_with_a_comma_is_quoted_in_the_row() { + auto r = scanlog::row("t", ap("cafe, bar", "aa:bb", 11, -40, "open")); + TEST_ASSERT_TRUE(r.find("\"cafe, bar\"") != std::string::npos); +} + +void test_throttle_logs_once_per_interval() { + ScanLogThrottle t(30000); + TEST_ASSERT_TRUE(t.due(0)); // first time + t.logged(1000); + TEST_ASSERT_FALSE(t.due(30999)); + TEST_ASSERT_TRUE(t.due(31000)); + t.logged(31000); + TEST_ASSERT_FALSE(t.due(40000)); +} + +void test_reset_makes_it_due_again() { + ScanLogThrottle t(30000); + t.logged(1000); + TEST_ASSERT_FALSE(t.due(2000)); + t.reset(); + TEST_ASSERT_TRUE(t.due(2000)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_plain_fields_are_unquoted); + RUN_TEST(test_fields_with_commas_or_quotes_are_quoted); + RUN_TEST(test_header_and_row); + RUN_TEST(test_hidden_network_row); + RUN_TEST(test_ssid_with_a_comma_is_quoted_in_the_row); + RUN_TEST(test_throttle_logs_once_per_interval); + RUN_TEST(test_reset_makes_it_due_again); + return UNITY_END(); +}