#include #include "display.h" #include "mnemonic_kb.h" // ============================================================ // KB2040 Hidden Signer // - Composite USB: HID Consumer + WebUSB vendor transport // - Media mode default // - Hidden signer mode via PLAY + PREV press chord // ============================================================ static const uint8_t desc_hid_report[] = { TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(1)) }; Adafruit_USBD_HID usb_hid; Adafruit_USBD_WebUSB usb_web; WEBUSB_URL_DEF(landing_url, 1 /* https */, "example.com/nsigner/kb2040"); // ------------------ Hardware pin mapping -------------------- static constexpr uint8_t PIN_ENC_A = 2; static constexpr uint8_t PIN_ENC_B = 3; static constexpr uint8_t PIN_BTN_PLAY = 5; static constexpr uint8_t PIN_BTN_NEXT = 6; static constexpr uint8_t PIN_BTN_PREV = 7; static constexpr int32_t DETENTS_PER_STEP = 4; static constexpr uint32_t DEBOUNCE_MS = 20; static bool s_display_init_done = false; static constexpr uint32_t DISPLAY_INIT_DELAY_MS = 750; // ------------------ Transport diagnostics ------------------- // These counters let us confirm, without any host tooling, whether // request bytes actually reach the device and whether the WebUSB // vendor interface believes it is "connected". static uint32_t s_diag_bytes_rx = 0; // total bytes pulled from usb_web static uint32_t s_diag_frames_rx = 0; // complete framed requests parsed static uint32_t s_diag_responses_tx = 0; // responses written back static bool s_diag_last_connected = false; static uint32_t s_diag_last_report_ms = 0; static constexpr uint32_t DIAG_REPORT_INTERVAL_MS = 500; // ---------------------- Modes / state ----------------------- enum device_mode_t { MODE_MEDIA = 0, MODE_SIGNER = 1, }; enum signer_ui_state_t { SIGNER_UI_MENU = 0, SIGNER_UI_READY = 1, SIGNER_UI_GENERATE_REVIEW = 2, SIGNER_UI_ENTER_LETTER = 3, SIGNER_UI_ENTER_PICK = 4, }; static device_mode_t s_mode = MODE_MEDIA; static signer_ui_state_t s_signer_ui_state = SIGNER_UI_MENU; static bool s_mode_switch_latched = false; // Demo signer state (placeholder until full n_signer crypto integration) static char s_mnemonic[256] = ""; static bool s_seed_loaded = false; static bool s_auto_approve = false; // Signer UI state static uint8_t s_signer_menu_selected = 0; // 0=Generate, 1=Enter static uint8_t s_signer_review_page = 0; // 0..2 for 12 words at 4/page static char s_generated_mnemonic[256] = ""; static uint8_t s_enter_slot = 0; // 0..11 static uint8_t s_enter_letter_index = 0; // 0..25 => a..z static uint16_t s_enter_word_index = 0; // current picker index static uint16_t s_enter_selected_indices[12] = {0}; static void signer_enter_ready_screen(); // -------------------- Transport framing ---------------------- static uint8_t s_rx_buf[2048]; static size_t s_rx_len = 0; static uint8_t s_tx_buf[2048]; // ---------------------- Encoder state ------------------------ volatile int8_t s_isr_last_ab = 0; volatile int32_t s_detent_accumulator = 0; uint8_t s_local_volume = 50; static constexpr int8_t QUAD_TABLE[16] = { 0, -1, +1, 0, +1, 0, 0, -1, -1, 0, 0, +1, 0, +1, -1, 0 }; // ---------------------- Button state ------------------------- struct ButtonState { uint8_t pin; bool stable_level; // HIGH=released, LOW=pressed bool last_sample; uint32_t last_change_ms; }; ButtonState s_buttons[] = { { PIN_BTN_PLAY, true, true, 0 }, { PIN_BTN_NEXT, true, true, 0 }, { PIN_BTN_PREV, true, true, 0 }, }; static bool s_evt_play_pressed = false; static bool s_evt_next_pressed = false; static bool s_evt_prev_pressed = false; // ---------------------- Utilities ---------------------------- static bool btn_pressed(int pin) { return digitalRead(pin) == LOW; } static void led_blink(int n, int on_ms, int off_ms) { for (int i = 0; i < n; i++) { digitalWrite(LED_BUILTIN, LOW); delay(on_ms); digitalWrite(LED_BUILTIN, HIGH); delay(off_ms); } } static void send_consumer_key(uint16_t usage) { if (!usb_hid.ready()) return; usb_hid.sendReport16(1, usage); delay(2); usb_hid.sendReport16(1, 0); } static int8_t read_ab() { const int a = digitalRead(PIN_ENC_A) ? 1 : 0; const int b = digitalRead(PIN_ENC_B) ? 1 : 0; return static_cast((a << 1) | b); } void on_encoder_edge() { const int8_t curr_ab = read_ab(); const uint8_t idx = static_cast((s_isr_last_ab << 2) | curr_ab); const int8_t delta = QUAD_TABLE[idx & 0x0F]; if (delta != 0) { s_detent_accumulator += delta; } s_isr_last_ab = curr_ab; } // Pop full encoder detents from the ISR accumulator, preserving partial transitions. static int32_t encoder_take_steps() { int32_t transitions = 0; noInterrupts(); transitions = s_detent_accumulator; s_detent_accumulator = 0; interrupts(); int32_t steps = 0; while (transitions >= DETENTS_PER_STEP) { transitions -= DETENTS_PER_STEP; steps += 1; } while (transitions <= -DETENTS_PER_STEP) { transitions += DETENTS_PER_STEP; steps -= 1; } if (transitions != 0) { noInterrupts(); s_detent_accumulator += transitions; interrupts(); } return steps; } static int8_t volume_change_by(int8_t delta) { int16_t next = static_cast(s_local_volume) + static_cast(delta); if (next < 0) { next = 0; } else if (next > 100) { next = 100; } const uint8_t clamped = static_cast(next); const int8_t applied = static_cast(static_cast(clamped) - static_cast(s_local_volume)); s_local_volume = clamped; return applied; } static void write_u32_be(uint8_t *p, uint32_t v) { p[0] = (uint8_t)((v >> 24) & 0xFF); p[1] = (uint8_t)((v >> 16) & 0xFF); p[2] = (uint8_t)((v >> 8) & 0xFF); p[3] = (uint8_t)(v & 0xFF); } static uint32_t read_u32_be(const uint8_t *p) { return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3]; } static bool json_extract_string(const char *json, const char *key, char *out, size_t out_sz) { char needle[64]; snprintf(needle, sizeof(needle), "\"%s\"", key); const char *k = strstr(json, needle); if (!k) return false; const char *colon = strchr(k, ':'); if (!colon) return false; const char *q1 = strchr(colon, '"'); if (!q1) return false; q1++; const char *q2 = strchr(q1, '"'); if (!q2) return false; size_t n = (size_t)(q2 - q1); if (n >= out_sz) n = out_sz - 1; memcpy(out, q1, n); out[n] = '\0'; return true; } // Deterministic placeholder key derivation for transport integration testing. static void pseudo_pubkey_hex(char out_hex_64[65]) { uint32_t h = 2166136261u; for (size_t i = 0; i < strlen(s_mnemonic); i++) { h ^= (uint8_t)s_mnemonic[i]; h *= 16777619u; } for (int i = 0; i < 32; i++) { uint8_t b = (uint8_t)((h >> ((i % 4) * 8)) ^ (i * 37)); static const char *hex = "0123456789abcdef"; out_hex_64[i * 2] = hex[(b >> 4) & 0x0F]; out_hex_64[i * 2 + 1] = hex[b & 0x0F]; } out_hex_64[64] = '\0'; } static void signer_apply_seed(const char *mnemonic) { if (!mnemonic) { return; } strncpy(s_mnemonic, mnemonic, sizeof(s_mnemonic) - 1); s_mnemonic[sizeof(s_mnemonic) - 1] = '\0'; s_seed_loaded = strlen(s_mnemonic) > 0; display_set_status(s_seed_loaded ? "Seed loaded" : "Seed cleared"); } static bool wait_for_user_approval(uint32_t timeout_ms) { display_show_message("APPROVAL", "PLAY=Allow", "PREV=Deny"); display_set_status("Waiting approval"); uint32_t start = millis(); while ((millis() - start) < timeout_ms) { if (btn_pressed(PIN_BTN_PLAY)) { while (btn_pressed(PIN_BTN_PLAY)) delay(5); display_set_status("Approved"); return true; } if (btn_pressed(PIN_BTN_PREV)) { while (btn_pressed(PIN_BTN_PREV)) delay(5); display_set_status("Denied"); return false; } display_tick(); delay(5); } display_set_status("Approval timeout"); return false; } static void send_json_response(const char *json) { size_t n = strlen(json); if (n > (sizeof(s_tx_buf) - 4)) return; write_u32_be(s_tx_buf, (uint32_t)n); memcpy(s_tx_buf + 4, json, n); usb_web.write(s_tx_buf, n + 4); usb_web.flush(); s_diag_responses_tx++; } static void handle_rpc(const char *req_json) { char method[64] = {0}; if (!json_extract_string(req_json, "method", method, sizeof(method))) { send_json_response("{\"error\":{\"code\":-32600,\"message\":\"invalid request\"}}"); display_set_status("RPC invalid request"); return; } if (strcmp(method, "ping") == 0) { send_json_response("{\"result\":\"pong\"}"); display_set_status("RPC ping"); return; } if (strcmp(method, "get_status") == 0) { char resp[256]; snprintf(resp, sizeof(resp), "{\"result\":{\"mode\":\"%s\",\"seed_loaded\":%s,\"auto_approve\":%s}}", s_mode == MODE_MEDIA ? "media" : "signer", s_seed_loaded ? "true" : "false", s_auto_approve ? "true" : "false"); send_json_response(resp); display_set_status("RPC get_status"); return; } if (strcmp(method, "set_mnemonic") == 0) { char phrase[256] = {0}; if (!json_extract_string(req_json, "mnemonic", phrase, sizeof(phrase))) { send_json_response("{\"error\":{\"code\":-32602,\"message\":\"missing mnemonic\"}}"); display_set_status("Mnemonic missing"); return; } signer_apply_seed(phrase); if (s_mode == MODE_SIGNER && s_seed_loaded) { signer_enter_ready_screen(); } send_json_response("{\"result\":\"ok\"}"); return; } if (strcmp(method, "set_auto_approve") == 0) { if (strstr(req_json, "\"value\":true") != nullptr) { s_auto_approve = true; send_json_response("{\"result\":true}"); display_set_status("Auto-approve ON"); } else if (strstr(req_json, "\"value\":false") != nullptr) { s_auto_approve = false; send_json_response("{\"result\":false}"); display_set_status("Auto-approve OFF"); } else { send_json_response("{\"error\":{\"code\":-32602,\"message\":\"missing value\"}}"); display_set_status("Auto-approve invalid"); } return; } if (strcmp(method, "get_public_key") == 0) { if (!s_seed_loaded) { send_json_response("{\"error\":{\"code\":2014,\"message\":\"seed not loaded\"}}"); display_set_status("No seed"); return; } char pubhex[65]; pseudo_pubkey_hex(pubhex); char resp[192]; snprintf(resp, sizeof(resp), "{\"result\":{\"pubkey\":\"%s\"}}", pubhex); send_json_response(resp); display_set_status("Pubkey served"); return; } if (strcmp(method, "sign_event") == 0) { if (!s_seed_loaded) { send_json_response("{\"error\":{\"code\":2014,\"message\":\"seed not loaded\"}}"); display_set_status("Sign blocked:no seed"); return; } if (s_mode != MODE_SIGNER) { send_json_response("{\"error\":{\"code\":2015,\"message\":\"not in signer mode\"}}"); display_set_status("Sign blocked:mode"); return; } bool approved = s_auto_approve ? true : wait_for_user_approval(30000); if (!approved) { send_json_response("{\"error\":{\"code\":2001,\"message\":\"user denied or timeout\"}}"); // Return signer UI screen after approval prompt if (s_signer_ui_state == SIGNER_UI_MENU) { display_show_signer_menu(s_signer_menu_selected); } else if (s_signer_ui_state == SIGNER_UI_READY) { signer_enter_ready_screen(); } return; } send_json_response("{\"result\":{\"sig\":\"dev_signature_placeholder\"}}"); display_set_status("Event signed"); if (s_signer_ui_state == SIGNER_UI_MENU) { display_show_signer_menu(s_signer_menu_selected); } else if (s_signer_ui_state == SIGNER_UI_READY) { signer_enter_ready_screen(); } return; } send_json_response("{\"error\":{\"code\":-32601,\"message\":\"method not found\"}}"); display_set_status("RPC method missing"); } static void webusb_pump_frames() { while (usb_web.available()) { if (s_rx_len >= sizeof(s_rx_buf)) { s_rx_len = 0; break; } int c = usb_web.read(); if (c < 0) break; s_rx_buf[s_rx_len++] = (uint8_t)c; // DIAG: a byte arrived from the host. Pulse LED + count it so we can // confirm, with no host tooling, that request bytes reach the device. s_diag_bytes_rx++; digitalWrite(LED_BUILTIN, LOW); } while (s_rx_len >= 4) { s_diag_frames_rx++; uint32_t body_len = read_u32_be(s_rx_buf); if (body_len > (sizeof(s_rx_buf) - 4)) { s_rx_len = 0; send_json_response("{\"error\":{\"code\":-32000,\"message\":\"frame too large\"}}"); display_set_status("Frame too large"); return; } if (s_rx_len < (size_t)(4 + body_len)) { return; } char req[1536]; size_t n = body_len; if (n >= sizeof(req)) n = sizeof(req) - 1; memcpy(req, s_rx_buf + 4, n); req[n] = '\0'; size_t remain = s_rx_len - (4 + body_len); memmove(s_rx_buf, s_rx_buf + 4 + body_len, remain); s_rx_len = remain; handle_rpc(req); } } static void signer_enter_menu_screen() { s_signer_ui_state = SIGNER_UI_MENU; display_set_view(DISPLAY_VIEW_SIGNER); display_show_signer_menu(s_signer_menu_selected); display_set_status("ENC scroll PLAY select"); } static void signer_enter_ready_screen() { s_signer_ui_state = SIGNER_UI_READY; display_set_view(DISPLAY_VIEW_SIGNER); display_show_signer_ready(); display_set_status("Ready to sign"); } static char signer_current_letter() { return (char)('a' + (s_enter_letter_index % 26)); } static void signer_enter_letter_pick_screen() { s_signer_ui_state = SIGNER_UI_ENTER_LETTER; char line1[20]; char line2[24]; snprintf(line1, sizeof(line1), "Word %u/12", (unsigned)(s_enter_slot + 1)); snprintf(line2, sizeof(line2), "Letter: %c", signer_current_letter()); display_show_message("PICK FIRST LETTER", line1, line2); display_set_status("ENC letter PLAY confirm"); } static void signer_enter_word_pick_screen() { s_signer_ui_state = SIGNER_UI_ENTER_PICK; uint16_t start = 0; uint16_t count = 0; if (!mnemonic_letter_range(signer_current_letter(), &start, &count) || count == 0) { start = 0; count = 2048; } if (s_enter_word_index < start || s_enter_word_index >= (uint16_t)(start + count)) { s_enter_word_index = start; } const char *word = mnemonic_word_at(s_enter_word_index); if (!word) word = "abandon"; display_show_word_pick(s_enter_slot, 12, word, s_enter_word_index); display_set_status("ENC word PLAY select"); } static void signer_enter_review_screen() { s_signer_ui_state = SIGNER_UI_GENERATE_REVIEW; if (s_signer_review_page > 2) s_signer_review_page = 2; display_show_mnemonic_page(s_generated_mnemonic, s_signer_review_page); display_set_status("PLAY confirm NEXT cancel"); } static void build_entered_mnemonic(char *out, size_t out_sz) { if (!out || out_sz == 0) { return; } out[0] = '\0'; size_t cursor = 0; for (uint8_t i = 0; i < 12; ++i) { const char *w = mnemonic_word_at(s_enter_selected_indices[i]); if (!w) w = "abandon"; const size_t wlen = strlen(w); if (cursor != 0) { if (cursor + 1 >= out_sz) break; out[cursor++] = ' '; } if (cursor + wlen >= out_sz) break; memcpy(out + cursor, w, wlen); cursor += wlen; out[cursor] = '\0'; } } static void handle_mode_switch_chord() { const bool play = btn_pressed(PIN_BTN_PLAY); const bool prev = btn_pressed(PIN_BTN_PREV); if (play && prev) { if (s_mode_switch_latched) { return; } s_mode_switch_latched = true; s_mode = (s_mode == MODE_MEDIA) ? MODE_SIGNER : MODE_MEDIA; display_set_mode(s_mode == MODE_SIGNER); if (s_mode == MODE_SIGNER) { display_set_view(DISPLAY_VIEW_SIGNER); signer_enter_menu_screen(); display_set_status("Signer mode"); } else { display_set_view(DISPLAY_VIEW_VOLUME); display_set_status("Media mode"); } led_blink(s_mode == MODE_SIGNER ? 3 : 1, 80, 80); return; } s_mode_switch_latched = false; } static void update_button_events() { s_evt_play_pressed = false; s_evt_next_pressed = false; s_evt_prev_pressed = false; const uint32_t now = millis(); for (auto &btn : s_buttons) { const bool sample = digitalRead(btn.pin); if (sample != btn.last_sample) { btn.last_sample = sample; btn.last_change_ms = now; } if ((now - btn.last_change_ms) >= DEBOUNCE_MS && btn.stable_level != sample) { btn.stable_level = sample; // Active-low press edge if (!btn.stable_level) { if (btn.pin == PIN_BTN_PLAY) s_evt_play_pressed = true; if (btn.pin == PIN_BTN_NEXT) s_evt_next_pressed = true; if (btn.pin == PIN_BTN_PREV) s_evt_prev_pressed = true; } } } } static void media_mode_tick() { const int32_t steps = encoder_take_steps(); if (steps > 0) { for (int32_t i = 0; i < steps; ++i) { const int8_t applied = volume_change_by(+1); if (applied > 0) { send_consumer_key(HID_USAGE_CONSUMER_VOLUME_INCREMENT); display_set_volume(s_local_volume); } } display_set_status("Volume up"); } else if (steps < 0) { for (int32_t i = 0; i < -steps; ++i) { const int8_t applied = volume_change_by(-1); if (applied < 0) { send_consumer_key(HID_USAGE_CONSUMER_VOLUME_DECREMENT); display_set_volume(s_local_volume); } } display_set_status("Volume down"); } if (s_evt_play_pressed) { send_consumer_key(HID_USAGE_CONSUMER_PLAY_PAUSE); display_set_status("Play/Pause"); } if (s_evt_next_pressed) { // Preserve original hardware remap: NEXT wiring is swapped. send_consumer_key(HID_USAGE_CONSUMER_SCAN_PREVIOUS_TRACK); display_set_status("Prev track"); } if (s_evt_prev_pressed) { send_consumer_key(HID_USAGE_CONSUMER_SCAN_NEXT_TRACK); display_set_status("Next track"); } } static void signer_mode_tick() { const int32_t steps = encoder_take_steps(); // Suppress actions while chord is held for mode switching. if (btn_pressed(PIN_BTN_PLAY) && btn_pressed(PIN_BTN_PREV)) { return; } switch (s_signer_ui_state) { case SIGNER_UI_MENU: { if (steps != 0) { int16_t v = (int16_t)s_signer_menu_selected + (steps > 0 ? 1 : -1); if (v < 0) v = 0; if (v > 1) v = 1; s_signer_menu_selected = (uint8_t)v; display_show_signer_menu(s_signer_menu_selected); } if (s_evt_play_pressed) { if (s_signer_menu_selected == 0) { if (generate_mnemonic_12(s_generated_mnemonic, sizeof(s_generated_mnemonic)) == 0) { s_signer_review_page = 0; signer_enter_review_screen(); } else { display_show_message("ERROR", "Generate failed", "Try again"); display_set_status("Generate failed"); } } else { s_enter_slot = 0; s_enter_letter_index = 0; s_enter_word_index = s_enter_selected_indices[s_enter_slot]; signer_enter_letter_pick_screen(); } } break; } case SIGNER_UI_GENERATE_REVIEW: { if (steps != 0) { int16_t p = (int16_t)s_signer_review_page + (steps > 0 ? 1 : -1); if (p < 0) p = 0; if (p > 2) p = 2; s_signer_review_page = (uint8_t)p; signer_enter_review_screen(); } if (s_evt_play_pressed) { signer_apply_seed(s_generated_mnemonic); signer_enter_ready_screen(); } if (s_evt_next_pressed) { signer_enter_menu_screen(); display_set_status("Generate canceled"); } break; } case SIGNER_UI_READY: { if (s_evt_next_pressed) { signer_enter_menu_screen(); } break; } case SIGNER_UI_ENTER_LETTER: { if (steps != 0) { int16_t idx = (int16_t)s_enter_letter_index + (steps > 0 ? 1 : -1); while (idx < 0) idx += 26; while (idx >= 26) idx -= 26; s_enter_letter_index = (uint8_t)idx; signer_enter_letter_pick_screen(); } if (s_evt_play_pressed) { uint16_t start = 0; uint16_t count = 0; if (mnemonic_letter_range(signer_current_letter(), &start, &count) && count > 0) { s_enter_word_index = start; } else { s_enter_word_index = 0; } signer_enter_word_pick_screen(); } if (s_evt_next_pressed) { if (s_enter_slot == 0) { signer_enter_menu_screen(); display_set_status("Entry canceled"); } else { s_enter_slot--; const char *prev_word = mnemonic_word_at(s_enter_selected_indices[s_enter_slot]); if (prev_word && prev_word[0] >= 'a' && prev_word[0] <= 'z') { s_enter_letter_index = (uint8_t)(prev_word[0] - 'a'); } s_enter_word_index = s_enter_selected_indices[s_enter_slot]; signer_enter_word_pick_screen(); display_set_status("Back one step"); } } break; } case SIGNER_UI_ENTER_PICK: { uint16_t start = 0; uint16_t count = 0; if (!mnemonic_letter_range(signer_current_letter(), &start, &count) || count == 0) { start = 0; count = 2048; } if (steps != 0) { int32_t rel = (int32_t)s_enter_word_index - (int32_t)start; rel += steps; while (rel < 0) rel += count; while (rel >= count) rel -= count; s_enter_word_index = (uint16_t)(start + rel); signer_enter_word_pick_screen(); } if (s_evt_play_pressed) { s_enter_selected_indices[s_enter_slot] = s_enter_word_index; if (s_enter_slot < 11) { s_enter_slot++; s_enter_letter_index = 0; s_enter_word_index = s_enter_selected_indices[s_enter_slot]; signer_enter_letter_pick_screen(); } else { char phrase[256]; build_entered_mnemonic(phrase, sizeof(phrase)); if (mnemonic_validate_basic(phrase)) { signer_apply_seed(phrase); signer_enter_ready_screen(); } else { display_show_message("INVALID", "Bad mnemonic", "NEXT to return"); display_set_status("Validation failed"); } } } if (s_evt_next_pressed) { signer_enter_letter_pick_screen(); display_set_status("Back to letter"); } break; } } } void setup() { pinMode(LED_BUILTIN, OUTPUT); digitalWrite(LED_BUILTIN, LOW); pinMode(PIN_ENC_A, INPUT_PULLUP); pinMode(PIN_ENC_B, INPUT_PULLUP); pinMode(PIN_BTN_PLAY, INPUT_PULLUP); pinMode(PIN_BTN_NEXT, INPUT_PULLUP); pinMode(PIN_BTN_PREV, INPUT_PULLUP); usb_web.setLandingPage(&landing_url); usb_web.setStringDescriptor("n_signer WebUSB"); usb_web.begin(); usb_hid.setPollInterval(2); usb_hid.setReportDescriptor(desc_hid_report, sizeof(desc_hid_report)); usb_hid.setStringDescriptor("KB2040 Media"); usb_hid.begin(); s_isr_last_ab = read_ab(); attachInterrupt(digitalPinToInterrupt(PIN_ENC_A), on_encoder_edge, CHANGE); attachInterrupt(digitalPinToInterrupt(PIN_ENC_B), on_encoder_edge, CHANGE); const uint32_t now = millis(); for (auto &btn : s_buttons) { const bool s = digitalRead(btn.pin); btn.stable_level = s; btn.last_sample = s; btn.last_change_ms = now; } while (!TinyUSBDevice.mounted()) { delay(1); } display_set_mode(false); display_set_view(DISPLAY_VIEW_VOLUME); display_set_status("USB mounted"); digitalWrite(LED_BUILTIN, HIGH); led_blink(2, 60, 60); } void loop() { if (!s_display_init_done && millis() >= DISPLAY_INIT_DELAY_MS) { s_display_init_done = true; display_init(); display_set_mode(s_mode == MODE_SIGNER); display_set_volume(s_local_volume); display_set_view(s_mode == MODE_SIGNER ? DISPLAY_VIEW_SIGNER : DISPLAY_VIEW_VOLUME); display_set_status("Ready"); if (s_mode == MODE_SIGNER) { signer_enter_menu_screen(); } } update_button_events(); handle_mode_switch_chord(); if (s_mode == MODE_MEDIA) { media_mode_tick(); } else { signer_mode_tick(); } webusb_pump_frames(); // DIAG: periodically surface transport activity on the OLED status line so // we can confirm whether host request bytes reach the device, and whether // the WebUSB vendor interface reports itself "connected". Format: // D c<0|1> b f t // - c1 => usb_web.connected() true (host opened the WebUSB session) // - b => total bytes received from host on the vendor OUT endpoint // - f => complete framed requests parsed // - t => responses written back { const bool connected = usb_web.connected(); const uint32_t now = millis(); if (connected != s_diag_last_connected || (now - s_diag_last_report_ms) >= DIAG_REPORT_INTERVAL_MS) { s_diag_last_connected = connected; s_diag_last_report_ms = now; char diag[24]; snprintf(diag, sizeof(diag), "D c%d b%lu f%lu t%lu", connected ? 1 : 0, (unsigned long)s_diag_bytes_rx, (unsigned long)s_diag_frames_rx, (unsigned long)s_diag_responses_tx); display_set_status(diag); // Idle LED state: ON (HIGH) when connected, OFF otherwise. The RX path // drives it LOW briefly on each byte, so a flicker == bytes arriving. digitalWrite(LED_BUILTIN, connected ? HIGH : LOW); } } display_tick(); delay(2); }