feat(sim): add variants/sim/ — real companion_radio firmware on native + Emscripten

New board variant compiling the unmodified MyMesh/UITask/DataStore app
logic against real mesh::Radio/MainBoard/RTCClock/RNG interfaces, for
running the actual firmware outside embedded hardware:

- Native (plain g++, platform = native): ASCII-art display over stdout,
  stdin-driven input, local-disk-backed DataStore/IdentityStore.
- Emscripten/WASM (variants/sim/build_wasm.sh, since PlatformIO's native
  platform force-overrides any CC/CXX toolchain override back to system
  clang++): canvas-backed display, IDBFS-backed persistence across page
  reloads, JS-callable input via sim_enqueue_key(), emscripten_set_main_loop.

Real rweather/Crypto (AES128/SHA256/Ed25519) vendored unmodified and
proven working on both targets. variants/sim/web/index.html is a bare
verification harness, not the polished website embed.
This commit is contained in:
Jakub
2026-09-03 00:46:47 +02:00
parent bbf107d62c
commit 8f4c92a217
197 changed files with 23070 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
#pragma once
// Minimal native Arduino-core shim for the SIM_PLATFORM (variants/sim)
// native build. Scoped empirically (see the Phase-1 status report) by
// grepping examples/companion_radio + ui-new + the src/helpers files the
// sim build actually compiles for real Arduino API usage -- the app logic
// turned out to use almost none of it: no Arduino String anywhere, no
// PROGMEM/pgm_read, no digitalWrite/pinMode/analogRead/Wire/SPI reachable
// (all gated behind board-specific PIN_* defines this build never sets),
// and Serial. only in main.cpp's Serial.begin() plus MyMesh.cpp's
// CLI-rescue debug command handler.
//
// Deliberately NOT defining the ARDUINO preprocessor macro: a couple of
// vendored third-party libs (CayenneLPP.cpp, ArduinoJson) branch on
// `#ifdef ARDUINO` to choose between Arduino String/Stream and plain
// std::string/std::ostream -- leaving ARDUINO undefined routes them onto
// their portable std:: path, which is exactly what we want and needs no
// Arduino String implementation at all.
#include <cstdint>
#include <cstdlib>
#include <cmath>
#include <chrono>
#include <thread>
#include <algorithm>
#include <type_traits>
#include "Stream.h"
using std::isnan;
using std::isinf;
// --- timing -----------------------------------------------------------
inline uint32_t _sim_millis_start_epoch_ms() {
static const uint32_t t0 = (uint32_t)std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
return t0;
}
inline unsigned long millis() {
uint32_t now = (uint32_t)std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
return (unsigned long)(now - _sim_millis_start_epoch_ms());
}
inline unsigned long micros() {
uint64_t now = (uint64_t)std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
return (unsigned long)now;
}
// Real delay(); MyMesh.cpp:2848 and UITask.cpp:2619's one-shot pre-reboot/
// pre-shutdown pauses get their own SIM_PLATFORM branch that skips calling
// this entirely (see the Phase-1 status report), so an actual sleep here
// never blocks the sim's stdin-reader thread for long.
inline void delay(unsigned long ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}
// --- randomSeed()/random() (Arduino's global RNG, used by StdRNG in
// src/helpers/ArduinoHelpers.h -- NOT the same thing as mesh::RNG) ----
inline void randomSeed(unsigned long seed) { if (seed != 0) ::srand((unsigned)seed); }
inline long random(long howbig) {
if (howbig <= 0) return 0;
return (long)(::rand() % howbig);
}
inline long random(long howsmall, long howbig) {
if (howsmall >= howbig) return howsmall;
return howsmall + (long)(::rand() % (howbig - howsmall));
}
// --- misc Arduino macros/helpers ---------------------------------------
#ifndef PROGMEM
#define PROGMEM
#endif
#ifndef F
#define F(x) (x)
#endif
#ifndef PSTR
#define PSTR(x) (x)
#endif
// Real Arduino cores define min()/max()/constrain() as untyped macros, which
// works but is notorious for silently corrupting any <algorithm>/<vector>/etc
// internals that use those same names if such a header is ever included
// afterwards (this codebase's CayenneLPPPolyline.h uses std::vector/std::map,
// and SimFS.h/etc use std::string) -- so these are real (mixed-argument-type)
// templates instead, deducing a common value type via std::common_type
// rather than decltype(a<b?a:b) (which can deduce a *reference* type when
// both arguments are same-typed lvalues, and then fail to bind that
// reference to the temporary a cast/mixed-type branch produces).
#ifndef min
template <typename T, typename U>
typename std::common_type<T, U>::type min(T a, U b) { return a < b ? a : b; }
#endif
#ifndef max
template <typename T, typename U>
typename std::common_type<T, U>::type max(T a, U b) { return a > b ? a : b; }
#endif
#ifndef constrain
template <typename T, typename U, typename V>
typename std::common_type<T, U, V>::type constrain(T amt, U lo, V hi) {
return amt < lo ? lo : (amt > hi ? hi : amt);
}
#endif
#ifndef abs
#define abs(x) ((x) > 0 ? (x) : -(x))
#endif
// Arduino global itoa/ltoa/utoa/ultoa (AVR-libc-style, not in standard C++);
// src/helpers/TxtDataHelpers.cpp's float-formatting code calls ltoa()
// directly. glibc/libc++ don't provide these, so implement via snprintf's
// base-10/16/8/2 support for 10, else a manual digit loop for other bases.
inline char* ltoa(long value, char* result, int base) {
if (base == 10) { snprintf(result, 32, "%ld", value); return result; }
if (value == 0) { result[0] = '0'; result[1] = 0; return result; }
bool neg = value < 0;
unsigned long v = neg ? (unsigned long)(-value) : (unsigned long)value;
char tmp[34]; int i = 0;
while (v > 0) { int d = v % base; tmp[i++] = d < 10 ? ('0' + d) : ('a' + d - 10); v /= base; }
int j = 0;
if (neg) result[j++] = '-';
while (i > 0) result[j++] = tmp[--i];
result[j] = 0;
return result;
}
inline char* itoa(int value, char* result, int base) { return ltoa((long)value, result, base); }
// Cooperative-multitasking yield point (ESP32/RP2040 cores feed this to their
// scheduler/watchdog between blocking waits; a plain native process has none
// of that, so it's a no-op). src/helpers/StreamUtils.h calls it directly.
inline void yield() { }
// Opaque incomplete type used only for PROGMEM-string pointer typing on real
// Arduino cores (F("...") normally returns a `const __FlashStringHelper*`).
// Since F(x) is defined as a plain passthrough above (no real PROGMEM on a
// native build), nothing ever actually constructs one of these -- it only
// needs to exist as a type so a `print(const __FlashStringHelper*)` overload
// (e.g. examples/companion_radio/ui-new/TrailScreen.h's BoundedSerialPrint)
// declares cleanly, exactly like on a real board.
class __FlashStringHelper;
// --- Serial -------------------------------------------------------------
// Only ever used for Serial.begin() (main.cpp, ignored) and MyMesh.cpp's
// CLI-rescue debug command handler (Serial.print/println/printf as output,
// Serial.available()/read() as input). Output goes to real stdout; input
// always reports "nothing available" -- CLI-rescue isn't reachable through
// the sim's stdin-driven UI input path (see variants/sim/README) and isn't
// one of the Phase-1 exit criteria, but every call still needs to compile.
class SimSerialClass : public Stream {
public:
void begin(unsigned long baud) { }
// Real HardwareSerial's operator bool() reports "is a USB/BLE host
// actually connected" (some real boards genuinely gate on this, e.g.
// TrailScreen.h's GPX-export-over-serial feature). stdout is always
// "there" for a native process, so just always report ready.
explicit operator bool() const { return true; }
int available() override { return 0; }
int read() override { return -1; }
int peek() override { return -1; }
size_t write(uint8_t b) override { putchar(b); return 1; }
size_t write(const uint8_t *buffer, size_t size) override {
fwrite(buffer, 1, size, stdout);
return size;
}
void flush() override { fflush(stdout); }
};
extern SimSerialClass Serial;
+104
View File
@@ -0,0 +1,104 @@
#pragma once
// Minimal native stand-in for Arduino's Print class -- just enough of the
// real API surface for MeshCore's app logic (which never uses Arduino
// String) to link: byte/buffer write(), the numeric print() overloads, and
// printf() (a genuine Arduino Print extension on ESP32/etc, used sparingly
// by MyMesh.cpp's CLI-rescue debug path).
//
// Modelled on test/mocks/Stream.h's Print (same shape, same DEC/HEX/OCT/BIN
// constants) but adds printf() since the real app needs it.
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdarg>
#include <cstring>
#define DEC 10
#define HEX 16
#define OCT 8
#define BIN 2
class Print {
public:
virtual ~Print() = default;
virtual size_t write(uint8_t b) { return 1; }
size_t write(const char *str) {
if (str == NULL) return 0;
return write((const uint8_t *)str, strlen(str));
}
virtual size_t write(const uint8_t *buffer, size_t size) {
size_t t = 0;
for (size_t i = 0; i < size; i++) t += write(buffer[i]);
return t;
}
size_t write(const char *buffer, size_t size) {
return write((const uint8_t *)buffer, size);
}
virtual size_t print(unsigned char b, int base = DEC) { return printInt((unsigned long)b, base); }
virtual size_t print(int v, int base = DEC) { return base == DEC ? printSigned((long)v) : printInt((unsigned long)v, base); }
virtual size_t print(unsigned int v, int base = DEC) { return printInt((unsigned long)v, base); }
virtual size_t print(long v, int base = DEC) { return base == DEC ? printSigned(v) : printInt((unsigned long)v, base); }
virtual size_t print(unsigned long v, int base = DEC) { return printInt(v, base); }
virtual size_t print(long long v, int base = DEC) { char buf[32]; snprintf(buf, sizeof(buf), "%lld", v); return write(buf); }
virtual size_t print(unsigned long long v, int base = DEC) { char buf[32]; snprintf(buf, sizeof(buf), "%llu", v); return write(buf); }
virtual size_t print(double v, int digits = 2) { char buf[64]; snprintf(buf, sizeof(buf), "%.*f", digits, v); return write(buf); }
size_t print(char c) { return write((uint8_t)c); }
size_t print(const char* s) { return write(s); }
size_t println() { return write("\r\n"); }
size_t println(const char* s) { size_t n = print(s); n += println(); return n; }
size_t println(char c) { size_t n = print(c); n += println(); return n; }
size_t println(int v, int base = DEC) { size_t n = print(v, base); n += println(); return n; }
size_t println(unsigned int v, int base = DEC) { size_t n = print(v, base); n += println(); return n; }
size_t println(long v, int base = DEC) { size_t n = print(v, base); n += println(); return n; }
size_t println(unsigned long v, int base = DEC){ size_t n = print(v, base); n += println(); return n; }
size_t println(double v, int digits = 2) { size_t n = print(v, digits); n += println(); return n; }
// Real Arduino cores (ESP32/NRF52) expose Print::printf(); used by
// MyMesh.cpp's CLI-rescue debug command handler.
size_t printf(const char* fmt, ...) {
char buf[256];
va_list args;
va_start(args, fmt);
int n = vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
if (n < 0) return 0;
if ((size_t)n < sizeof(buf)) return write(buf);
// truncated -- still write what fit
return write(buf);
}
virtual void flush() { }
private:
size_t printInt(unsigned long v, int base) {
char buf[34];
if (base == DEC) { snprintf(buf, sizeof(buf), "%lu", v); }
else if (base == HEX) { snprintf(buf, sizeof(buf), "%lx", v); }
else if (base == OCT) { snprintf(buf, sizeof(buf), "%lo", v); }
else {
// generic base conversion (BIN etc.)
char tmp[34]; int i = 0;
unsigned long n = v;
if (n == 0) tmp[i++] = '0';
while (n > 0) { tmp[i++] = "0123456789abcdefghijklmnopqrstuvwxyz"[n % base]; n /= base; }
int j = 0;
while (i > 0) buf[j++] = tmp[--i];
buf[j] = 0;
}
return write(buf);
}
size_t printSigned(long v) {
char buf[34];
snprintf(buf, sizeof(buf), "%ld", v);
return write(buf);
}
};
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// Minimal native stand-in for Adafruit's RTClib.h. Real hardware boards pull
// this in transitively for helpers/AutoDiscoverRTCClock.h (not used by the
// sim build -- SimRTCClock.h reads the host wall clock directly) and for
// DateTime, which src/helpers/CommonCLI.cpp's "clock"/"clock sync"/"time"
// CLI commands genuinely construct and call hour()/minute()/day()/month()/
// year() on. Nothing in companion_radio (root files or ui-new/) uses
// DateTime, but CommonCLI.cpp (built for every board, including the sim) does.
//
// Implemented via the real C library's gmtime_r() (UTC calendar breakdown)
// rather than hand-rolling RTClib's own unixtime<->y/m/d/h/m/s conversion --
// same result, zero risk of a transcription bug in that math.
#include <ctime>
#include <cstdint>
class DateTime {
uint32_t _unixtime;
public:
DateTime(uint32_t t = 0) : _unixtime(t) { }
uint16_t year() const { return 1900 + tmFields().tm_year; }
uint8_t month() const { return (uint8_t)(tmFields().tm_mon + 1); }
uint8_t day() const { return (uint8_t)tmFields().tm_mday; }
uint8_t hour() const { return (uint8_t)tmFields().tm_hour; }
uint8_t minute() const { return (uint8_t)tmFields().tm_min; }
uint8_t second() const { return (uint8_t)tmFields().tm_sec; }
uint32_t unixtime() const { return _unixtime; }
private:
struct tm tmFields() const {
time_t t = (time_t)_unixtime;
struct tm out;
gmtime_r(&t, &out);
return out;
}
};
+33
View File
@@ -0,0 +1,33 @@
#pragma once
// Minimal native stand-in for Arduino's Stream class. src/Utils.h includes
// <Stream.h> directly (mesh::Utils::printHex(Stream&, ...)), and
// src/Identity.cpp's readFrom/writeTo/printTo take a Stream& -- both real
// hardware File objects (fs::File, Adafruit_LittleFS's File) and our own
// SimFile (variants/sim/SimFS.h) derive from this, exactly like on real
// boards.
#include "Print.h"
class Stream : public Print {
public:
virtual ~Stream() = default;
virtual int available() { return 0; }
virtual int availableForWrite() { return 0; }
virtual int read() { return -1; }
virtual int peek() { return -1; }
virtual size_t readBytes(char *buffer, size_t length) {
size_t i = 0;
while (i < length) {
int c = read();
if (c < 0) break;
buffer[i++] = (char)c;
}
return i;
}
size_t readBytes(uint8_t *buffer, size_t length) {
return readBytes((char *)buffer, length);
}
};
+35
View File
@@ -0,0 +1,35 @@
#pragma once
// Minimal native stand-in for densaugeo/base64's base64.hpp. Real board envs
// pull in the whole library via lib_deps; src/helpers/BaseChatMesh.cpp
// (built for every board, including the sim, whenever MAX_GROUP_CHANNELS is
// defined) only ever calls decode_base64() -- to decode a channel's
// user-supplied PSK -- so that's the only function reproduced here, as a
// plain standard base64 decoder (skips '=' padding/whitespace/invalid chars
// rather than erroring, same permissive behaviour the real library has).
static inline int _sim_b64_val(unsigned char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
inline unsigned int decode_base64(const unsigned char input[], unsigned int inputLength, unsigned char output[]) {
unsigned int out_len = 0;
int val = 0, bits = -8;
for (unsigned int i = 0; i < inputLength; i++) {
unsigned char c = input[i];
if (c == '=') break;
int v = _sim_b64_val(c);
if (v < 0) continue; // skip whitespace/invalid characters
val = (val << 6) + v;
bits += 6;
if (bits >= 0) {
output[out_len++] = (unsigned char)((val >> bits) & 0xFF);
bits -= 8;
}
}
return out_len;
}