Harden firmware state and transfer handling

This commit is contained in:
Julian Appel 2026-07-24 09:49:21 +02:00
parent 50dbf8fbee
commit ce5db617a1
27 changed files with 558 additions and 231 deletions

View file

@ -101,7 +101,10 @@ void CButton::clear_override()
void CButton::set_anim(LEDAnim anim, uint16_t period_ms, uint16_t phase_offset_ms)
{
m_anim = anim;
m_anim_period_ms = (period_ms > 0) ? period_ms : 1; // Division durch 0 vermeiden
// PULSE teilt intern durch die halbe Periode und braucht daher mindestens
// 2 ms. Für alle anderen Animationen genügt 1 ms als sicherer Mindestwert.
uint16_t minimum = (anim == LEDAnim::PULSE) ? 2 : 1;
m_anim_period_ms = (period_ms >= minimum) ? period_ms : minimum;
// phase_offset_ms in die Vergangenheit zurücksetzen → verschobener Startpunkt
m_anim_start_ms = millis() - phase_offset_ms;
m_dirty = true;

View file

@ -9,8 +9,8 @@
//
// Nebenläufigkeit:
// Encoder-ISRs und der Matrixcallback im Loop können beide push() aufrufen.
// Dieser gemischte Producerfall ist aktuell nicht durch eine Critical
// Section geschützt; siehe doc/09_known_limitations.md.
// Der Matrixcallback schützt seinen push() mit einer kurzen Critical Section,
// sodass m_tail nie von Loop und ISR gleichzeitig verändert wird.
#include "CEventQueue.h"

View file

@ -9,8 +9,10 @@
//
// Nebenläufigkeit:
// push() wird aus Encoder-ISRs und aus dem Matrixcallback im Loop aufgerufen.
// pop() läuft ebenfalls im Loop. Es gibt aktuell keine Critical Section für
// den Fall, dass ein Encoderinterrupt einen Matrix-Push unterbricht.
// Der Matrixcallback maskiert Interrupts während push(); Encoder-ISRs können
// sich auf dem Single-Core-M0+ nicht gleichpriorisiert gegenseitig unterbrechen.
// pop() läuft im Loop; ein gleichzeitig eintreffender Push wird spätestens
// im nächsten processEvents()-Durchlauf sichtbar.
#pragma once
#include "SEvent.h"
@ -31,6 +33,6 @@ public:
private:
static const uint8_t QUEUE_SIZE = 17; // 16 nutzbare Slots
SEvent m_buf[QUEUE_SIZE];
uint8_t m_head = 0; // Nächster Lese-Index (Consumer: pop)
uint8_t m_tail = 0; // Nächster Schreib-Index (Producer: push)
volatile uint8_t m_head = 0; // Nächster Lese-Index (Consumer: pop)
volatile uint8_t m_tail = 0; // Nächster Schreib-Index (Producer: push)
};

View file

@ -54,12 +54,17 @@ static void matrix_cb(uint8_t key, bool pressed)
ev.type = pressed ? EventType::KEY_DOWN : EventType::KEY_UP;
ev.key_id = key;
ev.payload = 0;
// Encoder-ISRs benutzen dieselbe Queue. Den Loop-Producer kurz gegen einen
// dazwischenlaufenden ISR-Push schützen; Encoder-Pushes selbst laufen
// bereits mit maskierten gleichpriorisierten Interrupts.
noInterrupts();
s_queue->push(ev);
interrupts();
}
// Wird von handle_encoder() aufgerufen läuft im ISR-Kontext (EIC-Interrupt).
// Die Queue vermeidet den Heap, schützt den gemischten Matrix-/ISR-Producerfall
// aber aktuell nicht mit einer Critical Section.
// Der Matrix-Producer maskiert Interrupts während seines Queue-Pushs.
static void encoder_cb(uint8_t enc, int8_t dir)
{
if (!s_queue) return;
@ -75,8 +80,10 @@ static void encoder_cb(uint8_t enc, int8_t dir)
CMainController::CMainController()
: m_cfg_chunks_expected(0)
, m_cfg_receiving(false)
, m_cfg_transfer_valid(false)
, m_macro_chunks_expected(0)
, m_macro_receiving(false)
, m_macro_transfer_valid(false)
, m_factory_left_held(false)
, m_factory_right_held(false)
, m_factory_reset_armed(false)
@ -84,10 +91,21 @@ CMainController::CMainController()
, m_factory_hold_started_ms(0)
{
memset(m_cfg_buf, 0, sizeof(m_cfg_buf));
memset(m_cfg_received, 0, sizeof(m_cfg_received));
memset(m_macro_buf, 0, sizeof(m_macro_buf));
memset(m_macro_received, 0, sizeof(m_macro_received));
memset(&m_macros, 0, sizeof(m_macros));
}
bool CMainController::all_chunks_received(
const uint8_t* received, uint8_t count)
{
for (uint8_t i = 0; i < count; i++) {
if (received[i] == 0) return false;
}
return true;
}
void CMainController::setup()
{
macro_config_load(m_macros); // Makro-Tabelle aus NVM laden (oder leere Tabelle)
@ -148,6 +166,11 @@ void CMainController::init_buttons()
// Phase gleichmäßig verteilen → stehender Regenbogen dreht sich
uint16_t phase = (uint16_t)((uint32_t)mx_idx * period / 20);
m_buttons[key].set_anim(LEDAnim::COLOR_CYCLE, period, phase);
} else if (anim == LEDAnim::COLOR_FADE) {
// Config enthält nur eine Ziel-/Base-Farbe. COLOR_FADE wird beim
// Laden daher eindeutig als einmaliges Schwarz→Base interpretiert.
m_buttons[key].set_base(RGB(0, 0, 0));
m_buttons[key].set_color_fade(base, period);
} else {
m_buttons[key].set_anim(anim, period);
}
@ -214,17 +237,30 @@ void CMainController::poll_vendor()
// Neuen Empfang starten bisherige Daten verwerfen
m_cfg_chunks_expected = pkt.key_id();
m_cfg_receiving = true;
m_cfg_transfer_valid = (m_cfg_chunks_expected == CONFIG_CHUNKS);
memset(m_cfg_buf, 0, sizeof(m_cfg_buf));
memset(m_cfg_received, 0, sizeof(m_cfg_received));
break;
case USB_CMD_CONFIG_DATA:
if (m_cfg_receiving) {
// 6 Nutzbytes ab Puffer-Offset (chunk_index × 6) eintragen
uint16_t offset = (uint16_t)pkt.key_id() * 6;
if (offset < sizeof(m_cfg_buf)) {
uint8_t chunk = pkt.key_id();
uint16_t offset = (uint16_t)chunk * SERIAL_PAYLOAD_BYTES;
if (m_cfg_transfer_valid &&
chunk < CONFIG_CHUNKS &&
m_cfg_received[chunk] == 0 &&
offset < sizeof(m_cfg_buf))
{
uint16_t remaining = (uint16_t)(sizeof(m_cfg_buf) - offset);
uint8_t count = (uint8_t)(remaining > 6 ? 6 : remaining);
uint8_t count = (uint8_t)(
remaining > SERIAL_PAYLOAD_BYTES
? SERIAL_PAYLOAD_BYTES
: remaining);
memcpy(m_cfg_buf + offset, &pkt.data[2], count);
m_cfg_received[chunk] = 1;
} else {
m_cfg_transfer_valid = false;
}
}
break;
@ -237,8 +273,8 @@ void CMainController::poll_vendor()
nvm_config_load(cfg); // ungültige NVM → Defaults
const uint8_t* raw = reinterpret_cast<const uint8_t*>(&cfg);
const uint16_t sz = sizeof(SDeviceConfig); // 740
const uint8_t payload = 6;
uint8_t chunks = (uint8_t)((sz + payload - 1) / payload); // 124
const uint8_t payload = SERIAL_PAYLOAD_BYTES;
const uint8_t chunks = CONFIG_CHUNKS;
usb_serial_send(USB_EVT_CONFIG_BEGIN, chunks);
@ -258,14 +294,17 @@ void CMainController::poll_vendor()
}
case USB_CMD_CONFIG_COMMIT:
if (m_cfg_receiving) {
m_cfg_receiving = false;
{
bool complete =
m_cfg_receiving &&
m_cfg_transfer_valid &&
all_chunks_received(m_cfg_received, CONFIG_CHUNKS);
m_cfg_receiving = false;
if (complete) {
SDeviceConfig cfg;
memcpy(&cfg, m_cfg_buf, sizeof(cfg));
if (cfg.magic == NVM_CONFIG_MAGIC &&
cfg.version == NVM_CONFIG_VERSION &&
cfg.crc == nvm_config_crc(cfg))
{
if (nvm_config_validate(cfg)) {
if (nvm_config_save(cfg)) {
init_buttons();
usb_serial_send(USB_EVT_CONFIG_ACK, 0); // Erfolg melden
@ -277,46 +316,76 @@ void CMainController::poll_vendor()
{
usb_serial_send(USB_EVT_CONFIG_NACK, 0); // CRC/Magic-Fehler
}
} else {
usb_serial_send(USB_EVT_CONFIG_NACK, 0);
}
break;
}
// ── Makro-Übertragung: BEGIN → n×DATA → COMMIT ──────────────────
case USB_CMD_MACRO_BEGIN:
m_macro_chunks_expected = pkt.key_id();
m_macro_receiving = true;
m_macro_transfer_valid = (m_macro_chunks_expected == MACRO_CHUNKS);
memset(m_macro_buf, 0, sizeof(m_macro_buf));
memset(m_macro_received, 0, sizeof(m_macro_received));
break;
case USB_CMD_MACRO_DATA:
if (m_macro_receiving) {
uint16_t offset = (uint16_t)pkt.key_id() * 6;
if (offset < sizeof(m_macro_buf)) {
uint8_t chunk = pkt.key_id();
uint16_t offset =
(uint16_t)chunk * SERIAL_PAYLOAD_BYTES;
if (m_macro_transfer_valid &&
chunk < MACRO_CHUNKS &&
m_macro_received[chunk] == 0 &&
offset < sizeof(m_macro_buf))
{
uint16_t remaining = (uint16_t)(sizeof(m_macro_buf) - offset);
uint8_t count = (uint8_t)(remaining > 6 ? 6 : remaining);
uint8_t count = (uint8_t)(
remaining > SERIAL_PAYLOAD_BYTES
? SERIAL_PAYLOAD_BYTES
: remaining);
memcpy(m_macro_buf + offset, &pkt.data[2], count);
m_macro_received[chunk] = 1;
} else {
m_macro_transfer_valid = false;
}
}
break;
case USB_CMD_MACRO_COMMIT:
if (m_macro_receiving) {
m_macro_receiving = false;
memcpy(&m_macros, m_macro_buf, sizeof(m_macros));
if (macro_config_save(m_macros)) {
usb_serial_send(USB_EVT_MACRO_ACK, 0);
} else {
usb_serial_send(USB_EVT_MACRO_NACK, 0); // NVM-Timeout
}
{
bool complete =
m_macro_receiving &&
m_macro_transfer_valid &&
all_chunks_received(m_macro_received, MACRO_CHUNKS);
m_macro_receiving = false;
SMacroTable incoming;
if (complete) {
memcpy(&incoming, m_macro_buf, sizeof(incoming));
}
if (complete &&
macro_config_validate(incoming) &&
macro_config_save(incoming))
{
m_macros = incoming;
usb_serial_send(USB_EVT_MACRO_ACK, 0);
} else {
usb_serial_send(USB_EVT_MACRO_NACK, 0);
}
break;
}
// ── Makro-Dump anfordern ─────────────────────────────────────────
case USB_CMD_MACRO_READ:
{
const uint8_t* raw = reinterpret_cast<const uint8_t*>(&m_macros);
const uint16_t sz = sizeof(SMacroTable); // 512
const uint8_t payload = 6;
uint8_t chunks = (uint8_t)((sz + payload - 1) / payload); // 86
const uint8_t payload = SERIAL_PAYLOAD_BYTES;
const uint8_t chunks = MACRO_CHUNKS;
usb_serial_send(USB_EVT_MACRO_BEGIN, chunks);
@ -348,7 +417,7 @@ void CMainController::poll_vendor()
//
// KEY_DOWN: execute_action_down() HID-Taste wird gedrückt, bleibt aktiv bis KEY_UP.
// KEY_UP: execute_action_up() HID-Taste wird losgelassen.
// Encoder CW/CCW: execute_action_down() + execute_action_up() für atomare TAP-Sequenz.
// Encoder CW/CCW: Host-Event mit Richtung oder HID-Tap-Sequenz.
void CMainController::processEvents()
{
@ -378,17 +447,15 @@ void CMainController::processEvents()
case EventType::ENC_CW:
if (ev.key_id < 4) {
execute_action_down(m_enc_cw[ev.key_id], ev.key_id);
delay(10);
execute_action_up(m_enc_cw[ev.key_id], ev.key_id);
execute_encoder_action(
m_enc_cw[ev.key_id], ev.key_id, USB_EVT_ENC_CW);
}
break;
case EventType::ENC_CCW:
if (ev.key_id < 4) {
execute_action_down(m_enc_ccw[ev.key_id], ev.key_id);
delay(10);
execute_action_up(m_enc_ccw[ev.key_id], ev.key_id);
execute_encoder_action(
m_enc_ccw[ev.key_id], ev.key_id, USB_EVT_ENC_CCW);
}
break;
@ -483,8 +550,8 @@ void CMainController::perform_factory_reset()
// Laufzeit-Zustand immer an die Defaults angleichen selbst wenn NVM gerade
// nicht geschrieben werden konnte, sieht das Gerät sofort wieder "frisch" aus.
m_macros = macros;
usb_hid_release_key();
usb_hid_release_consumer();
usb_hid_release_all_keys();
usb_hid_release_all_consumers();
init_buttons();
show_factory_reset_feedback();
@ -526,7 +593,7 @@ void CMainController::show_factory_reset_feedback()
// execute_action_up(): Taste wird losgelassen (Hold-Ende).
// HID_KEY: sendet Key-Up.
// HID_CONSUMER: sendet Consumer-Up.
// HOST_COMMAND: aktuell keine Ausgabe auf Release.
// HOST_COMMAND: sendet KEY_UP mit Command-ID.
// MACRO/NONE: keine Aktion.
void CMainController::execute_action_down(SAction action, uint8_t key_id)
@ -551,8 +618,12 @@ void CMainController::execute_action_down(SAction action, uint8_t key_id)
}
case ActionType::HOST_COMMAND:
// Windows-App übernimmt Ausführung; KEY_DOWN-Event senden
usb_serial_send(USB_EVT_KEY_DOWN, key_id);
// Command-ID little-endian in Byte 2/3 übertragen.
usb_serial_send(
USB_EVT_KEY_DOWN,
key_id,
static_cast<uint8_t>(action.data & 0xFF),
static_cast<uint8_t>(action.data >> 8));
break;
case ActionType::MACRO:
@ -566,7 +637,7 @@ void CMainController::execute_action_down(SAction action, uint8_t key_id)
if (s.keycode == 0) break;
usb_hid_send_key(s.keycode, s.modifier);
delay(10);
usb_hid_release_key();
usb_hid_release_key(s.keycode, s.modifier);
delay(20); // Kurze Pause zwischen Steps damit der Host mitkommt
}
break;
@ -599,15 +670,23 @@ void CMainController::execute_action_up(SAction action, uint8_t key_id)
switch (action.type) {
case ActionType::HID_KEY:
usb_hid_release_key();
{
uint8_t keycode = static_cast<uint8_t>(action.data & 0xFF);
uint8_t modifier = static_cast<uint8_t>(action.data >> 8);
usb_hid_release_key(keycode, modifier);
break;
}
case ActionType::HID_CONSUMER:
usb_hid_release_consumer();
usb_hid_release_consumer(action.data);
break;
case ActionType::HOST_COMMAND:
// USB_EVT_KEY_UP ist definiert, wird aktuell aber nicht gesendet.
usb_serial_send(
USB_EVT_KEY_UP,
key_id,
static_cast<uint8_t>(action.data & 0xFF),
static_cast<uint8_t>(action.data >> 8));
break;
case ActionType::MACRO:
@ -619,6 +698,23 @@ void CMainController::execute_action_up(SAction action, uint8_t key_id)
}
}
void CMainController::execute_encoder_action(
SAction action, uint8_t enc_id, uint8_t host_event)
{
if (action.type == ActionType::HOST_COMMAND) {
usb_serial_send(
host_event,
enc_id,
static_cast<uint8_t>(action.data & 0xFF),
static_cast<uint8_t>(action.data >> 8));
return;
}
execute_action_down(action, enc_id);
delay(10);
execute_action_up(action, enc_id);
}
// ─── LED-Rendering ────────────────────────────────────────────────────────────
//
// Fragt alle CButton-Instanzen ab. Jede Instanz mit dirty-Flag schreibt

View file

@ -41,17 +41,32 @@ private:
void processEvents(); // Queue leeren, Aktionen ausführen
void execute_action_down(SAction action, uint8_t key_id); // Taste drücken (Hold-Start)
void execute_action_up(SAction action, uint8_t key_id); // Taste losgelassen (Hold-Ende)
void execute_encoder_action(SAction action, uint8_t enc_id, uint8_t host_event);
void updateLEDs(); // Dirty-LEDs in WS2812-Buffer schreiben
enum : uint8_t {
SERIAL_PAYLOAD_BYTES = 6,
CONFIG_CHUNKS = (sizeof(SDeviceConfig) + SERIAL_PAYLOAD_BYTES - 1) /
SERIAL_PAYLOAD_BYTES,
MACRO_CHUNKS = (sizeof(SMacroTable) + SERIAL_PAYLOAD_BYTES - 1) /
SERIAL_PAYLOAD_BYTES,
};
// ── Config-Empfangspuffer ─────────────────────────────────────────────────
uint8_t m_cfg_buf[sizeof(SDeviceConfig)]; // 740 Bytes
uint8_t m_cfg_received[CONFIG_CHUNKS];
uint8_t m_cfg_chunks_expected;
bool m_cfg_receiving;
bool m_cfg_transfer_valid;
// ── Makro-Empfangspuffer ──────────────────────────────────────────────────
uint8_t m_macro_buf[sizeof(SMacroTable)]; // 512 Bytes
uint8_t m_macro_received[MACRO_CHUNKS];
uint8_t m_macro_chunks_expected;
bool m_macro_receiving;
bool m_macro_transfer_valid;
static bool all_chunks_received(const uint8_t* received, uint8_t count);
// Geladene Makro-Tabelle (im RAM wird beim Start aus NVM geladen)
SMacroTable m_macros;

View file

@ -6,15 +6,15 @@ enum class ActionType : uint8_t
NONE, // Keine Aktion
HID_KEY, // Standard-Keyboard-Keycode (direkt in Firmware gesendet)
HID_CONSUMER, // Consumer-Control-Keycode (Volume, Media, …)
HOST_COMMAND, // Host-Event; data wird vom aktuellen Controller nicht übertragen
HOST_COMMAND, // Host-Event; data = Command-ID für die Desktop-App
MACRO, // Makro-Slot (data = Slot-Index 031) → bis zu 8 HID-Keys sequenziell
PROFILE_SWITCH, // Profil 02 oder 0xFF = nächstes Profil; speichert in NVM
PROFILE_SWITCH, // Profil 02 oder 0x00FF/0xFFFF = nächstes Profil; speichert in NVM
};
struct __attribute__((packed)) SAction
{
ActionType type;
uint16_t data; // Typabhängige Nutzdaten; für HOST_COMMAND aktuell ungenutzt
uint16_t data; // Typabhängige Nutzdaten; HOST_COMMAND = Command-ID
// packed: 1B type + 2B data = 3B (kein Alignment-Padding)
// Muss packed sein, damit sizeof(SDeviceConfig)==740 und die
// hostseitige Serialisierung bytegenau übereinstimmen.

View file

@ -42,6 +42,18 @@ static bool nvm_write_page(uint32_t addr, const uint8_t* data)
return nvm_exec(NVMCTRL_CTRLA_CMD_WP);
}
bool macro_config_validate(const SMacroTable& tbl)
{
for (uint8_t slot = 0; slot < MACRO_SLOTS; slot++) {
for (uint8_t step = 0; step < MACRO_MAX_STEPS; step++) {
uint8_t keycode = tbl.steps[slot][step].keycode;
if (keycode > 0x65)
return false;
}
}
return true;
}
bool macro_config_load(SMacroTable& tbl)
{
memcpy(&tbl, reinterpret_cast<const void*>(k_macro_addr), sizeof(tbl));
@ -56,11 +68,17 @@ bool macro_config_load(SMacroTable& tbl)
memset(&tbl, 0, sizeof(tbl)); // Leere Tabelle als Default
return false;
}
if (!macro_config_validate(tbl)) {
memset(&tbl, 0, sizeof(tbl));
return false;
}
return true;
}
bool macro_config_save(const SMacroTable& tbl)
{
if (!macro_config_validate(tbl)) return false;
// Auf 4-Byte-ausgerichteten Puffer kopieren bevor nvm_write_page ihn als uint32_t* liest.
// SMacroTable ist __attribute__((packed)) und könnte unaligned liegen →
// direkter uint32_t*-Cast würde auf Cortex-M0+ einen HardFault auslösen.

View file

@ -28,6 +28,13 @@ struct __attribute__((packed)) SMacroTable
SMacroStep steps[MACRO_SLOTS][MACRO_MAX_STEPS];
};
static_assert(sizeof(SMacroStep) == 2, "SMacroStep binary layout changed");
static_assert(sizeof(SMacroTable) == 512, "SMacroTable binary layout changed");
// Prüft, dass alle belegten Steps in den vom HID-Descriptor unterstützten
// Keyboard-Usage-Bereich fallen.
bool macro_config_validate(const SMacroTable& tbl);
// Makro-Tabelle aus NVM lesen (Row 0+1: 0x1FB00).
// Gibt false zurück wenn der Flash-Bereich noch gelöscht (0xFF) war → leere Tabelle geladen.
bool macro_config_load(SMacroTable& tbl);

View file

@ -2,6 +2,7 @@
// NVM-Zugriff für SDeviceConfig (3 Rows ab 0x1FD00, 768B gesamt, 740B genutzt).
#include "nvm_config.h"
#include "macro_config.h"
#include <Arduino.h>
#include <string.h>
@ -65,6 +66,63 @@ uint16_t nvm_config_crc(const SDeviceConfig& cfg)
return crc;
}
static bool action_valid(const SAction& action)
{
switch (action.type) {
case ActionType::NONE:
return true;
case ActionType::HID_KEY:
return static_cast<uint8_t>(action.data & 0xFF) <= 0x65;
case ActionType::HID_CONSUMER:
return action.data <= 0x03FF;
case ActionType::HOST_COMMAND:
return true;
case ActionType::MACRO:
return action.data < MACRO_SLOTS;
case ActionType::PROFILE_SWITCH:
return action.data <= 2 ||
action.data == 0x00FF ||
action.data == 0xFFFF;
default:
return false;
}
}
bool nvm_config_validate(const SDeviceConfig& cfg)
{
if (cfg.magic != NVM_CONFIG_MAGIC) return false;
if (cfg.version != NVM_CONFIG_VERSION) return false;
if (cfg.crc != nvm_config_crc(cfg)) return false;
if (cfg.active_profile >= 3) return false;
for (uint8_t p = 0; p < 3; p++) {
const SDeviceProfile& prof = cfg.profiles[p];
for (uint8_t i = 0; i < 20; i++) {
if (!action_valid(prof.mx_actions[i])) return false;
uint8_t anim = prof.led_anim[i];
if (anim > 6) return false; // LEDAnim::COLOR_FADE
if (anim == 2 && prof.led_period_ms[i] < 2) return false;
}
for (uint8_t enc = 0; enc < 4; enc++) {
for (uint8_t action = 0; action < 3; action++) {
if (!action_valid(prof.enc_actions[enc][action]))
return false;
}
}
}
return true;
}
// ── Defaults ─────────────────────────────────────────────────────────────────
void nvm_config_defaults(SDeviceConfig& cfg)
@ -112,12 +170,10 @@ bool nvm_config_load(SDeviceConfig& cfg)
{
memcpy(&cfg, reinterpret_cast<const void*>(k_config_addr), sizeof(cfg));
if (cfg.magic != NVM_CONFIG_MAGIC) { nvm_config_defaults(cfg); return false; }
if (cfg.version != NVM_CONFIG_VERSION) { nvm_config_defaults(cfg); return false; }
if (cfg.crc != nvm_config_crc(cfg)) { nvm_config_defaults(cfg); return false; }
// Profil-Index absichern
if (cfg.active_profile >= 3) cfg.active_profile = 0;
if (!nvm_config_validate(cfg)) {
nvm_config_defaults(cfg);
return false;
}
return true;
}
@ -126,11 +182,15 @@ bool nvm_config_load(SDeviceConfig& cfg)
bool nvm_config_save(const SDeviceConfig& cfg)
{
SDeviceConfig stored = cfg;
stored.crc = nvm_config_crc(stored);
if (!nvm_config_validate(stored)) return false;
// Config (740B) in 768B-Puffer kopieren (3 Rows), Rest mit 0xFF füllen.
// __attribute__((aligned(4))) ist zwingend: nvm_write_page castet zu uint32_t*.
uint8_t row[768] __attribute__((aligned(4)));
memset(row, 0xFF, sizeof(row));
memcpy(row, &cfg, sizeof(cfg));
memcpy(row, &stored, sizeof(stored));
NVMCTRL->CTRLB.bit.MANW = 1;

View file

@ -61,13 +61,22 @@ struct __attribute__((packed)) SDeviceConfig
// Gesamt: 32 + 708 = 740B
};
static_assert(sizeof(SAction) == 3, "SAction binary layout changed");
static_assert(sizeof(SDeviceProfile) == 236, "SDeviceProfile binary layout changed");
static_assert(sizeof(SDeviceConfig) == 740, "SDeviceConfig binary layout changed");
// Standardwerte wenn keine gültige Config im NVM
void nvm_config_defaults(SDeviceConfig& cfg);
// Vollständige Prüfung des persistenten/seriellen Binärvertrags inklusive CRC,
// Enum-Bereichen, Action-Nutzdaten und animationsspezifischen Mindestwerten.
bool nvm_config_validate(const SDeviceConfig& cfg);
// Config aus NVM lesen. Gibt false zurück wenn Magic/CRC/Version ungültig → Defaults geladen.
bool nvm_config_load(SDeviceConfig& cfg);
// Config in NVM schreiben (löscht 3 Rows, schreibt 12 Pages).
// Config in NVM schreiben (CRC wird intern neu berechnet; löscht 3 Rows,
// schreibt 12 Pages).
// Gibt false zurück wenn eine NVM-Operation nicht rechtzeitig fertig wird (Board hängt nicht).
bool nvm_config_save(const SDeviceConfig& cfg);

View file

@ -1,6 +1,7 @@
#include "usb_hid.h"
#include <Arduino.h>
#include <HID.h>
#include <string.h>
// ── HID Report Descriptor: Keyboard + Consumer Control ───────────────────────
// Host-Kommunikation außerhalb von HID läuft separat über USB CDC (SerialUSB).
@ -67,30 +68,140 @@ struct ConsumerReport {
uint16_t usage;
};
void usb_hid_init() {}
static uint8_t s_key_refcount[256] = {};
static uint8_t s_modifier_refcount[8] = {};
void usb_hid_send_key(uint8_t keycode, uint8_t modifier)
struct ConsumerState {
uint16_t usage;
uint8_t refcount;
uint32_t order;
};
static constexpr uint8_t CONSUMER_STATE_SLOTS = 8;
static ConsumerState s_consumer_state[CONSUMER_STATE_SLOTS] = {};
static uint32_t s_consumer_order = 0;
static void send_keyboard_state()
{
KeyboardReport report = {};
report.modifier = modifier;
report.keycodes[0] = keycode;
for (uint8_t bit = 0; bit < 8; bit++) {
if (s_modifier_refcount[bit] > 0)
report.modifier |= static_cast<uint8_t>(1u << bit);
}
uint8_t out = 0;
for (uint16_t key = 1; key < 256 && out < 6; key++) {
if (s_key_refcount[key] > 0)
report.keycodes[out++] = static_cast<uint8_t>(key);
}
HID().SendReport(HID_REPORT_ID_KEYBOARD, &report, sizeof(report));
}
void usb_hid_release_key()
static void send_consumer_state()
{
KeyboardReport report = {};
HID().SendReport(HID_REPORT_ID_KEYBOARD, &report, sizeof(report));
}
uint16_t usage = 0;
uint32_t newest = 0;
for (uint8_t i = 0; i < CONSUMER_STATE_SLOTS; i++) {
if (s_consumer_state[i].refcount > 0 &&
s_consumer_state[i].order >= newest)
{
newest = s_consumer_state[i].order;
usage = s_consumer_state[i].usage;
}
}
void usb_hid_send_consumer(uint16_t usage)
{
ConsumerReport report = { usage };
HID().SendReport(HID_REPORT_ID_CONSUMER, &report, sizeof(report));
}
void usb_hid_release_consumer()
void usb_hid_init()
{
ConsumerReport report = { 0 };
HID().SendReport(HID_REPORT_ID_CONSUMER, &report, sizeof(report));
memset(s_key_refcount, 0, sizeof(s_key_refcount));
memset(s_modifier_refcount, 0, sizeof(s_modifier_refcount));
memset(s_consumer_state, 0, sizeof(s_consumer_state));
s_consumer_order = 0;
}
void usb_hid_send_key(uint8_t keycode, uint8_t modifier)
{
if (keycode != 0 && s_key_refcount[keycode] < 0xFF)
s_key_refcount[keycode]++;
for (uint8_t bit = 0; bit < 8; bit++) {
if ((modifier & (1u << bit)) != 0 && s_modifier_refcount[bit] < 0xFF)
s_modifier_refcount[bit]++;
}
send_keyboard_state();
}
void usb_hid_release_key(uint8_t keycode, uint8_t modifier)
{
if (keycode != 0 && s_key_refcount[keycode] > 0)
s_key_refcount[keycode]--;
for (uint8_t bit = 0; bit < 8; bit++) {
if ((modifier & (1u << bit)) != 0 && s_modifier_refcount[bit] > 0)
s_modifier_refcount[bit]--;
}
send_keyboard_state();
}
void usb_hid_release_all_keys()
{
memset(s_key_refcount, 0, sizeof(s_key_refcount));
memset(s_modifier_refcount, 0, sizeof(s_modifier_refcount));
send_keyboard_state();
}
void usb_hid_send_consumer(uint16_t usage)
{
ConsumerState* free_slot = nullptr;
for (uint8_t i = 0; i < CONSUMER_STATE_SLOTS; i++) {
ConsumerState& state = s_consumer_state[i];
if (state.refcount > 0 && state.usage == usage) {
if (state.refcount < 0xFF) state.refcount++;
state.order = ++s_consumer_order;
send_consumer_state();
return;
}
if (state.refcount == 0 && free_slot == nullptr)
free_slot = &state;
}
if (free_slot != nullptr) {
free_slot->usage = usage;
free_slot->refcount = 1;
free_slot->order = ++s_consumer_order;
}
send_consumer_state();
}
void usb_hid_release_consumer(uint16_t usage)
{
for (uint8_t i = 0; i < CONSUMER_STATE_SLOTS; i++) {
ConsumerState& state = s_consumer_state[i];
if (state.refcount > 0 && state.usage == usage) {
state.refcount--;
if (state.refcount == 0) {
state.usage = 0;
state.order = 0;
}
break;
}
}
send_consumer_state();
}
void usb_hid_release_all_consumers()
{
memset(s_consumer_state, 0, sizeof(s_consumer_state));
send_consumer_state();
}

View file

@ -28,8 +28,14 @@
void usb_hid_init();
// Keyboard-Zustand wird referenzgezählt. Dadurch bleiben andere gehaltene
// Tasten/Modifier aktiv, wenn genau eine Action losgelassen wird.
void usb_hid_send_key(uint8_t keycode, uint8_t modifier = 0);
void usb_hid_release_key();
void usb_hid_release_key(uint8_t keycode, uint8_t modifier = 0);
void usb_hid_release_all_keys();
// Der Consumer-Descriptor kann jeweils ein Usage übertragen. Mehrere Holds
// werden intern verwaltet; sichtbar bleibt das zuletzt gedrückte aktive Usage.
void usb_hid_send_consumer(uint16_t usage);
void usb_hid_release_consumer();
void usb_hid_release_consumer(uint16_t usage);
void usb_hid_release_all_consumers();

View file

@ -41,10 +41,10 @@
#define USB_CMD_MACRO_READ 0x23 // Board sendet aktuelle Makro-Tabelle zurück
// ── Events: Board → PC ────────────────────────────────────────────────────────
#define USB_EVT_KEY_DOWN 0x81 // key_id → HOST_COMMAND-Button gedrückt
#define USB_EVT_KEY_UP 0x82 // Reserviert; vom Controller aktuell nicht gesendet
#define USB_EVT_ENC_CW 0x83 // Reserviert; vom Controller aktuell nicht gesendet
#define USB_EVT_ENC_CCW 0x84 // Reserviert; vom Controller aktuell nicht gesendet
#define USB_EVT_KEY_DOWN 0x81 // key_id + Command-ID in Data[2..3]
#define USB_EVT_KEY_UP 0x82 // key_id + Command-ID in Data[2..3]
#define USB_EVT_ENC_CW 0x83 // enc_id + Command-ID in Data[2..3]
#define USB_EVT_ENC_CCW 0x84 // enc_id + Command-ID in Data[2..3]
#define USB_EVT_PONG 0x85 // Antwort auf USB_CMD_PING
#define USB_EVT_CONFIG_ACK 0x90 // Config erfolgreich in NVM geschrieben
#define USB_EVT_CONFIG_NACK 0x91 // Config CRC/Magic ungültig nicht geschrieben