S1 #11: bytes read and written, counted per network service

A Counted<> wrapper around the network clients adds what goes through
their buffer read and write to a per-service counter (IRC, Gemini, Debug
Console, Updates); the single-byte calls and print() end up there, so
each byte counts once. `net` prints the totals. For TLS it's the plain
text the service sees.

Checked on the device: a Gemini fetch counts 164,986 in (a 164,970-byte
page and its 16-byte header) and 42 out (the URL and CRLF).

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-06 01:10:55 +02:00
co-authored by Claude Opus 5.5
parent 1df94b684a
commit 70bb2a4137
9 changed files with 185 additions and 7 deletions
+46
View File
@@ -0,0 +1,46 @@
#include "traffic.h"
#include <atomic>
#include <cstdio>
namespace roro::net {
namespace {
constexpr size_t kUsers = static_cast<size_t>(User::Count);
std::atomic<uint32_t> in_[kUsers], out_[kUsers];
} // namespace
const char* userName(User user) {
switch (user) {
case User::Irc: return "IRC";
case User::Gemini: return "Gemini";
case User::DebugConsole: return "Debug Console";
case User::Updates: return "Updates";
default: return "?";
}
}
void received(User user, size_t bytes) { in_[static_cast<size_t>(user)] += static_cast<uint32_t>(bytes); }
void sent(User user, size_t bytes) { out_[static_cast<size_t>(user)] += static_cast<uint32_t>(bytes); }
Traffic traffic(User user) { return {in_[static_cast<size_t>(user)], out_[static_cast<size_t>(user)]}; }
void resetTraffic() {
for (size_t i = 0; i < kUsers; i++) in_[i] = out_[i] = 0;
}
uint32_t bytesPerSecond(uint32_t before, uint32_t now, uint32_t elapsedMs) {
if (!elapsedMs) return 0;
return static_cast<uint32_t>(static_cast<uint64_t>(now - before) * 1000 / elapsedMs); // wraps with the counter
}
std::string formatTraffic(uint32_t bytes) {
char s[16];
if (bytes < 1000) std::snprintf(s, sizeof s, "%u B", static_cast<unsigned>(bytes));
else if (bytes < 10 * 1024) std::snprintf(s, sizeof s, "%.1f KB", bytes / 1024.0);
else if (bytes < 1000 * 1024) std::snprintf(s, sizeof s, "%u KB", static_cast<unsigned>(bytes / 1024));
else if (bytes < 10u * 1024 * 1024) std::snprintf(s, sizeof s, "%.1f MB", bytes / 1048576.0);
else std::snprintf(s, sizeof s, "%u MB", static_cast<unsigned>(bytes / 1048576));
return s;
}
} // namespace roro::net
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
namespace roro::net {
// Bytes each network service has read and written since boot (S1, Q121), as the service sees
// them: for TLS connections that's the plain text, without the handshake or record overhead.
// Counted from the services' own tasks, read from the main loop.
enum class User : uint8_t { Irc, Gemini, DebugConsole, Updates, Count };
const char* userName(User user);
struct Traffic {
uint32_t in = 0, out = 0;
};
void received(User user, size_t bytes);
void sent(User user, size_t bytes);
Traffic traffic(User user);
void resetTraffic(); // for tests
uint32_t bytesPerSecond(uint32_t before, uint32_t now, uint32_t elapsedMs);
std::string formatTraffic(uint32_t bytes); // "999 B", "1.5 KB", "12 KB", "1.7 MB"
} // namespace roro::net
+10 -2
View File
@@ -19,6 +19,7 @@
#include "event_bus.h"
#include "file_receiver.h"
#include "ipv4.h"
#include "traffic.h"
#include "key_mapper.h"
#include "platform/console.h"
#include "platform/crash_report.h"
@@ -383,7 +384,7 @@ static void ipTrialStep() {
static const char* const kHelp =
"info firmware, uptime, memory, Wi-Fi, app slots\n"
"tasks FreeRTOS tasks: state, priority, free stack, CPU\n"
"tasks FreeRTOS tasks: state, priority, free stack, CPU\n" "net bytes each network service has read and written since boot\n"
"reboot restart\n"
"boot other restart into the other app slot (manual Rollback)\n"
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
@@ -415,7 +416,7 @@ static const char* const kHelp =
// Commands that only touch what Safe Mode starts.
static bool safeModeCommand(const String& line) {
return line == "help" || line == "info" || line == "tasks" || line == "reboot" || line == "boot other" ||
return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" ||
line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") ||
line == "wifi status" || line.startsWith("wifi add ");
}
@@ -433,6 +434,13 @@ static void runCommand(String line) {
system_info::printSlots(console, nvs);
}
if (line == "tasks") system_info::printTasks(console);
if (line == "net") { // bytes each service has read and written since boot (S1, Q121)
for (int i = 0; i < static_cast<int>(net::User::Count); i++) {
net::Traffic t = net::traffic(static_cast<net::User>(i));
console.printf("net: %-14s in %10lu out %10lu\n", net::userName(static_cast<net::User>(i)), (unsigned long)t.in,
(unsigned long)t.out);
}
}
if (line == "reboot") {
console.println("restarting");
delay(300);
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "traffic.h"
namespace roro {
// A NetworkClient (or NetworkClientSecure) that adds what it reads and writes to its service's
// traffic counters (S1, Q121). Only the two buffer calls are overridden: the single-byte ones,
// print() and printf() all go through them, so every byte is counted once.
template <class Base>
class Counted : public Base {
public:
explicit Counted(net::User user) : user_(user) {}
Counted(const Base& accepted, net::User user) : Base(accepted), user_(user) {}
// Overriding the buffer calls hides the single-byte ones; these put them back. They end up in
// the buffer calls below, where the counting happens.
int read() override { return Base::read(); }
size_t write(uint8_t byte) override { return Base::write(byte); }
size_t write(const uint8_t* buf, size_t size) override {
size_t n = Base::write(buf, size);
net::sent(user_, n);
return n;
}
int read(uint8_t* buf, size_t size) override {
int n = Base::read(buf, size);
if (n > 0) net::received(user_, static_cast<size_t>(n));
return n;
}
private:
net::User user_;
};
} // namespace roro
+2 -1
View File
@@ -17,6 +17,7 @@
#include "file_receiver.h"
#include "sha256.h"
#include "platform/console.h"
#include "platform/counted_client.h"
#include "sd_fault.h"
#include "version.h"
@@ -110,7 +111,7 @@ void DebugConsole::listen() {
listening = false;
}
if (listening) {
NetworkClient client = server.accept();
Counted<NetworkClient> client(server.accept(), net::User::DebugConsole);
if (client) {
client.setNoDelay(true);
if (authenticate(client)) serve(client);
+2 -1
View File
@@ -12,6 +12,7 @@
#include "gemtext.h"
#include "platform/console.h"
#include "sha256.h"
#include "platform/counted_client.h"
#include "storage_paths.h"
namespace roro {
@@ -544,7 +545,7 @@ void GeminiService::fetchOne(const std::string& url, GeminiPage& page, bool load
auto track = [this]() { lowest_ = std::min<size_t>(lowest_, esp_get_free_heap_size()); };
size_t freeBefore = esp_get_free_heap_size(); // what's left once the connection closes again
NetworkClientSecure tls;
Counted<NetworkClientSecure> tls(net::User::Gemini);
tls.setInsecure(); // trust on first use: the pinned fingerprint is what counts (Q71)
tls.setTimeout(15);
if (!tls.connect(u.host.c_str(), u.portOrDefault())) {
+3 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include <NetworkClientSecure.h>
#include "platform/counted_client.h"
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
@@ -75,8 +76,8 @@ class IrcService : public Service {
std::unique_ptr<IrcSession> session_;
SemaphoreHandle_t lock_ = nullptr;
TaskHandle_t task_ = nullptr;
NetworkClientSecure tlsClient_;
NetworkClient plainClient_;
Counted<NetworkClientSecure> tlsClient_{net::User::Irc}; // counted for the System App (S1, Q121)
Counted<NetworkClient> plainClient_{net::User::Irc};
NetworkClient* conn_ = &tlsClient_; // whichever the config asks for
ReconnectPolicy backoff_;
std::string partial_;
+2 -1
View File
@@ -1,4 +1,5 @@
#include "update_service.h"
#include "platform/counted_client.h"
#include <SD.h>
#include <WiFi.h>
@@ -208,7 +209,7 @@ void UpdateService::listen() {
}
}
if (listening && phase_ == Phase::Idle) {
NetworkClient client = server.accept();
Counted<NetworkClient> client(server.accept(), net::User::Updates);
if (client) {
client.setNoDelay(true);
NetSource src(client);
+54
View File
@@ -0,0 +1,54 @@
#include <unity.h>
#include "traffic.h"
using namespace roro::net;
void setUp() { resetTraffic(); }
void tearDown() {}
void test_counts_per_service() {
received(User::Irc, 100);
received(User::Irc, 20);
sent(User::Irc, 7);
sent(User::Gemini, 40);
TEST_ASSERT_EQUAL_UINT32(120, traffic(User::Irc).in);
TEST_ASSERT_EQUAL_UINT32(7, traffic(User::Irc).out);
TEST_ASSERT_EQUAL_UINT32(0, traffic(User::Gemini).in);
TEST_ASSERT_EQUAL_UINT32(40, traffic(User::Gemini).out);
TEST_ASSERT_EQUAL_UINT32(0, traffic(User::Updates).in);
}
void test_names() {
TEST_ASSERT_EQUAL_STRING("IRC", userName(User::Irc));
TEST_ASSERT_EQUAL_STRING("Gemini", userName(User::Gemini));
TEST_ASSERT_EQUAL_STRING("Debug Console", userName(User::DebugConsole));
TEST_ASSERT_EQUAL_STRING("Updates", userName(User::Updates));
}
// Bytes a second between two readings; the counter wraps at 4 GB like any uint32.
void test_rate() {
TEST_ASSERT_EQUAL_UINT32(1000, bytesPerSecond(5000, 6000, 1000));
TEST_ASSERT_EQUAL_UINT32(500, bytesPerSecond(0, 1000, 2000));
TEST_ASSERT_EQUAL_UINT32(0, bytesPerSecond(7, 7, 1000));
TEST_ASSERT_EQUAL_UINT32(20, bytesPerSecond(0xFFFFFFF6u, 10, 1000));
TEST_ASSERT_EQUAL_UINT32(0, bytesPerSecond(1, 2, 0));
}
void test_sizes_for_humans() {
TEST_ASSERT_EQUAL_STRING("0 B", formatTraffic(0).c_str());
TEST_ASSERT_EQUAL_STRING("999 B", formatTraffic(999).c_str());
TEST_ASSERT_EQUAL_STRING("1.5 KB", formatTraffic(1536).c_str());
TEST_ASSERT_EQUAL_STRING("12 KB", formatTraffic(12 * 1024 + 100).c_str());
TEST_ASSERT_EQUAL_STRING("1.7 MB", formatTraffic(1774688).c_str());
TEST_ASSERT_EQUAL_STRING("48 MB", formatTraffic(48u * 1024 * 1024).c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_counts_per_service);
RUN_TEST(test_names);
RUN_TEST(test_rate);
RUN_TEST(test_sizes_for_humans);
return UNITY_END();
}