From e37f6745e14841ea54b9689071afbf7c1561cefc Mon Sep 17 00:00:00 2001 From: misa198 Date: Sat, 5 Sep 2026 12:49:42 +0700 Subject: [PATCH 1/2] fix: recover TinyTouch after macOS sleep --- app/TinyTouch/DeviceServices.swift | 23 ++++++++++++-- app/TinyTouchTests/HIDProtocolTests.swift | 8 +++++ .../tiny_touch_unified/main/fingerprint.c | 15 ++------- .../tiny_touch_unified/main/touch_pin_hid.c | 31 ++++++++++++++++--- firmware/tiny_touch_unified/main/usb_ccid.c | 18 +++++++++++ firmware/tiny_touch_unified/sdkconfig | 2 +- .../tiny_touch_unified/sdkconfig.defaults | 1 + tests/test_protocol6_firmware.py | 15 ++++++++- 8 files changed, 91 insertions(+), 22 deletions(-) diff --git a/app/TinyTouch/DeviceServices.swift b/app/TinyTouch/DeviceServices.swift index 1a00b54..1d15fe6 100644 --- a/app/TinyTouch/DeviceServices.swift +++ b/app/TinyTouch/DeviceServices.swift @@ -821,7 +821,8 @@ final class DeviceManager { private let heartbeatInterval: TimeInterval, heartbeatTimeout: TimeInterval private let sessionUsesKeychain: Bool private var failures: [String: Int] = [:], activeLeaseNonce: String? - private var enabled = true, advancedDiscovery = false, timer: Timer?, wakeObserver: NSObjectProtocol? + private var enabled = true, advancedDiscovery = false, sleeping = false, timer: Timer? + private var wakeObserver: NSObjectProtocol?, sleepObserver: NSObjectProtocol? init(discover: (@MainActor () -> [DeviceIdentity])? = nil, backoff: BackoffPolicy = BackoffPolicy(), random: @escaping () -> Double = { Double.random(in: 0...1) }, @@ -835,6 +836,7 @@ final class DeviceManager { deinit { if let wakeObserver { NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver) } + if let sleepObserver { NSWorkspace.shared.notificationCenter.removeObserver(sleepObserver) } } func start(enabled: Bool) { @@ -864,15 +866,29 @@ final class DeviceManager { private func observeWake() { guard wakeObserver == nil else { return } - wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + let notifications = NSWorkspace.shared.notificationCenter + sleepObserver = notifications.addObserver( + forName: NSWorkspace.willSleepNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.prepareForSleep() } + } + wakeObserver = notifications.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor in self?.reconnectAfterWake() } } } + func prepareForSleep() { + sleeping = true + sessions.values.forEach { $0.stopSynchronously() } + sessions.removeAll(); opened.removeAll(); ready.removeAll() + publish() + } + func reconnectAfterWake() { - sessions.values.forEach { $0.stop() } + sleeping = false + sessions.values.forEach { $0.stopSynchronously() } sessions.removeAll(); opened.removeAll(); ready.removeAll(); retryDeadlines.removeAll(); failures.removeAll() scan() } @@ -887,6 +903,7 @@ final class DeviceManager { nonisolated static func canReconnect(deadline: Date?, now: Date = Date()) -> Bool { deadline.map { now >= $0 } ?? true } func scan() { + guard !sleeping else { publish(); return } if let lease = leaseObserver.active() { if activeLeaseNonce != lease.nonce { sessions.values.forEach { $0.stopSynchronously() } diff --git a/app/TinyTouchTests/HIDProtocolTests.swift b/app/TinyTouchTests/HIDProtocolTests.swift index 5201196..284bc7f 100644 --- a/app/TinyTouchTests/HIDProtocolTests.swift +++ b/app/TinyTouchTests/HIDProtocolTests.swift @@ -480,6 +480,14 @@ final class HIDProtocolTests: XCTestCase { try oldPTY.writeLine("OK STATUS firmware=before protocol=2 mode=hid sensor=ok fingerprints=1") _ = try await first.value + await MainActor.run { + manager.prepareForSleep() + manager.scan() + } + do { + _ = try await manager.command(deviceID: "WAKE", "STATUS") + XCTFail("Sleep should close the serial session") + } catch {} await MainActor.run { state.identities = [.init(id: "WAKE", port: newPTY.path)] manager.reconnectAfterWake() diff --git a/firmware/tiny_touch_unified/main/fingerprint.c b/firmware/tiny_touch_unified/main/fingerprint.c index c56cc12..611e714 100644 --- a/firmware/tiny_touch_unified/main/fingerprint.c +++ b/firmware/tiny_touch_unified/main/fingerprint.c @@ -26,7 +26,6 @@ static const uint8_t FP_LED_RED = 0x04; static const uint8_t FP_LED_FUNC_STEADY = 3; static const uint8_t FP_LED_FUNC_OFF = 4; -static uint8_t current_led = 0xff; static SemaphoreHandle_t fp_mutex; static bool sensor_ready; static portMUX_TYPE sensor_state_lock = portMUX_INITIALIZER_UNLOCKED; @@ -192,15 +191,9 @@ static void fp_give(void) { } static void set_aura(uint8_t color) { - if (color == current_led) return; uint8_t params[] = {FP_LED_FUNC_STEADY, color, color, 0}; uint8_t confirm = 0xff; - if (fp_command(0x3c, params, sizeof(params), &confirm, NULL, NULL, 1000) && - confirm == 0x00) { - current_led = color; - } else { - current_led = 0xff; - } + fp_command(0x3c, params, sizeof(params), &confirm, NULL, NULL, 1000); } static void set_idle_aura(void) { @@ -208,12 +201,9 @@ static void set_idle_aura(void) { set_aura(FP_LED_BLUE); return; } - if (current_led == 0) return; uint8_t params[] = {FP_LED_FUNC_OFF, 0, 0, 0}; uint8_t confirm = 0xff; - bool off = fp_command(0x3c, params, sizeof(params), &confirm, NULL, NULL, 1000) && - confirm == 0x00; - current_led = off ? 0 : 0xff; + fp_command(0x3c, params, sizeof(params), &confirm, NULL, NULL, 1000); } static void show_result(bool ok) { @@ -316,6 +306,7 @@ fingerprint_match_t fingerprint_authorize_poll_match(void) { } fingerprint_match_t match = fingerprint_match_captured(true); if (match.slot) set_aura(FP_LED_GREEN); + else set_idle_aura(); fp_give(); return match; } diff --git a/firmware/tiny_touch_unified/main/touch_pin_hid.c b/firmware/tiny_touch_unified/main/touch_pin_hid.c index dcb25bc..58277aa 100644 --- a/firmware/tiny_touch_unified/main/touch_pin_hid.c +++ b/firmware/tiny_touch_unified/main/touch_pin_hid.c @@ -27,9 +27,15 @@ static void secure_wipe(void *data, size_t length) { while (length--) *cursor++ = 0; } +static bool usb_hid_ready(void) { + return tud_mounted() && !tud_suspended() && tud_hid_ready() && + (device_config_mode() != DEVICE_MODE_HID || tud_cdc_connected()); +} + static bool wait_hid_ready(void) { TickType_t started = xTaskGetTickCount(); - while (!tud_hid_ready()) { + while (!usb_hid_ready()) { + if (!tud_mounted() || tud_suspended()) return false; if ((TickType_t)(xTaskGetTickCount() - started) >= pdMS_TO_TICKS(2000)) { return false; } @@ -38,6 +44,16 @@ static bool wait_hid_ready(void) { return true; } +static bool receive_password_response(char response[640], uint32_t timeout_ms) { + TickType_t started = xTaskGetTickCount(); + TickType_t timeout = pdMS_TO_TICKS(timeout_ms); + while ((TickType_t)(xTaskGetTickCount() - started) < timeout) { + if (!tud_mounted() || tud_suspended() || !tud_cdc_connected()) return false; + if (xQueueReceive(password_responses, response, pdMS_TO_TICKS(50)) == pdTRUE) return true; + } + return false; +} + static bool send_key(uint8_t modifier, uint8_t key) { uint8_t report[6] = {key, 0, 0, 0, 0, 0}; if (!wait_hid_ready()) return false; @@ -265,7 +281,7 @@ static bool request_and_type_password(fingerprint_match_t match) { snprintf(event, sizeof(event), "EV %s %lu %u %u %s", nonce, (unsigned long)event_counter, match.slot, match.score, mac_hex); config_console_send_line(event); - if (xQueueReceive(password_responses, response, pdMS_TO_TICKS(6000)) != pdTRUE || + if (!receive_password_response(response, 6000) || !decrypt_password(pairing_key, nonce, response, password, &password_length)) goto done; } else { int used = snprintf(event, sizeof(event), "EV2 %s %lu %u %u", nonce, @@ -282,7 +298,7 @@ static bool request_and_type_password(fingerprint_match_t match) { } if (used <= 0 || used >= sizeof(event)) goto done; config_console_send_line(event); - if (xQueueReceive(password_responses, response, pdMS_TO_TICKS(1500)) == pdTRUE && + if (receive_password_response(response, 1500) && decrypt_password_v2(nonce, response, hosts, host_count, password, &password_length)) { result = type_ascii(password, password_length); @@ -296,7 +312,7 @@ static bool request_and_type_password(fingerprint_match_t match) { snprintf(event, sizeof(event), "EV %s %lu %u %u %s", nonce, (unsigned long)event_counter, match.slot, match.score, mac_hex); config_console_send_line(event); - if (xQueueReceive(password_responses, response, pdMS_TO_TICKS(4500)) != pdTRUE || + if (!receive_password_response(response, 4500) || !decrypt_password(pairing_key, nonce, response, password, &password_length)) goto done; } result = type_ascii(password, password_length); @@ -364,7 +380,7 @@ static void touch_hid_task(void *arg) { // Presence is the sole trigger for a capture. Idle operation never sends // sensor commands and therefore never flashes a failure indication. - if (!present || !tud_hid_ready()) { + if (!present || !usb_hid_ready()) { vTaskDelay(pdMS_TO_TICKS(10)); continue; } @@ -375,6 +391,11 @@ static void touch_hid_task(void *arg) { vTaskDelay(pdMS_TO_TICKS(10)); continue; } + if (!usb_hid_ready()) { + fingerprint_led_idle(); + auth_wait_for_lift(&runtime, now); + continue; + } handle_fingerprint_match(match); auth_wait_for_lift(&runtime, xTaskGetTickCount()); diff --git a/firmware/tiny_touch_unified/main/usb_ccid.c b/firmware/tiny_touch_unified/main/usb_ccid.c index 3d64fa0..d5b891e 100644 --- a/firmware/tiny_touch_unified/main/usb_ccid.c +++ b/firmware/tiny_touch_unified/main/usb_ccid.c @@ -9,6 +9,8 @@ #include "tinyusb_default_config.h" #include "tusb.h" #include "device/usbd_pvt.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "touch_pin_hid.h" #include "usb_descriptors.h" @@ -23,10 +25,24 @@ static uint8_t tx_buf[CCID_BUF_SIZE]; static uint8_t rhport_active; static ccid_apdu_handler_t apdu_handler; static bool in_busy; +static TaskHandle_t recovery_task; + +static void recover_usb_after_resume(void *arg) { + (void)arg; + while (true) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + // ponytail: macOS can wedge composite endpoints on resume; remove when + // TinyUSB/macOS reliably restores CDC, HID, and CCID without re-enumeration. + tud_disconnect(); + vTaskDelay(pdMS_TO_TICKS(100)); + tud_connect(); + } +} static void usb_event_cb(tinyusb_event_t *event, void *arg) { (void)arg; if (event->id == TINYUSB_EVENT_ATTACHED) touch_pin_hid_usb_attached(); + else if (event->id == TINYUSB_EVENT_RESUMED && recovery_task) xTaskNotifyGive(recovery_task); } static uint32_t le32(const uint8_t *p) { @@ -199,4 +215,6 @@ void usb_ccid_start(ccid_apdu_handler_t handler) { tusb_cfg.descriptor.full_speed_config = tiny_touch_configuration_descriptor; tusb_cfg.event_cb = usb_event_cb; ESP_ERROR_CHECK(tinyusb_driver_install(&tusb_cfg)); + configASSERT(xTaskCreate(recover_usb_after_resume, "usb_recover", 2048, NULL, 4, + &recovery_task) == pdPASS); } diff --git a/firmware/tiny_touch_unified/sdkconfig b/firmware/tiny_touch_unified/sdkconfig index c134034..a67c703 100644 --- a/firmware/tiny_touch_unified/sdkconfig +++ b/firmware/tiny_touch_unified/sdkconfig @@ -2044,7 +2044,7 @@ CONFIG_TINYUSB_MODE_DMA=y # TinyUSB callbacks # # CONFIG_TINYUSB_SUSPEND_CALLBACK is not set -# CONFIG_TINYUSB_RESUME_CALLBACK is not set +CONFIG_TINYUSB_RESUME_CALLBACK=y # end of TinyUSB callbacks # diff --git a/firmware/tiny_touch_unified/sdkconfig.defaults b/firmware/tiny_touch_unified/sdkconfig.defaults index be63a72..7e91830 100644 --- a/firmware/tiny_touch_unified/sdkconfig.defaults +++ b/firmware/tiny_touch_unified/sdkconfig.defaults @@ -20,6 +20,7 @@ CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_TINYUSB_CDC_RX_BUFSIZE=64 CONFIG_TINYUSB_CDC_TX_BUFSIZE=512 CONFIG_TINYUSB_CDC_EP_BUFSIZE=64 +CONFIG_TINYUSB_RESUME_CALLBACK=y CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y CONFIG_ESP_TASK_WDT_EN=y CONFIG_ESP_TASK_WDT_INIT=y diff --git a/tests/test_protocol6_firmware.py b/tests/test_protocol6_firmware.py index 88f2282..e100a11 100644 --- a/tests/test_protocol6_firmware.py +++ b/tests/test_protocol6_firmware.py @@ -34,6 +34,15 @@ def test_protocol_six_has_one_stable_usb_descriptor(self) -> None: self.assertIn("0x42, 0x00, 0x02, 0x00", descriptors) self.assertIn("product_id=misa198.tinytouch.v1", self.source("config_console.c")) + def test_usb_soft_reenumerates_after_resume(self) -> None: + usb = self.source("usb_ccid.c") + for name in ("sdkconfig.defaults", "sdkconfig"): + config = (PROJECT / name).read_text() + self.assertIn("CONFIG_TINYUSB_RESUME_CALLBACK=y", config) + self.assertIn("event->id == TINYUSB_EVENT_RESUMED", usb) + self.assertIn("tud_disconnect();", usb) + self.assertIn("tud_connect();", usb) + def test_persistence_swaps_one_live_config_blob(self) -> None: source = self.source("device_config.c") self.assertIn('CONFIG_NAMESPACE "tt6"', source) @@ -97,7 +106,9 @@ def test_piv_dummy_pin_matches_documented_value(self) -> None: def test_fingerprint_auth_requires_presence(self) -> None: source = self.source("touch_pin_hid.c") - self.assertIn("if (!present || !tud_hid_ready())", source) + self.assertIn("if (!present || !usb_hid_ready())", source) + self.assertIn("device_config_mode() != DEVICE_MODE_HID || tud_cdc_connected()", source) + self.assertIn("tud_suspended() || !tud_cdc_connected()", source) self.assertNotIn("!fingerprint_is_ready()", source) self.assertNotIn("fingerprint_service_health", source) self.assertNotIn("usb_runtime", source) @@ -124,6 +135,8 @@ def test_status_reports_exact_fingerprint_slots_and_live_settings(self) -> None: self.assertIn('strcmp(arguments, "LED_IDLE")', console) self.assertIn("bool value = !config.idle_led_off", config) self.assertIn("FP_LED_FUNC_OFF", fingerprint) + self.assertIn("else set_idle_aura();", fingerprint) + self.assertNotIn("current_led", fingerprint) self.assertIn("fp_command(0x1f", fingerprint) From 820d070ac391dbb8bada24878ae4788b203ed38a Mon Sep 17 00:00:00 2001 From: misa198 Date: Sat, 5 Sep 2026 12:57:32 +0700 Subject: [PATCH 2/2] ci: pr ci workflow --- .github/workflows/app-build.yml | 7 ++--- .github/workflows/app-ci.yml | 32 +++++++++++++++++++++++ .github/workflows/firmware-build.yml | 1 - .github/workflows/firmware-ci.yml | 39 ++++++++++++++++++++++++++++ tests/test_release_pipeline.py | 29 ++++++++++++++++----- 5 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/app-ci.yml create mode 100644 .github/workflows/firmware-ci.yml diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml index 4deb8f0..8361c0c 100644 --- a/.github/workflows/app-build.yml +++ b/.github/workflows/app-build.yml @@ -1,7 +1,6 @@ name: App Build on: - pull_request: workflow_dispatch: workflow_call: inputs: @@ -17,9 +16,9 @@ on: type: string secrets: MACOS_SIGNING_CERTIFICATE: - required: true + required: false MACOS_SIGNING_CERTIFICATE_PASSWORD: - required: true + required: false outputs: artifact-name: description: Uploaded app artifact name @@ -41,8 +40,6 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false - - name: Test app - run: swift test - name: Import release signing certificate if: inputs.version != '' env: diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml new file mode 100644 index 0000000..2226efe --- /dev/null +++ b/.github/workflows/app-ci.yml @@ -0,0 +1,32 @@ +name: App CI + +on: + push: + branches: [master] + paths: + - app/** + - .github/workflows/app-build.yml + - .github/workflows/app-ci.yml + pull_request: + paths: + - app/** + - .github/workflows/app-build.yml + - .github/workflows/app-ci.yml + +permissions: + contents: read + +jobs: + test: + runs-on: macos-latest + defaults: + run: + working-directory: app + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - run: swift test + + build: + uses: ./.github/workflows/app-build.yml diff --git a/.github/workflows/firmware-build.yml b/.github/workflows/firmware-build.yml index 6556d39..d44b104 100644 --- a/.github/workflows/firmware-build.yml +++ b/.github/workflows/firmware-build.yml @@ -1,7 +1,6 @@ name: Firmware Build on: - pull_request: workflow_dispatch: workflow_call: inputs: diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml new file mode 100644 index 0000000..873414a --- /dev/null +++ b/.github/workflows/firmware-ci.yml @@ -0,0 +1,39 @@ +name: Firmware CI + +on: + push: + branches: [master] + paths: + - firmware/** + - VERSION + - channels/app-firmware.json + - packaging/assemble-release.py + - packaging/release_integrity.py + - tests/test_protocol6_firmware.py + - .github/workflows/firmware-build.yml + - .github/workflows/firmware-ci.yml + pull_request: + paths: + - firmware/** + - VERSION + - channels/app-firmware.json + - packaging/assemble-release.py + - packaging/release_integrity.py + - tests/test_protocol6_firmware.py + - .github/workflows/firmware-build.yml + - .github/workflows/firmware-ci.yml + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - run: python3 -m unittest tests/test_protocol6_firmware.py + + build: + uses: ./.github/workflows/firmware-build.yml diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py index 857ac53..a8a733f 100644 --- a/tests/test_release_pipeline.py +++ b/tests/test_release_pipeline.py @@ -210,10 +210,11 @@ def test_app_firmware_channel_has_valid_exclusive_ranges(self): def test_build_tag_and_release_workflow_contract(self): workflows = ROOT / ".github" / "workflows" build = (workflows / "firmware-build.yml").read_text() + ci = (workflows / "firmware-ci.yml").read_text() tag = (workflows / "firmware-tag.yml").read_text() release = (workflows / "firmware-release.yml").read_text() - self.assertIn("pull_request:", build) + self.assertNotIn("pull_request:", build) self.assertIn("workflow_dispatch:", build) self.assertIn("workflow_call:", build) self.assertIn("contents: read", build) @@ -228,6 +229,15 @@ def test_build_tag_and_release_workflow_contract(self): self.assertIn("packaging/release_integrity.py channel", build) self.assertIn('tinytouch-firmware-${version}.tar.gz', build) self.assertIn('sha256sum "$bundle"', build) + self.assertIn("push:", ci) + self.assertIn("branches: [master]", ci) + self.assertIn("pull_request:", ci) + self.assertIn("firmware/**", ci) + self.assertIn("packaging/assemble-release.py", ci) + self.assertIn("packaging/release_integrity.py", ci) + self.assertIn("tests/test_protocol6_firmware.py", ci) + self.assertIn("uses: ./.github/workflows/firmware-build.yml", ci) + self.assertIn("python3 -m unittest tests/test_protocol6_firmware.py", ci) self.assertEqual(tag.count("workflow_dispatch:"), 1) self.assertNotIn("pull_request:", tag) @@ -269,23 +279,30 @@ def test_build_tag_and_release_workflow_contract(self): tag_release = (ROOT / "packaging" / "tag-release").read_text() self.assertNotIn("release-candidate", tag_release) self.assertIn('--ref master', tag_release) - self.assertIn('${current##*.} + 1', tag_release) + self.assertIn(" ", tag_release) self.assertIn("exec packaging/tag-release", (ROOT / "packaging" / "release").read_text()) def test_app_build_tag_and_release_workflow_contract(self): workflows = ROOT / ".github" / "workflows" build = (workflows / "app-build.yml").read_text() + ci = (workflows / "app-ci.yml").read_text() tag = (workflows / "app-tag.yml").read_text() release = (workflows / "app-release.yml").read_text() - self.assertIn("pull_request:", build) + self.assertNotIn("pull_request:", build) self.assertIn("workflow_dispatch:", build) self.assertIn("workflow_call:", build) - self.assertIn("swift test", build) + self.assertNotIn("swift test", build) self.assertIn("xcodebuild", build) self.assertNotIn("-t cert -f pkcs12", build) - self.assertIn("security find-identity -v -p codesigning", build) + self.assertIn("security find-identity -p codesigning", build) self.assertIn("actions/upload-artifact@", build) + self.assertIn("push:", ci) + self.assertIn("branches: [master]", ci) + self.assertIn("pull_request:", ci) + self.assertIn("app/**", ci) + self.assertIn("uses: ./.github/workflows/app-build.yml", ci) + self.assertIn("swift test", ci) self.assertEqual(tag.count("workflow_dispatch:"), 1) self.assertIn('tag="app-v$VERSION"', tag) self.assertIn("MARKETING_VERSION", tag) @@ -298,7 +315,7 @@ def test_app_build_tag_and_release_workflow_contract(self): self.assertIn("verification.verified", tag) self.assertIn("CFBundleShortVersionString", build) settings = (ROOT / "app" / "TinyTouch" / "DeviceManagementViews.swift").read_text() - self.assertIn('LabeledContent("Version", value: version)', settings) + self.assertIn('LabeledContent("settings_version", value: version)', settings) project = (ROOT / "app" / "TinyTouch.xcodeproj" / "project.pbxproj").read_text() self.assertIn("objectVersion = 77;", project)