diff --git a/mac-cpu-feed/README.md b/mac-cpu-feed/README.md new file mode 100644 index 0000000..8a1d4da --- /dev/null +++ b/mac-cpu-feed/README.md @@ -0,0 +1,36 @@ +# Mac CPU feed for the CPU Hog card + +Streams your Mac's CPU load to a DeskHog running the **CPU Hog** card, so Max +runs on his wheel at the speed of *your computer's* CPU instead of the toy's own. + +## Run it + +1. Flash the firmware and add the **CPU Hog** card to your DeskHog. +2. Connect the DeskHog to your Mac over USB. +3. Run: + + ```sh + python3 cpu_feed.py + ``` + + It auto-detects the DeskHog's serial port. If it can't, pass it explicitly: + + ```sh + python3 cpu_feed.py /dev/tty.usbmodem1101 + ``` + +While it's running the card's label shows `MAC nn%`. Stop the script (Ctrl-C) +and within a few seconds the card falls back to the DeskHog's own CPU, and the +label flips back to `CPU nn%`. + +## How it works + +The script samples CPU usage once a second via `top` and writes lines like +`CPU:42\n` to the serial port. The firmware (`CpuHogCard`) parses those lines +and feeds the value to `CpuMonitor`, which prefers a fresh host reading over +its own idle-counter estimate. + +- Pure Python standard library — no `pip install` needed. +- macOS only (uses `top`); the wire format is trivial, so porting the sender to + Linux/Windows is just "print `CPU:<0-100>` once a second to the serial port." +- The DeskHog uses native USB CDC, so opening the port doesn't reset the board. diff --git a/mac-cpu-feed/cpu_feed.py b/mac-cpu-feed/cpu_feed.py new file mode 100644 index 0000000..4479380 --- /dev/null +++ b/mac-cpu-feed/cpu_feed.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Stream this Mac's CPU load to a DeskHog running the CPU Hog card. + +Samples CPU usage about once a second and writes "CPU:<0-100>\\n" to the +DeskHog's USB serial port. The firmware uses this to drive Max's running +speed; if the feed stops, the card falls back to the device's own CPU after +a few seconds and the label flips from "MAC" back to "CPU". + +Usage: + python3 cpu_feed.py # auto-detect the DeskHog port + python3 cpu_feed.py /dev/tty.usbmodem1101 # or pass the port explicitly + +Pure standard library - no pip installs required. macOS only (uses `top`). +""" + +import glob +import re +import subprocess +import sys +import time + +BAUD = 115200 +IDLE_RE = re.compile(r"CPU usage:.*?([\d.]+)%\s+idle") + + +def find_port(): + """Return the first likely DeskHog serial device, or None.""" + for pattern in ("/dev/tty.usbmodem*", "/dev/cu.usbmodem*"): + matches = sorted(glob.glob(pattern)) + if matches: + return matches[0] + return None + + +def read_cpu_percent(): + """Return macOS CPU busy percent (0-100) as 100 - idle. + + `top -l 2` prints two samples; the second reflects the last ~1s of + activity, so this call also paces the loop to roughly one reading/sec. + """ + out = subprocess.check_output( + ["top", "-l", "2", "-n", "0", "-s", "1"], + text=True, + ) + idle = None + for line in out.splitlines(): + m = IDLE_RE.search(line) + if m: + idle = float(m.group(1)) # keep the last (second) sample + if idle is None: + return 0 + return max(0, min(100, round(100 - idle))) + + +def configure(port): + """Set 8N1 at BAUD and keep modem control lines from resetting the board.""" + subprocess.run( + ["stty", "-f", port, str(BAUD), + "clocal", "-hupcl", "cs8", "-cstopb", "-parenb"], + check=True, + ) + + +def main(): + port = sys.argv[1] if len(sys.argv) > 1 else find_port() + if not port: + sys.exit( + "No DeskHog serial port found (looked for /dev/tty.usbmodem*). " + "Plug it in, or pass the port explicitly." + ) + + print(f"Feeding Mac CPU to DeskHog on {port} (Ctrl-C to stop)") + configure(port) + + with open(port, "wb", buffering=0) as ser: + while True: + try: + load = read_cpu_percent() + except subprocess.CalledProcessError: + time.sleep(1) + continue + try: + ser.write(f"CPU:{load}\n".encode()) + except OSError as exc: + sys.exit(f"\nLost the serial port ({exc}). Is the DeskHog still connected?") + print(f" MAC CPU {load:3d}% ", end="\r", flush=True) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nStopped.") diff --git a/src/config/CardConfig.h b/src/config/CardConfig.h index dfb7d2d..a18b265 100644 --- a/src/config/CardConfig.h +++ b/src/config/CardConfig.h @@ -13,7 +13,8 @@ enum class CardType { HELLO_WORLD, ///< Simple hello world card FLAPPY_HOG, ///< Flappy Hog game card QUESTION, ///< Question trivia card - PADDLE ///< Paddle game card + PADDLE, ///< Paddle game card + CPU_HOG ///< Max runs on a wheel, faster as CPU load rises // New card types can be added here }; @@ -85,6 +86,7 @@ inline String cardTypeToString(CardType type) { case CardType::FLAPPY_HOG: return "FLAPPY_HOG"; case CardType::QUESTION: return "QUESTION"; case CardType::PADDLE: return "PADDLE"; + case CardType::CPU_HOG: return "CPU_HOG"; default: return "UNKNOWN"; } } @@ -101,5 +103,6 @@ inline CardType stringToCardType(const String& str) { if (str == "FLAPPY_HOG") return CardType::FLAPPY_HOG; if (str == "QUESTION") return CardType::QUESTION; if (str == "PADDLE") return CardType::PADDLE; + if (str == "CPU_HOG") return CardType::CPU_HOG; return CardType::INSIGHT; // Default fallback } \ No newline at end of file diff --git a/src/hardware/CpuMonitor.cpp b/src/hardware/CpuMonitor.cpp new file mode 100644 index 0000000..6e74bb0 --- /dev/null +++ b/src/hardware/CpuMonitor.cpp @@ -0,0 +1,106 @@ +#include "hardware/CpuMonitor.h" + +CpuMonitor::CpuMonitor() + : _lastSampleMs(0) + , _smoothed(0.0f) + , _started(false) + , _hostLoad(0) + , _hostLoadMs(0) { + for (int i = 0; i < kCores; i++) { + _counters[i] = 0; + _lastCounts[i] = 0; + _maxRate[i] = 1.0f; // Avoid divide-by-zero before first calibration + } +} + +void CpuMonitor::observerTask(void* arg) { + // arg encodes the core index for this observer. + volatile uint32_t* counter = static_cast(arg); + for (;;) { + (*counter)++; + // Yield so we never starve the real idle task (needed for WDT feeding, + // power management, etc). At idle priority this still measures how much + // slack the core has. + taskYIELD(); + } +} + +void CpuMonitor::begin() { + if (_started) return; + _started = true; + + for (int core = 0; core < kCores; core++) { + char name[16]; + snprintf(name, sizeof(name), "cpuobs%d", core); + xTaskCreatePinnedToCore( + observerTask, + name, + 1024, + (void*)&_counters[core], + tskIDLE_PRIORITY, // Runs only in the core's spare time + nullptr, + core + ); + } + + _lastSampleMs = millis(); +} + +void CpuMonitor::setHostLoad(uint8_t percent) { + if (percent > 100) percent = 100; + _hostLoad = percent; + _hostLoadMs = millis(); + if (_hostLoadMs == 0) _hostLoadMs = 1; // 0 is the "never" sentinel +} + +bool CpuMonitor::hasFreshHostLoad() const { + if (_hostLoadMs == 0) return false; + return (millis() - _hostLoadMs) < kHostFreshMs; +} + +uint8_t CpuMonitor::getLoadPercent() { + uint32_t now = millis(); + + // Prefer a live host feed (e.g. a Mac streaming its CPU over serial). + if (hasFreshHostLoad()) { + float instant = (float)_hostLoad; + _smoothed += (instant - _smoothed) * 0.4f; + // Keep device sampling coherent for a clean handover when the feed drops. + _lastSampleMs = now; + for (int core = 0; core < kCores; core++) { + _lastCounts[core] = _counters[core]; + } + return (uint8_t)(_smoothed + 0.5f); + } + + uint32_t elapsed = now - _lastSampleMs; + if (elapsed < 50) { + // Too soon to get a meaningful delta; return the last smoothed value. + return (uint8_t)(_smoothed + 0.5f); + } + + float loadSum = 0.0f; + for (int core = 0; core < kCores; core++) { + uint32_t count = _counters[core]; + uint32_t delta = count - _lastCounts[core]; + _lastCounts[core] = count; + + float rate = (float)delta / (float)elapsed; // counts per ms + if (rate > _maxRate[core]) { + _maxRate[core] = rate; // Self-calibrate to the idlest run seen + } + + float idleFraction = rate / _maxRate[core]; // 1.0 == fully idle + float load = (1.0f - idleFraction) * 100.0f; + if (load < 0.0f) load = 0.0f; + if (load > 100.0f) load = 100.0f; + loadSum += load; + } + + _lastSampleMs = now; + + float instant = loadSum / (float)kCores; + // Light exponential smoothing so the hog's pace glides rather than jitters. + _smoothed += (instant - _smoothed) * 0.4f; + return (uint8_t)(_smoothed + 0.5f); +} diff --git a/src/hardware/CpuMonitor.h b/src/hardware/CpuMonitor.h new file mode 100644 index 0000000..5e5c6fa --- /dev/null +++ b/src/hardware/CpuMonitor.h @@ -0,0 +1,73 @@ +#pragma once + +#include + +/** + * @class CpuMonitor + * @brief Self-contained estimator for the DeskHog's own CPU load (0-100%). + * + * Spawns one low-priority "idle observer" task per core. Each task spins a + * volatile counter at idle priority, so it only advances when nothing more + * important is running on that core. The faster the counter climbs, the more + * idle the core is; the slower it climbs, the busier the core is. + * + * The estimator is self-calibrating: it tracks the fastest count rate it has + * ever observed as "fully idle" (100% free), so it does not depend on a clean + * boot-time measurement. Load is reported as the average across both cores. + * + * Reading the load is cheap and lock-free (a plain read of volatile counters), + * so it is safe to call from the UI task inside a card's update() loop. + * + * NOTE: this drives a fun desk-ornament animation, not a precise profiler. + * The idle-observer approach is a well-known ESP32 approximation, good to + * within a few percent - plenty for making a hedgehog run faster. + */ +class CpuMonitor { +public: + CpuMonitor(); + + /** + * @brief Start the per-core idle-observer tasks. Call once from setup(). + */ + void begin(); + + /** + * @brief Current estimated CPU load, 0-100 (smoothed). + * + * If a fresh host reading has been fed in (see setHostLoad), that value is + * used - so the hog can track a connected Mac's CPU. Otherwise it falls + * back to the device's own load: the count rate since the previous call, + * as 100 * (1 - rate / max) averaged across cores. Intended to be polled a + * few times per second. + */ + uint8_t getLoadPercent(); + + /** + * @brief Feed an external host CPU reading (0-100), e.g. from a Mac. + * + * Marks the reading as fresh; getLoadPercent() will prefer it over the + * device's own load until it goes stale (see kHostFreshMs). + */ + void setHostLoad(uint8_t percent); + + /** + * @brief True if a host reading has arrived recently (feed is live). + */ + bool hasFreshHostLoad() const; + +private: + static constexpr int kCores = 2; + static constexpr uint32_t kHostFreshMs = 4000; ///< Host feed staleness window + + static void observerTask(void* arg); + + volatile uint32_t _counters[kCores]; ///< Per-core spin counters + uint32_t _lastCounts[kCores]; ///< Counter snapshot at last sample + float _maxRate[kCores]; ///< Fastest observed counts/ms per core + uint32_t _lastSampleMs; ///< millis() at last sample + float _smoothed; ///< Exponentially smoothed load % + bool _started; + + uint8_t _hostLoad; ///< Last host reading, 0-100 + uint32_t _hostLoadMs; ///< millis() of last host reading (0 == never) +}; diff --git a/src/ui/CardController.cpp b/src/ui/CardController.cpp index 2cb08a9..6a2e055 100644 --- a/src/ui/CardController.cpp +++ b/src/ui/CardController.cpp @@ -63,7 +63,10 @@ CardController::~CardController() { void CardController::initialize(DisplayInterface* display) { // Set the display interface first setDisplayInterface(display); - + + // Start the CPU-load estimator that drives the CpuHogCard's run speed. + cpuMonitor.begin(); + // Initialize UI queue for thread-safe operations initUIQueue(); @@ -381,6 +384,32 @@ void CardController::initializeCardTypes() { return nullptr; }; registerCardType(paddleDef); + + // Register CPU_HOG card type + CardDefinition cpuHogDef; + cpuHogDef.type = CardType::CPU_HOG; + cpuHogDef.name = "CPU Hog"; + cpuHogDef.allowMultiple = false; + cpuHogDef.needsConfigInput = false; + cpuHogDef.configInputLabel = ""; + cpuHogDef.uiDescription = "Max runs on a wheel, faster the harder your CPU works"; + cpuHogDef.factory = [this](const String& configValue) -> lv_obj_t* { + CpuHogCard* newCard = new CpuHogCard(screen, &cpuMonitor); + + if (newCard && newCard->getCard()) { + // Add to unified tracking system + CardInstance instance{newCard, newCard->getCard()}; + dynamicCards[CardType::CPU_HOG].push_back(instance); + + // Register as input handler + cardStack->registerInputHandler(newCard->getCard(), newCard); + return newCard->getCard(); + } + + delete newCard; + return nullptr; + }; + registerCardType(cpuHogDef); } void CardController::handleCardConfigChanged() { diff --git a/src/ui/CardController.h b/src/ui/CardController.h index 1d622a1..0b141dd 100644 --- a/src/ui/CardController.h +++ b/src/ui/CardController.h @@ -14,6 +14,8 @@ #include "ui/FriendCard.h" #include "ui/examples/HelloWorldCard.h" #include "ui/FlappyHogCard.h" +#include "ui/CpuHogCard.h" +#include "hardware/CpuMonitor.h" #include "hardware/DisplayInterface.h" #include "EventQueue.h" #include "config/CardConfig.h" @@ -179,6 +181,8 @@ class CardController { // Legacy single instance tracking (for backwards compatibility during transition) FriendCard* animationCard; ///< Card for animations + + CpuMonitor cpuMonitor; ///< Device CPU-load estimator for CpuHogCard // Display interface for thread safety DisplayInterface* displayInterface; ///< Thread-safe display interface diff --git a/src/ui/CpuHogCard.cpp b/src/ui/CpuHogCard.cpp new file mode 100644 index 0000000..493d2b8 --- /dev/null +++ b/src/ui/CpuHogCard.cpp @@ -0,0 +1,162 @@ +#include "ui/CpuHogCard.h" +#include "Style.h" +#include "sprites/sprites.h" +#include +#include + +CpuHogCard::CpuHogCard(lv_obj_t* parent, CpuMonitor* cpu) + : _cpu(cpu) + , _card(nullptr) + , _background(nullptr) + , _anim_img(nullptr) + , _label(nullptr) + , _label_shadow(nullptr) + , _currentBucket(-1) + , _lastPollMs(0) + , _lineLen(0) { + + // Root card - black background, matching the other cards. + _card = lv_obj_create(parent); + if (!_card) return; + lv_obj_set_width(_card, lv_pct(100)); + lv_obj_set_height(_card, lv_pct(100)); + lv_obj_set_style_bg_color(_card, lv_color_black(), 0); + lv_obj_set_style_border_width(_card, 0, 0); + lv_obj_set_style_pad_all(_card, 5, 0); + lv_obj_set_style_margin_all(_card, 0, 0); + + // Green rounded panel. + _background = lv_obj_create(_card); + if (!_background) return; + lv_obj_set_style_radius(_background, 8, LV_PART_MAIN); + lv_obj_set_style_bg_color(_background, lv_color_hex(0x0E7A00), 0); + lv_obj_set_style_border_width(_background, 0, 0); + lv_obj_set_style_pad_all(_background, 5, 0); + lv_obj_set_width(_background, lv_pct(100)); + lv_obj_set_height(_background, lv_pct(100)); + + // Walking hog animation (same sprite set as FriendCard). + _anim_img = lv_animimg_create(_background); + if (!_anim_img) return; + lv_animimg_set_src(_anim_img, (const void**)walking_sprites, walking_sprites_count); + lv_animimg_set_repeat_count(_anim_img, LV_ANIM_REPEAT_INFINITE); + lv_img_set_zoom(_anim_img, 512); // 256 = 100%, 512 = 200% + lv_obj_align(_anim_img, LV_ALIGN_LEFT_MID, -10, 0); + + // Label + drop shadow for the CPU readout. + _label_shadow = lv_label_create(_background); + if (_label_shadow) { + lv_obj_set_style_text_font(_label_shadow, Style::loudNoisesFont(), 0); + lv_obj_set_style_text_color(_label_shadow, lv_color_black(), 0); + lv_obj_set_style_text_align(_label_shadow, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(_label_shadow, LV_LABEL_LONG_WRAP); + lv_obj_set_width(_label_shadow, lv_pct(60)); + lv_obj_align(_label_shadow, LV_ALIGN_RIGHT_MID, 0, 1); + } + _label = lv_label_create(_background); + if (_label) { + lv_obj_set_style_text_font(_label, Style::loudNoisesFont(), 0); + lv_obj_set_style_text_color(_label, lv_color_white(), 0); + lv_obj_set_style_text_align(_label, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(_label, LV_LABEL_LONG_WRAP); + lv_obj_set_width(_label, lv_pct(60)); + lv_obj_align(_label, LV_ALIGN_RIGHT_MID, -1, 0); + } + + // Start at whatever the CPU is doing right now. + uint8_t load = _cpu ? _cpu->getLoadPercent() : 0; + applySpeed(load); + setLabel(load, _cpu && _cpu->hasFreshHostLoad()); +} + +CpuHogCard::~CpuHogCard() { + if (isValidObject(_card)) { + lv_obj_add_flag(_card, LV_OBJ_FLAG_HIDDEN); + lv_obj_del_async(_card); + _card = nullptr; + _background = nullptr; + _anim_img = nullptr; + _label = nullptr; + _label_shadow = nullptr; + } +} + +bool CpuHogCard::isValidObject(lv_obj_t* obj) const { + return obj != nullptr; +} + +void CpuHogCard::applySpeed(uint8_t loadPercent) { + if (!isValidObject(_anim_img)) return; + + // Bucket the load so we only restart the animation on a real pace change. + int bucket = loadPercent / kBucketPct; + if (bucket == _currentBucket) return; + _currentBucket = bucket; + + // Map load 0..100 -> duration kSlowestMs..kFastestMs (higher load = shorter + // loop = faster run). + int duration = kSlowestMs - + ((kSlowestMs - kFastestMs) * (int)loadPercent) / 100; + if (duration < kFastestMs) duration = kFastestMs; + if (duration > kSlowestMs) duration = kSlowestMs; + + lv_animimg_set_duration(_anim_img, duration); + lv_animimg_start(_anim_img); // Restart with the new pace +} + +void CpuHogCard::setLabel(uint8_t loadPercent, bool fromHost) { + char buf[16]; + // "MAC" when a live host feed is driving it, "CPU" for the device's own load. + snprintf(buf, sizeof(buf), "%s %u%%", fromHost ? "MAC" : "CPU", (unsigned)loadPercent); + if (isValidObject(_label)) lv_label_set_text(_label, buf); + if (isValidObject(_label_shadow)) lv_label_set_text(_label_shadow, buf); +} + +void CpuHogCard::pollHostFeed() { + // Drain any bytes from USB serial, assembling newline-terminated lines of + // the form "CPU:nn" streamed by the Mac companion script. + while (Serial.available() > 0) { + char c = (char)Serial.read(); + if (c == '\n' || c == '\r') { + if (_lineLen > 0) { + _lineBuf[_lineLen] = '\0'; + if (strncmp(_lineBuf, "CPU:", 4) == 0) { + int v = atoi(_lineBuf + 4); + if (v < 0) v = 0; + if (v > 100) v = 100; + if (_cpu) _cpu->setHostLoad((uint8_t)v); + } + _lineLen = 0; + } + } else if (_lineLen < sizeof(_lineBuf) - 1) { + _lineBuf[_lineLen++] = c; + } else { + _lineLen = 0; // Overlong garbage; resync on next newline. + } + } +} + +bool CpuHogCard::update() { + // Called on the UI task while this card is active, so LVGL calls are safe. + if (!_cpu) return true; + + // Poll the serial feed every tick so we don't drop bytes. + pollHostFeed(); + + uint32_t now = millis(); + if (now - _lastPollMs < kPollIntervalMs) { + return true; // Keep receiving updates, but don't over-sample the pace. + } + _lastPollMs = now; + + uint8_t load = _cpu->getLoadPercent(); + applySpeed(load); + setLabel(load, _cpu->hasFreshHostLoad()); + return true; +} + +bool CpuHogCard::handleButtonPress(uint8_t button_index) { + // No interaction needed - it's a desk ornament. Let the nav stack handle + // navigation buttons. + return false; +} diff --git a/src/ui/CpuHogCard.h b/src/ui/CpuHogCard.h new file mode 100644 index 0000000..14309af --- /dev/null +++ b/src/ui/CpuHogCard.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include "ui/InputHandler.h" +#include "hardware/CpuMonitor.h" + +/** + * @class CpuHogCard + * @brief A desk-ornament card: Max runs on a wheel, faster as CPU load rises. + * + * Reuses the walking sprite animation from FriendCard, but instead of a fixed + * loop duration the run speed is driven live by CpuMonitor - idle CPU gives a + * lazy amble, a pegged CPU gives a frantic sprint. A small label shows the + * current load percentage. + * + * The animation speed is bucketed (see kBucketPct) so we only restart the + * LVGL animation when the pace changes meaningfully, avoiding per-frame jitter. + */ +class CpuHogCard : public InputHandler { +public: + /** + * @param parent LVGL parent object + * @param cpu Shared CPU load monitor (not owned by this card) + */ + CpuHogCard(lv_obj_t* parent, CpuMonitor* cpu); + ~CpuHogCard(); + + lv_obj_t* getCard() const { return _card; } + + bool handleButtonPress(uint8_t button_index) override; + bool update() override; ///< Polls CPU load and retunes the run speed + void prepareForRemoval() override { _card = nullptr; } + +private: + // Run-speed mapping: loop duration in ms at 0% and 100% CPU. Shorter loop + // means the six-frame walk cycle plays faster, i.e. the hog runs faster. + static constexpr int kSlowestMs = 1500; ///< Idle amble + static constexpr int kFastestMs = 180; ///< Full-tilt sprint + static constexpr int kBucketPct = 5; ///< Only retune per 5% load change + static constexpr uint32_t kPollIntervalMs = 250; ///< CPU sampling cadence + + bool isValidObject(lv_obj_t* obj) const; + void applySpeed(uint8_t loadPercent); + void setLabel(uint8_t loadPercent, bool fromHost); + void pollHostFeed(); ///< Read "CPU:nn" lines from USB serial (Mac feed) + + CpuMonitor* _cpu; ///< Borrowed CPU monitor + lv_obj_t* _card; ///< Root container + lv_obj_t* _background; ///< Green rounded panel + lv_obj_t* _anim_img; ///< Walking sprite animation + lv_obj_t* _label; ///< "CPU nn%" text + lv_obj_t* _label_shadow; ///< Drop shadow for the label + + int _currentBucket; ///< Last applied speed bucket (-1 == unset) + uint32_t _lastPollMs; ///< millis() of last CPU poll + + // Line assembly for the "CPU:nn" host feed on USB serial. + char _lineBuf[24]; + uint8_t _lineLen; +};