Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions mac-cpu-feed/README.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 93 additions & 0 deletions mac-cpu-feed/cpu_feed.py
Original file line number Diff line number Diff line change
@@ -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.")
5 changes: 4 additions & 1 deletion src/config/CardConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down Expand Up @@ -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";
}
}
Expand All @@ -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
}
106 changes: 106 additions & 0 deletions src/hardware/CpuMonitor.cpp
Original file line number Diff line number Diff line change
@@ -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<volatile uint32_t*>(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);
}
73 changes: 73 additions & 0 deletions src/hardware/CpuMonitor.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#pragma once

#include <Arduino.h>

/**
* @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)
};
31 changes: 30 additions & 1 deletion src/ui/CardController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading