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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-02 15:10:03 +02:00
co-authored by Claude Opus 5.5
parent 311eb38723
commit 74a341418e
11 changed files with 357 additions and 4 deletions
+3
View File
@@ -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.
+211
View File
@@ -0,0 +1,211 @@
#include "irc_service.h"
#include <Arduino.h>
#include <cstdio>
#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<IrcService*>(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<NetworkClient*>(&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<char>(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<int32_t>(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<int32_t>(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
+87
View File
@@ -0,0 +1,87 @@
#pragma once
#include <NetworkClientSecure.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <memory>
#include <string>
#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 <typename F>
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<IrcSession> 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