From 74a341418e0b7ad8cc936aa378fd2c3c122924ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 2 Oct 2026 15:10:03 +0200 Subject: [PATCH] M1 step 4b: IRC Service on the device; 8-bit frame buffer - IrcService: networking on its own task, TLS with the built-in CA bundle (or trust-on-first-use pinning when self-signed is allowed), plain TCP when TLS is off; connects only while Wi-Fi is Connected, marks pauses for Monitoring, reconnects with backoff, pings a quiet server, writes Logs, raises Notifications for Mentions - Session: forget /quit once disconnected (it was handled every loop); the server Buffer never counts as unread (MOTD showed as [4]) - Status Bar: unread count - Frame buffer 16 -> 8-bit colour (M1 Q46): min free heap with IRC on TLS went from 51 KB to 79 KB - Serial: irc start / say / dump Verified on the device against irc.libera.chat:6697: certificate checked, joined #roro9stack-test, sent a message, quit cleanly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- README.md | 3 + lib/irc/src/irc_session.cpp | 3 +- src/main.cpp | 27 +++ src/services/clock_service.h | 3 + src/services/irc_service.cpp | 211 +++++++++++++++++++++ src/services/irc_service.h | 87 +++++++++ src/ui/screen.cpp | 2 +- src/ui/screen.h | 2 +- src/ui/widgets.cpp | 1 + src/ui/widgets.h | 3 +- test/test_irc_session/test_irc_session.cpp | 19 ++ 11 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 src/services/irc_service.cpp create mode 100644 src/services/irc_service.h diff --git a/README.md b/README.md index 580ac2a..e1aad6f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ sudo usermod -aG dialout "$USER" | `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 | +| `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 | | `wifi status` | Prints Wi-Fi state, network, signal, clock and free heap | `scripts/flash.sh` stops a running serial log first, since it would hold the port. diff --git a/lib/irc/src/irc_session.cpp b/lib/irc/src/irc_session.cpp index 501c84c..9d320c0 100644 --- a/lib/irc/src/irc_session.cpp +++ b/lib/irc/src/irc_session.cpp @@ -93,7 +93,7 @@ void IrcSession::add(int b, IrcLine::Kind kind, const std::string& nick, const s effects_.logs.push_back({buf.name, line}); bool fromOthers = kind == IrcLine::Kind::Message || kind == IrcLine::Kind::Action || kind == IrcLine::Kind::Notice; - if (b != viewing_ && fromOthers) { + if (b != viewing_ && fromOthers && buf.type != IrcBuffer::Type::Server) { buf.unread++; if (mention) { buf.mentioned = true; @@ -114,6 +114,7 @@ void IrcSession::connected(int64_t) { void IrcSession::disconnected(int64_t utc, const std::string& reason) { registered_ = false; + quit_ = false; rejoin_.clear(); for (int i = 0; i < bufferCount(); i++) { if (buffers_[i].type == IrcBuffer::Type::Channel && buffers_[i].joined) rejoin_.push_back(buffers_[i].name); diff --git a/src/main.cpp b/src/main.cpp index 48bedf8..442d45a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,7 +10,9 @@ #include "key_mapper.h" #include "platform/nvs_store.h" #include "service_manager.h" +#include "platform/identity.h" #include "services/battery_service.h" +#include "services/irc_service.h" #include "services/clock_service.h" #include "services/power_service.h" #include "services/storage_service.h" @@ -37,6 +39,7 @@ static PowerService* power; static ClockService* clockService; static SavedNetworks* savedNetworks; static WifiService* wifi; +static IrcService* irc; static Notifier* notifier; static LauncherApp launcher; static AppManager* apps; @@ -66,6 +69,7 @@ static StatusInfo currentStatus() { s.sdPresent = storageState.present; s.sdLevel = storageState.level; s.compose = keyMapper.pendingCompose(); + s.unread = irc->totalUnread(); using WifiState = WifiController::State; switch (wifi->state()) { case WifiState::Connected: { @@ -97,6 +101,7 @@ void setup() { savedNetworks = new SavedNetworks(nvs); savedNetworks->load(); wifi = new WifiService(settings, *savedNetworks, *clockService); + irc = new IrcService(nvs, "roro_" + identity::defaultShortName(), *wifi, *storageService, *clockService, bus); notifier = new Notifier(bus, settings); notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); }; services.add(*power); @@ -104,6 +109,7 @@ void setup() { services.add(*battery); services.add(*storageService); services.add(*wifi); + services.add(*irc); apps = new AppManager(launcher); launcher.setManager(*apps); @@ -172,6 +178,27 @@ static void serialCommands() { storageService->requestListing(); listingWanted = true; } + if (line == "irc start") irc->connect(); + if (line.startsWith("irc say ")) { // irc say + String rest = line.substring(8); + int space = rest.indexOf(' '); + int b = rest.substring(0, space).toInt(); + std::string text = rest.substring(space + 1).c_str(); + irc->withSession([&](IrcSession& s) { s.input(b, text, clockService->utcNow()); }); + } + 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()); + 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; + 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()); + } + }); + } if (line == "wifi status") Serial.printf("wifi: state %d ssid '%s' rssi %d clock %s heap %u min %u\n", (int)wifi->state(), wifi->ssid().c_str(), wifi->rssi(), clockService->displayTime().c_str(), ESP.getFreeHeap(), diff --git a/src/services/clock_service.h b/src/services/clock_service.h index f830862..c613f43 100644 --- a/src/services/clock_service.h +++ b/src/services/clock_service.h @@ -23,6 +23,9 @@ class ClockService : public Service { bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); } const ClockModel& model() const { return model_; } + // UTC seconds, or -1 if the clock isn't set. + int64_t utcNow() const { return model_.isSet() ? model_.utcNow(millis()) : -1; } + // Local "YYYY-MM-DD" once set; otherwise empty (Logs then go to undated files). std::string localDate() const { return model_.isSet() ? ClockModel::formatLocalDate(model_.utcNow(millis())) : ""; } // Days since 1970-01-01 (UTC), or -1 if the clock isn't set. diff --git a/src/services/irc_service.cpp b/src/services/irc_service.cpp new file mode 100644 index 0000000..4ecd3a6 --- /dev/null +++ b/src/services/irc_service.cpp @@ -0,0 +1,211 @@ +#include "irc_service.h" + +#include + +#include + +#include "storage_paths.h" + +namespace roro { + +namespace { +constexpr uint32_t kQuietPingMs = 240000; // no traffic for 4 min: ping the server +constexpr uint32_t kDeadMs = 330000; // and give up if it still says nothing + +std::string hex(const uint8_t* bytes, size_t n) { + std::string out; + char buf[3]; + for (size_t i = 0; i < n; i++) { + std::snprintf(buf, sizeof(buf), "%02x", bytes[i]); + out += buf; + } + return out; +} +} // namespace + +IrcService::IrcService(KeyValueStore& store, const std::string& defaultNick, WifiService& wifi, StorageService& storage, + ClockService& clock, EventBus& bus) + : config_(store), wifi_(wifi), storage_(storage), clock_(clock), bus_(bus) { + config_.load(defaultNick); + session_.reset(new IrcSession(config_)); +} + +void IrcService::start() { + if (task_) return; + lock_ = xSemaphoreCreateMutex(); + xTaskCreate(taskEntry, "irc", 8192, this, 1, &task_); +} + +void IrcService::connect() { + if (wanted_) return; + retryAtMs_ = millis(); + backoff_.reset(); + wanted_ = true; +} + +int IrcService::totalUnread() { + Lock l(lock_); + return session_->totalUnread(); +} + +std::string IrcService::saveConfig() { + std::string error = config_.save(); + if (error.empty() && !wanted_) { + Lock l(lock_); + session_.reset(new IrcSession(config_)); // a new server or nick starts a fresh session + } + return error; +} + +void IrcService::taskEntry(void* self) { static_cast(self)->loop(); } + +void IrcService::scheduleRetry(const std::string& why) { + uint32_t delay = backoff_.nextDelayMs(); + retryAtMs_ = millis() + delay; + status_ = Status::Retrying; + Lock l(lock_); + session_->disconnected(clock_.utcNow(), why + ", retrying in " + std::to_string(delay / 1000) + " s"); +} + +bool IrcService::open() { + status_ = Status::Connecting; + bool pinning = config_.tls && config_.allowSelfSigned; + conn_ = config_.tls ? static_cast(&tlsClient_) : &plainClient_; + if (config_.tls) { + tlsClient_.setTimeout(15); // seconds, for the handshake + if (pinning) tlsClient_.setInsecure(); + else tlsClient_.useBuiltinCACertBundle(); + } + if (!conn_->connect(config_.host.c_str(), config_.port)) return false; + + if (pinning) { + uint8_t sha[32]; + if (!tlsClient_.getFingerprintSHA256(sha)) { + tlsClient_.stop(); + return false; + } + std::string fingerprint = hex(sha, sizeof(sha)); + if (config_.pinnedSha256.empty()) { + config_.pinnedSha256 = fingerprint; // trust on first use + config_.save(); + } else if (config_.pinnedSha256 != fingerprint) { + tlsClient_.stop(); + Lock l(lock_); + session_->disconnected(clock_.utcNow(), "server certificate changed: not connecting"); + return false; + } + } + open_ = true; + partial_.clear(); + lastRxMs_ = millis(); + pingSent_ = false; + status_ = Status::Registering; + Lock l(lock_); + session_->connected(clock_.utcNow()); + return true; +} + +void IrcService::close(const std::string& reason) { + if (open_) conn_->stop(); + open_ = false; + if (!reason.empty()) { + Lock l(lock_); + session_->disconnected(clock_.utcNow(), reason); + } +} + +void IrcService::readLines() { + while (conn_->available()) { + char c = static_cast(conn_->read()); + if (c == '\n') { + lastRxMs_ = millis(); + pingSent_ = false; + Lock l(lock_); + session_->receive(partial_, clock_.utcNow()); + partial_.clear(); + } else if (c != '\r' && partial_.size() < 1024) { + partial_ += c; + } + } +} + +void IrcService::flushEffects() { + IrcEffects fx; + bool registered, quit; + { + Lock l(lock_); + fx = session_->takeEffects(); + registered = session_->registered(); + quit = session_->quitRequested(); + } + for (auto& line : fx.send) + if (open_) conn_->print((line + "\r\n").c_str()); + + std::string date = clock_.localDate(); + for (auto& entry : fx.logs) { + const IrcLine& l = entry.line; + std::string when = l.utc >= 0 ? ClockModel::formatLocalTime(l.utc) : "--:--"; + std::string text; + switch (l.kind) { + case IrcLine::Kind::Action: + case IrcLine::Kind::OwnAction: text = "* " + l.nick + " " + l.text; break; + case IrcLine::Kind::Notice: text = "-" + l.nick + "- " + l.text; break; + case IrcLine::Kind::Info: text = l.text; break; + default: text = "<" + l.nick + "> " + l.text; break; + } + storage_.appendLine(storage::dailyLogPath({"irc", config_.host, entry.buffer}, date), when + " " + text); + } + for (auto& n : fx.notifications) + bus_.publish(Event::withText(EventType::Notification, n.c_str(), static_cast(NotificationLevel::Message))); + + if (registered && status_ == Status::Registering) { + status_ = Status::Online; + backoff_.reset(); + } + if (quit && open_) { + delay(300); // let QUIT reach the server + close(""); + wanted_ = false; + status_ = Status::Stopped; + Lock l(lock_); + session_->disconnected(clock_.utcNow(), "disconnected"); + } +} + +void IrcService::loop() { + for (;;) { + uint32_t now = millis(); + bool wifiUp = wifi_.state() == WifiController::State::Connected; + bool monitoring = wifi_.state() == WifiController::State::Monitoring; + + if (!wanted_) { + if (open_) close("disconnected"); + status_ = Status::Stopped; + } else if (!wifiUp) { + if (open_) close(monitoring ? "paused for Wi-Fi monitoring" : "Wi-Fi lost"); + status_ = monitoring ? Status::Paused : Status::WaitingForWifi; + retryAtMs_ = now; // reconnect as soon as Wi-Fi is back + } else if (!open_) { + if (static_cast(now - retryAtMs_) >= 0) { + if (!open()) scheduleRetry("could not connect to " + config_.host); + } + } else if (!conn_->connected()) { + close(""); + scheduleRetry("connection lost"); + } else { + readLines(); + uint32_t quiet = millis() - lastRxMs_; + if (quiet > kDeadMs) { + close(""); + scheduleRetry("server stopped answering"); + } else if (quiet > kQuietPingMs && !pingSent_) { + conn_->print("PING :roro9stack\r\n"); + pingSent_ = true; + } + } + flushEffects(); + vTaskDelay(pdMS_TO_TICKS(open_ ? 30 : 250)); + } +} + +} // namespace roro diff --git a/src/services/irc_service.h b/src/services/irc_service.h new file mode 100644 index 0000000..fd720ac --- /dev/null +++ b/src/services/irc_service.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "event_bus.h" +#include "irc_config.h" +#include "irc_session.h" +#include "reconnect_policy.h" +#include "service.h" +#include "services/clock_service.h" +#include "services/storage_service.h" +#include "services/wifi_service.h" + +namespace roro { + +// Keeps the IRC connection alive in the background once started (see IRC Service in CONTEXT.md): +// connects over TLS while Wi-Fi is Connected, pauses while Monitoring, reconnects with backoff, +// writes Logs and raises Notifications for Mentions. Networking runs on its own task; the +// session is shared with the IRC App under a lock. +class IrcService : public Service { + public: + enum class Status { Stopped, WaitingForWifi, Connecting, Registering, Online, Paused, Retrying }; + + IrcService(KeyValueStore& store, const std::string& defaultNick, WifiService& wifi, StorageService& storage, + ClockService& clock, EventBus& bus); + const char* name() const override { return "irc"; } + void start() override; + + // Starts the session (the IRC App does this when opened); /quit stops it. + void connect(); + bool running() const { return wanted_; } + Status status() const { return status_; } + + // Read or act on the session while holding its lock: withSession([](IrcSession& s) { ... }). + template + auto withSession(F f) { + Lock l(lock_); + return f(*session_); + } + int totalUnread(); + + IrcConfig& config() { return config_; } + // Saves the config; takes effect at the next connection. + std::string saveConfig(); + + private: + struct Lock { + explicit Lock(SemaphoreHandle_t m) : m_(m) { xSemaphoreTake(m_, portMAX_DELAY); } + ~Lock() { xSemaphoreGive(m_); } + SemaphoreHandle_t m_; + }; + + static void taskEntry(void* self); + void loop(); + bool open(); + void close(const std::string& reason); + void readLines(); + void flushEffects(); + void scheduleRetry(const std::string& why); + + IrcConfig config_; + WifiService& wifi_; + StorageService& storage_; + ClockService& clock_; + EventBus& bus_; + std::unique_ptr session_; + SemaphoreHandle_t lock_ = nullptr; + TaskHandle_t task_ = nullptr; + NetworkClientSecure tlsClient_; + NetworkClient plainClient_; + NetworkClient* conn_ = &tlsClient_; // whichever the config asks for + ReconnectPolicy backoff_; + std::string partial_; + volatile bool wanted_ = false; + volatile Status status_ = Status::Stopped; + bool open_ = false; + uint32_t retryAtMs_ = 0; + uint32_t lastRxMs_ = 0; + bool pingSent_ = false; +}; + +} // namespace roro diff --git a/src/ui/screen.cpp b/src/ui/screen.cpp index e45fd42..ef3e16a 100644 --- a/src/ui/screen.cpp +++ b/src/ui/screen.cpp @@ -5,7 +5,7 @@ namespace roro { bool Screen::begin() { - canvas_.setColorDepth(16); + canvas_.setColorDepth(8); // RGB332: saves 32 KB of RAM (M1 Q46); theme colours are converted canvas_.setPsram(false); return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr; } diff --git a/src/ui/screen.h b/src/ui/screen.h index be9010d..558d401 100644 --- a/src/ui/screen.h +++ b/src/ui/screen.h @@ -9,7 +9,7 @@ namespace roro { // Composes the frame (Status Bar, foreground App, Toast) off-screen and pushes it to the display. class Screen { public: - bool begin(); // allocates the frame buffer (~64 KB) + bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour) void render(AppManager& apps, const StatusInfo& status, const Toast* toast); Canvas& canvas() { return canvas_; } diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index ba8bccc..d5707f0 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -29,6 +29,7 @@ void statusBar(Canvas& c, const StatusInfo& info) { x -= c.textWidth(text.c_str()) + 6; }; right(info.clock, kText); + if (info.unread > 0) right("[" + std::to_string(info.unread) + "]", kMessage); if (info.batteryPercent >= 0) right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText); if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kMuted); diff --git a/src/ui/widgets.h b/src/ui/widgets.h index c0c3fbf..1a8a7a2 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -23,11 +23,12 @@ struct StatusInfo { char compose = 0; // pending Compose Key state enum class Wifi { None, Searching, Connected, Monitoring } wifi = Wifi::None; int wifiBars = 0; // 0..3 when Connected + int unread = 0; // unread messages across Services bool operator==(const StatusInfo& o) const { return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock && sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi && - wifiBars == o.wifiBars; + wifiBars == o.wifiBars && unread == o.unread; } bool operator!=(const StatusInfo& o) const { return !(*this == o); } }; diff --git a/test/test_irc_session/test_irc_session.cpp b/test/test_irc_session/test_irc_session.cpp index 7a170fc..ec18262 100644 --- a/test/test_irc_session/test_irc_session.cpp +++ b/test/test_irc_session/test_irc_session.cpp @@ -316,6 +316,23 @@ void test_buffer_keeps_only_recent_lines() { TEST_ASSERT_EQUAL(IrcSession::kLinesPerBuffer, f.session->buffer(f.buffer("#roro")).lines.size()); } +void test_quit_is_forgotten_once_disconnected() { + Fixture f; + f.registerNow(); + f.session->input(0, "/quit", 0); + f.session->disconnected(0, "disconnected"); + TEST_ASSERT_FALSE(f.session->quitRequested()); +} + +void test_server_buffer_never_counts_as_unread() { + Fixture f; + f.session->connected(0); + f.recv(":srv NOTICE * :*** Looking up your hostname..."); + f.recv(":srv 372 roro :- Message of the day"); + f.recv(":srv 001 roro :Welcome"); + TEST_ASSERT_EQUAL(0, f.session->totalUnread()); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_plain_registration_sends_nick_and_user); @@ -341,5 +358,7 @@ int main() { RUN_TEST(test_names_reply_only_shown_when_asked); RUN_TEST(test_disconnect_marks_a_gap_and_reconnect_rejoins); RUN_TEST(test_buffer_keeps_only_recent_lines); + RUN_TEST(test_quit_is_forgotten_once_disconnected); + RUN_TEST(test_server_buffer_never_counts_as_unread); return UNITY_END(); }