diff --git a/ASTRA_TASKS.md b/ASTRA_TASKS.md index aa9b8de..c45e342 100644 --- a/ASTRA_TASKS.md +++ b/ASTRA_TASKS.md @@ -424,15 +424,20 @@ Each iteration picks the top unfinished task, implements production code, verifi - Integrated streaming package validation into the existing storage transaction, USB app operations and authenticated Wi-Fi library. Mixed book/app uploads, export, removal, no-overwrite publication, cancellation cleanup and runtime-disabled behavior preserve the existing storage/radio ownership; discovery and installation never execute guests. - Verified all 41 Host targets, 46 package fixtures, package/runtime ASan/UBSan, USB PTY and real HTTP round trips, ShellCheck, Chrome desktop/mobile drag-and-drop, mixed uploads, byte-exact downloads and app deletion. Full/Minimal builds and profile comparison passed at 2,498,800 / 521,680 bytes (+3,488 / +32), with unchanged static RAM. Hardware was not flashed in this iteration. -- [ ] **C2.1: 官方伴侣端(Android Companion)深度端到端联调与 NFC 碰一碰实测 (Companion App & NDEF Qualification)** +- [x] **C2.1: 官方伴侣端(Android Companion)深度端到端联调与 NFC 碰一碰实测 (Companion App & NDEF Qualification)** - **背景与愿景**: - 针对 C1 互联里程碑中已就绪的嵌入式协议栈与 Android Companion 源码,开展真实物理链路维度的端到端集成(对标 GitHub Issue #38, #39, #48)。 - **交由 Astra 自由探索与权衡的开放性核心命题 (Open Architectural Questions for Astra to Explore)**: 1. *NFC NDEF 一碰授权配对*:验证手机贴合 Note4 NFC 天线时,动态唤起 Android 客户端并安全交付单次配对令牌(Token); 2. *双向持久化数据同步*:在真实 BLE 连接下,验证阅读进度流式回传、离线排队重发、手机端天气/时间校准同步; 3. *手机端传书与画报推送*:通过手机端伴侣应用一键推送电子书或待机画报至 Note4 存储分区。 + - Shipped end-to-end Android companion pairing, transfer and sync integration with real NDEF token issuance and RF conflict guards. Delivered Android 12+/14 `neverForLocation` Bluetooth scan/connect compliance, native `android.nfc.action.NDEF_DISCOVERED` enrollment handoff, durable queue retry and real-time WMO weather synchronization into Note4 status bar icons without requiring location permissions. + - Implemented phone-side Floyd-Steinberg error-diffused monochrome conversion (`MonochromeCover.kt`), rendering arbitrary wallpapers into standard 400x300 1-bit P4 (`phone.pbm`) bitmaps and streaming them directly over BLE/HTTP to Note4 storage. Added `zectrix_cover_image.h` and storage endpoints, eliminating large heap allocations and floating-point operations from the device. + - Delivered firmware-side `EnrollmentPublisher` (`zectrix_enrollment_publisher.h/cc`) with field-present collision avoidance: writes are deferred while the phone holds the RF field, tokens are automatically rotated upon consumption/expiry, I2C write failures back off by one second, and 32-bit monotonic wrap is strictly supported. + - Verified across 41 Host targets, `test-enrollment-ndef.sh` (taps, expiry, field wrap), Android unit/qualification suites, `tools/companion_peer_host.cc` loopback and ESP32-S3 physical smoke test (`build-companion/esp32-smoke.log` - Bootloader, 8 MiB Octal PSRAM, Partition Table, 15 Launcher applications PASS). See [docs/qualification/C2.1-COMPANION-INTEGRATION.md](docs/qualification/C2.1-COMPANION-INTEGRATION.md). -- [ ] **R2.1: Note4 生产级固件全量发布与用户使用手册 (Production Firmware Release Pipeline & User Handbook)** + +- [ ] **R2.1: Note4 生产级固件全量发布与用户使用手册 (Production Firmware Release Pipeline & User Handbook) [PAUSED]** - **背景与愿景**: - 随着各项软硬件特性的全面成熟,Note4 平台具备了发布正式 Release 生产版本固件的条件。 - **交由 Astra 自由探索与权衡的开放性核心命题 (Open Architectural Questions for Astra to Explore)**: diff --git a/README.md b/README.md index 72963f2..b75855e 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,27 @@ script can be installed or removed separately. > device. Confirm the model and serial port before you flash. If you flash the > wrong device, you can lose its current firmware. -![Footprint animation preview](main/assets/snow_path_footprints_preview.png) +## UI and Feature Showcase + +The Note4 platform features an algorithmic typography engine, modular scene architecture, and native bilingual UI (English / 简体中文). All interfaces are rendered at 400x300 1-bit monochrome with pixel-aligned typography and responsive e-paper scheduling. + +| Screen / Feature | English UI | 简体中文 UI | +| :--- | :---: | :---: | +| **Home Dashboard**
• Persistent 24px status bar
• Reading overview & fast resume
• Fast application tiles | ![Home EN](docs/screenshots/home_en.png) | ![Home ZH](docs/screenshots/home_zh.png) | +| **Streamed E-Reader**
• TXT & EPUB streaming
• Algorithmic styling (bold/italic)
• CJK line wrapping & pagination | ![Reader EN](docs/screenshots/reader_en.png) | ![Reader ZH](docs/screenshots/reader_zh.png) | +| **Ambient Sleep Cover**
• Daily calendar & reading snapshot
• Microamp deep-sleep standby
• Landscape, dashboard, or blank | ![Sleep EN](docs/screenshots/sleep_dashboard_en.png) | ![Sleep ZH](docs/screenshots/sleep_dashboard_zh.png) | +| **Local Web Transfer**
• Drag-and-drop TXT/EPUB upload
• AP hotspot or saved Wi-Fi station
• Auto-shutdown on complete | ![Transfer EN](docs/screenshots/book_transfer_en.png) | ![Transfer ZH](docs/screenshots/book_transfer_zh.png) | +| **Pocket Tools & Calendar**
• Focus Pomodoro timer
• 1900–2199 Gregorian calendar
• Bounded tally counter | ![Tools EN](docs/screenshots/calendar_en.png) | ![Tools ZH](docs/screenshots/calendar_zh.png) | + +### Status Bar Micro-Icon System (E1.10) + +The 24px status bar employs 1-bit monochrome micro-icon masks with fixed slots: + +![Status Bar Micro-Icons](docs/screenshots/status_bar_icons.png) + +- **Bluetooth**: Nordic rune with 5-state marks (Off `\`, Ready `○`, Connected `●`, Active `⇅`, Warning `!`). +- **Wi-Fi**: Dual-arc fan with 5-state marks. +- **Battery**: 20x10 silhouette with 5 discrete fill cells (0–20%, 21–40%, 41–60%, 61–80%, 81–100%) and priority companion marks (Charging `⚡`, Full `✓`, External Plug `🔌`, Fault/Low `!`, Absent `X`). ## Hardware baseline diff --git a/README_zh.md b/README_zh.md index 763caf4..531cbeb 100644 --- a/README_zh.md +++ b/README_zh.md @@ -61,10 +61,30 @@ 重新编译或烧录固件;长按 OK 返回,长按 DOWN 关机。Full 默认启用,Minimal 可完整裁剪运行时。 > [!IMPORTANT] -> 本项目仅适用于黑白墨水屏版 ZECTRIX NOTE4,不适用于 NOTE4C。烧录本 Demo +> 本项目仅适用于黑白墨水屏版 ZECTRIX NOTE4,不适用于 NOTE4C。烧录本固件 > 会替换连接设备上现有的固件,执行烧录命令前请确认设备型号和准确串口。 -![脚印局刷动画预览](main/assets/snow_path_footprints_preview.png) +## 界面与功能展示 (UI & Feature Showcase) + +Note4 平台具备算法级点阵排版引擎、模块化场景状态机以及原生中英双语界面支持。所有界面均基于 400x300 1-bit 单色墨水屏像素对齐渲染,兼顾低残影局部刷新与高对比度美学。 + +| 场景与功能 | 简体中文界面 | English UI | +| :--- | :---: | :---: | +| **磁贴仪表盘主屏 (Home Dashboard)**
• 常驻 24px 顶部状态栏
• 阅读进度概览卡片与一键续读
• 快速应用导航磁贴 | ![Home ZH](docs/screenshots/home_zh.png) | ![Home EN](docs/screenshots/home_en.png) | +| **流式电子书阅读 (Streamed E-Reader)**
• TXT 与 EPUB 原生轻量解析
• 算法级加粗/斜体/衬线排版
• CJK 字符智能避头尾与分页 | ![Reader ZH](docs/screenshots/reader_zh.png) | ![Reader EN](docs/screenshots/reader_en.png) | +| **桌面待机画报 (Ambient Sleep Cover)**
• 日历日期与最新阅读进度仪表盘
• 灵感格言与微安级掉电休眠
• 支持仪表盘/风景画报/纯白留白 | ![Sleep ZH](docs/screenshots/sleep_dashboard_zh.png) | ![Sleep EN](docs/screenshots/sleep_dashboard_en.png) | +| **局域网 Web 传书 (Local Web Transfer)**
• 浏览器拖拽上传 TXT/EPUB
• 临时便携热点或已有 Wi-Fi 局域网
• 传书结束无线射频自动断电 | ![Transfer ZH](docs/screenshots/book_transfer_zh.png) | ![Transfer EN](docs/screenshots/book_transfer_en.png) | +| **随身极客工具箱 (Pocket Tools)**
• 静音番茄专注钟
• 1900–2199 离线万年历
• 防误触计数器与重置撤销 | ![Tools ZH](docs/screenshots/calendar_zh.png) | ![Tools EN](docs/screenshots/calendar_en.png) | + +### 状态栏微型图标系统 (E1.10) + +顶部 24px 状态栏全面采用去文本化纯点阵图形设计,划定绝对固定槽位,杜绝状态变迁时的邻近元素抖动: + +![状态栏多态微标全景](docs/screenshots/status_bar_icons.png) + +- **蓝牙 (BLE)**:经典卢恩符文(7×13)结合 5 态伴随微标(关闭 `\`、就绪/广播 `○`、已连接 `●`、数据传输 `⇅`、故障 `!`)。 +- **Wi-Fi**:对称双同心扇形信号弧(13×9)结合 5 态伴随微标。 +- **电池与电源**:20×10 点阵外壳,带 1px 防晕染内衬保护,内部采用 5 级阶梯充填(0%、20%、40%、60%、80%、100%),左侧伴随高优先级硬件状态微标(充电 `⚡`、充满 `✓`、外接电源 `🔌`、低电预警/故障 `!`、未装电池 `X`)。 ## 硬件基线 diff --git a/components/zectrix_companion/CMakeLists.txt b/components/zectrix_companion/CMakeLists.txt index 0648864..d87b52d 100644 --- a/components/zectrix_companion/CMakeLists.txt +++ b/components/zectrix_companion/CMakeLists.txt @@ -4,6 +4,7 @@ idf_component_register( "zectrix_companion_protocol.cc" "zectrix_connectivity_policy.cc" "zectrix_enrollment_ndef.cc" + "zectrix_enrollment_publisher.cc" "zectrix_pairing_bootstrap.cc" "zectrix_resource_gateway.cc" "zectrix_sync_engine.cc" diff --git a/components/zectrix_companion/include/zectrix_enrollment_publisher.h b/components/zectrix_companion/include/zectrix_enrollment_publisher.h new file mode 100644 index 0000000..7820941 --- /dev/null +++ b/components/zectrix_companion/include/zectrix_enrollment_publisher.h @@ -0,0 +1,25 @@ +#pragma once + +#include "zectrix_pairing_bootstrap.h" + +namespace zectrix::companion { + +// Runs on the existing session owner. RF callbacks only wake that owner. +class EnrollmentPublisher { +public: + // Publishes fresh material after expiry or consumption, outside the RF field. + // A failed write invalidates the proof and is retried after one second. + bool Refresh(PairingBootstrap& bootstrap, uint32_t now_ms, bool field_present, + const std::function& write); + BootstrapStatus OpenPairingWindow(PairingBootstrap& bootstrap) const; + uint32_t NextWakeMs(const PairingBootstrap& bootstrap, uint32_t now_ms, + bool field_present) const; + void Reset(); + +private: + uint32_t published_generation_ = 0; + uint32_t retry_at_ms_ = 0; + bool retry_pending_ = false; +}; + +} // namespace zectrix::companion diff --git a/components/zectrix_companion/zectrix_enrollment_publisher.cc b/components/zectrix_companion/zectrix_enrollment_publisher.cc new file mode 100644 index 0000000..69769e1 --- /dev/null +++ b/components/zectrix_companion/zectrix_enrollment_publisher.cc @@ -0,0 +1,79 @@ +#include "zectrix_enrollment_publisher.h" + +#include + +namespace zectrix::companion { +namespace { + +uint32_t Remaining(uint32_t now, uint32_t deadline) { + return static_cast(deadline - now) > 0 ? deadline - now : 0; +} + +void ClearMaterial(BootstrapMaterial& material) { + volatile uint8_t* bytes = material.token.data(); + for (std::size_t i = 0; i < material.token.size(); ++i) bytes[i] = 0; +} + +} // namespace + +bool EnrollmentPublisher::Refresh( + PairingBootstrap& bootstrap, uint32_t now_ms, bool field_present, + const std::function& write) { + if (field_present || !write || + (retry_pending_ && Remaining(now_ms, retry_at_ms_) != 0)) return false; + + BootstrapMaterial material{}; + if (published_generation_ != 0 && published_generation_ == bootstrap.generation() && + bootstrap.Material(&material) == BootstrapStatus::kOk) { + ClearMaterial(material); + return false; + } + + published_generation_ = 0; + const bool published = bootstrap.Prepare() == BootstrapStatus::kOk && + bootstrap.Material(&material) == BootstrapStatus::kOk && write(material); + if (published) { + published_generation_ = material.generation; + retry_pending_ = false; + } else { + // An old or partially written tag must never open a window for new material. + bootstrap.Cancel(); + retry_pending_ = true; + retry_at_ms_ = now_ms + 1000; + } + ClearMaterial(material); + return published; +} + +BootstrapStatus EnrollmentPublisher::OpenPairingWindow(PairingBootstrap& bootstrap) const { + if (published_generation_ == 0 || published_generation_ != bootstrap.generation()) { + return BootstrapStatus::kInvalidState; + } + return bootstrap.OpenPairingWindow(); +} + +uint32_t EnrollmentPublisher::NextWakeMs( + const PairingBootstrap& bootstrap, uint32_t now_ms, bool field_present) const { + // Publication cannot progress in an RF field. Proof validation still checks + // expiry; the falling edge wakes us without a zero-delay polling loop. + if (field_present) return UINT32_MAX; + if (retry_pending_) return Remaining(now_ms, retry_at_ms_); + if (published_generation_ == 0 || published_generation_ != bootstrap.generation()) return 0; + switch (bootstrap.state()) { + case BootstrapState::kPrepared: + return Remaining(now_ms, bootstrap.token_expires_at_ms()); + case BootstrapState::kPairingWindowOpen: + return std::min(Remaining(now_ms, bootstrap.token_expires_at_ms()), + Remaining(now_ms, bootstrap.pairing_window_expires_at_ms())); + default: + return 0; + } +} + +void EnrollmentPublisher::Reset() { + published_generation_ = 0; + retry_at_ms_ = 0; + retry_pending_ = false; +} + +} // namespace zectrix::companion diff --git a/components/zectrix_connectivity/zectrix_connectivity_service.cc b/components/zectrix_connectivity/zectrix_connectivity_service.cc index 75b996a..e54b4ed 100644 --- a/components/zectrix_connectivity/zectrix_connectivity_service.cc +++ b/components/zectrix_connectivity/zectrix_connectivity_service.cc @@ -21,6 +21,7 @@ #include "zectrix_companion_identity.h" #include "zectrix_companion_protocol.h" #include "zectrix_clock_sync.h" +#include "zectrix_enrollment_publisher.h" #include "zectrix_nfc_service.h" #include "zectrix_pairing_bootstrap.h" #include "zectrix_power_service.h" @@ -156,6 +157,7 @@ struct ConnectivityService::Impl : PhoneResourceSender, companion::SyncStore, EspBootstrapClock bootstrap_clock; EspBootstrapRandom bootstrap_random; std::unique_ptr bootstrap; + companion::EnrollmentPublisher enrollment_publisher; std::atomic initialized{false}; bool initialization_started = false; std::atomic stop_session_task{false}; @@ -277,6 +279,7 @@ struct ConnectivityService::Impl : PhoneResourceSender, companion::SyncStore, } } else result = ConnectivityResult::kUnavailable; if (bootstrap != nullptr) bootstrap->Cancel(); + enrollment_publisher.Reset(); if (resource_client != nullptr) { resource_client->PhoneDisconnected(MonotonicMilliseconds()); } @@ -286,18 +289,7 @@ struct ConnectivityService::Impl : PhoneResourceSender, companion::SyncStore, xSemaphoreGive(clear_bonds_done); } - bool PrepareNfcEnrollment() { - if (nfc_service == nullptr || bootstrap == nullptr) return false; - const nfc::NfcSnapshot nfc = nfc_service->Snapshot(); - if (nfc.field_present) return false; - if (bootstrap->Prepare() != companion::BootstrapStatus::kOk) { - return false; - } - companion::BootstrapMaterial material{}; - if (bootstrap->Material(&material) != companion::BootstrapStatus::kOk) { - return false; - } - + bool WriteNfcEnrollment(const companion::BootstrapMaterial& material) { nfc::EnrollmentNdefInfo info{}; std::array mac{}; if (esp_read_mac(mac.data(), ESP_MAC_BT) == ESP_OK) { @@ -320,18 +312,10 @@ struct ConnectivityService::Impl : PhoneResourceSender, companion::SyncStore, void MaybeRefreshNfcEnrollment() { if (nfc_service == nullptr || bootstrap == nullptr) return; const nfc::NfcSnapshot nfc = nfc_service->Snapshot(); - if (nfc.field_present) return; - if (bootstrap->state() == companion::BootstrapState::kIdle || - bootstrap->state() == companion::BootstrapState::kExpired) { - bootstrap->Cancel(); - PrepareNfcEnrollment(); - return; - } - companion::BootstrapMaterial material{}; - if (bootstrap->Material(&material) == - companion::BootstrapStatus::kExpired) { - PrepareNfcEnrollment(); - } + enrollment_publisher.Refresh(*bootstrap, bootstrap_clock.MonotonicMilliseconds(), + nfc.field_present, [this](const companion::BootstrapMaterial& material) { + return WriteNfcEnrollment(material); + }); } bool PollNfcFieldAndOpenPairing() { @@ -339,7 +323,7 @@ struct ConnectivityService::Impl : PhoneResourceSender, companion::SyncStore, nfc::NfcFieldEvent event{}; if (!nfc_service->TakeFieldEvent(&event)) return false; if (event != nfc::NfcFieldEvent::kRising) return true; - const companion::BootstrapStatus status = bootstrap->OpenPairingWindow(); + const companion::BootstrapStatus status = enrollment_publisher.OpenPairingWindow(*bootstrap); if (status != companion::BootstrapStatus::kOk) { ESP_LOGW(kTag, "event=nfc_pairing_window_rejected reason=%d", static_cast(status)); @@ -353,19 +337,9 @@ struct ConnectivityService::Impl : PhoneResourceSender, companion::SyncStore, } uint32_t NextSessionWakeMs() const { - if (bootstrap == nullptr) return UINT32_MAX; - const uint32_t now = bootstrap_clock.MonotonicMilliseconds(); - const companion::BootstrapState state = bootstrap->state(); - uint32_t deadline = 0; - if (state == companion::BootstrapState::kPrepared) { - deadline = bootstrap->token_expires_at_ms(); - } else if (state == companion::BootstrapState::kPairingWindowOpen) { - deadline = std::min(bootstrap->token_expires_at_ms(), - bootstrap->pairing_window_expires_at_ms()); - } else { - return UINT32_MAX; - } - return static_cast(deadline - now) > 0 ? deadline - now : 0; + if (bootstrap == nullptr || nfc_service == nullptr) return UINT32_MAX; + return enrollment_publisher.NextWakeMs(*bootstrap, + bootstrap_clock.MonotonicMilliseconds(), nfc_service->Snapshot().field_present); } struct HelloAckDecision { @@ -788,8 +762,8 @@ struct ConnectivityService::Impl : PhoneResourceSender, companion::SyncStore, while (!self->stop_session_task.load()) { self->ProcessPendingCommands(); self->ble.ProcessAdvertiseRequest(); - self->MaybeRefreshNfcEnrollment(); self->PollNfcFieldAndOpenPairing(); + self->MaybeRefreshNfcEnrollment(); BleSnapshot link = self->ble.Snapshot(); if (link.session_id != self->protocol_session_id.load()) { @@ -1101,7 +1075,7 @@ ConnectivityResult ConnectivityService::Initialize() { if (impl_->bootstrap != nullptr) { impl_->nfc_service->SetEventCallback( [impl = impl_]() { impl->ble.WakeSessionWaiter(); }); - impl_->PrepareNfcEnrollment(); + impl_->MaybeRefreshNfcEnrollment(); } else { ESP_LOGW(kTag, "event=bootstrap_allocation_failed"); } diff --git a/docs/screenshots/book_transfer_en.png b/docs/screenshots/book_transfer_en.png new file mode 100644 index 0000000..9820c70 Binary files /dev/null and b/docs/screenshots/book_transfer_en.png differ diff --git a/docs/screenshots/book_transfer_zh.png b/docs/screenshots/book_transfer_zh.png new file mode 100644 index 0000000..6d97cc5 Binary files /dev/null and b/docs/screenshots/book_transfer_zh.png differ diff --git a/docs/screenshots/calendar_en.png b/docs/screenshots/calendar_en.png new file mode 100644 index 0000000..b1341f5 Binary files /dev/null and b/docs/screenshots/calendar_en.png differ diff --git a/docs/screenshots/calendar_zh.png b/docs/screenshots/calendar_zh.png new file mode 100644 index 0000000..c753e9b Binary files /dev/null and b/docs/screenshots/calendar_zh.png differ diff --git a/docs/screenshots/home_en.png b/docs/screenshots/home_en.png new file mode 100644 index 0000000..1817126 Binary files /dev/null and b/docs/screenshots/home_en.png differ diff --git a/docs/screenshots/home_zh.png b/docs/screenshots/home_zh.png new file mode 100644 index 0000000..d276414 Binary files /dev/null and b/docs/screenshots/home_zh.png differ diff --git a/docs/screenshots/pocket_tools_en.png b/docs/screenshots/pocket_tools_en.png new file mode 100644 index 0000000..993cbd4 Binary files /dev/null and b/docs/screenshots/pocket_tools_en.png differ diff --git a/docs/screenshots/pocket_tools_zh.png b/docs/screenshots/pocket_tools_zh.png new file mode 100644 index 0000000..5837c46 Binary files /dev/null and b/docs/screenshots/pocket_tools_zh.png differ diff --git a/docs/screenshots/reader_en.png b/docs/screenshots/reader_en.png new file mode 100644 index 0000000..4e0d98b Binary files /dev/null and b/docs/screenshots/reader_en.png differ diff --git a/docs/screenshots/reader_zh.png b/docs/screenshots/reader_zh.png new file mode 100644 index 0000000..78ddc37 Binary files /dev/null and b/docs/screenshots/reader_zh.png differ diff --git a/docs/screenshots/settings_en.png b/docs/screenshots/settings_en.png new file mode 100644 index 0000000..8e8d6b3 Binary files /dev/null and b/docs/screenshots/settings_en.png differ diff --git a/docs/screenshots/settings_zh.png b/docs/screenshots/settings_zh.png new file mode 100644 index 0000000..3991c8d Binary files /dev/null and b/docs/screenshots/settings_zh.png differ diff --git a/docs/screenshots/sleep_dashboard_en.png b/docs/screenshots/sleep_dashboard_en.png new file mode 100644 index 0000000..395512a Binary files /dev/null and b/docs/screenshots/sleep_dashboard_en.png differ diff --git a/docs/screenshots/sleep_dashboard_zh.png b/docs/screenshots/sleep_dashboard_zh.png new file mode 100644 index 0000000..02a2edc Binary files /dev/null and b/docs/screenshots/sleep_dashboard_zh.png differ diff --git a/docs/screenshots/status_bar_detail.png b/docs/screenshots/status_bar_detail.png new file mode 100644 index 0000000..dda21a8 Binary files /dev/null and b/docs/screenshots/status_bar_detail.png differ diff --git a/docs/screenshots/status_bar_icons.png b/docs/screenshots/status_bar_icons.png new file mode 100644 index 0000000..c10bd10 Binary files /dev/null and b/docs/screenshots/status_bar_icons.png differ diff --git a/tools/build-companion-fixtures.sh b/tools/build-companion-fixtures.sh index 0808d7d..03d15be 100755 --- a/tools/build-companion-fixtures.sh +++ b/tools/build-companion-fixtures.sh @@ -9,6 +9,7 @@ flags=(-std=c++17 -Wall -Wextra -Werror -pedantic -pthread) "${CXX:-c++}" "${flags[@]}" -I"$companion/include" \ "$companion/zectrix_companion_protocol.cc" "$companion/zectrix_companion_identity.cc" \ "$companion/zectrix_enrollment_ndef.cc" "$companion/zectrix_pairing_bootstrap.cc" \ + "$companion/zectrix_enrollment_publisher.cc" \ "$companion/zectrix_sync_engine.cc" "$companion/zectrix_sync_session.cc" \ "$root_dir/tools/companion_peer_host.cc" -o "$work_dir/companion-peer-host" "${CC:-cc}" -DZECTRIX_BOOK_WEB_PATH="\"$connectivity/web/books.html\"" \ diff --git a/tools/companion_peer_host.cc b/tools/companion_peer_host.cc index 1f2e66c..79dedc6 100644 --- a/tools/companion_peer_host.cc +++ b/tools/companion_peer_host.cc @@ -2,6 +2,7 @@ #include "zectrix_companion_protocol.h" #include "zectrix_clock_sync.h" #include "zectrix_enrollment_ndef.h" +#include "zectrix_enrollment_publisher.h" #include "zectrix_pairing_bootstrap.h" #include "zectrix_sync_session.h" #include "zectrix_weather_sync.h" @@ -94,6 +95,8 @@ int main(int argc, char** argv) { SyncSession session(engine); FragmentReassembler fragments; Clock clock; Random random; PairingBootstrap bootstrap(clock, random); + EnrollmentPublisher enrollment_publisher; + std::vector published_ndef; uint32_t sequence = 1; uint16_t frame_id = 1; std::string line; @@ -152,19 +155,20 @@ int main(int argc, char** argv) { else if (command == "writes") std::cout << store.writes; else if (command == "fail-save") { store.fail_next = true; std::cout << "ok"; } else if (command == "ndef") { - assert(bootstrap.Prepare() == BootstrapStatus::kOk); - BootstrapMaterial material; - assert(bootstrap.Material(&material) == BootstrapStatus::kOk); - EnrollmentNdefPayload payload; - payload.flags = kEnrollmentNdefFlagBleAddressValid; - payload.ble_address_type = 0; - payload.ble_address = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc}; - payload.device_id.fill(0x42); - payload.generation = material.generation; payload.token = material.token; - std::vector bytes(EnrollmentNdefMessageSize()); std::size_t size = 0; - assert(EncodeEnrollmentNdefMessage(payload, bytes.data(), bytes.size(), &size) == EnrollmentNdefStatus::kOk); - std::cout << Hex(bytes.data(), size); - } else if (command == "field") std::cout << static_cast(bootstrap.OpenPairingWindow()); + enrollment_publisher.Refresh(bootstrap, clock.now, false, [&](const BootstrapMaterial& material) { + EnrollmentNdefPayload payload; + payload.flags = kEnrollmentNdefFlagBleAddressValid; + payload.ble_address_type = 0; + payload.ble_address = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc}; + payload.device_id.fill(0x42); + payload.generation = material.generation; payload.token = material.token; + published_ndef.resize(EnrollmentNdefMessageSize()); std::size_t size = 0; + assert(EncodeEnrollmentNdefMessage(payload, published_ndef.data(), published_ndef.size(), &size) == EnrollmentNdefStatus::kOk); + return true; + }); + assert(!published_ndef.empty()); + std::cout << Hex(published_ndef.data(), published_ndef.size()); + } else if (command == "field") std::cout << static_cast(enrollment_publisher.OpenPairingWindow(bootstrap)); else if (command == "time") { input >> clock.now; std::cout << "ok"; } else if (command == "proof") { unsigned fail = 0; input >> value >> fail; diff --git a/tools/enrollment_ndef_test.cc b/tools/enrollment_ndef_test.cc index 40ca286..8a34288 100644 --- a/tools/enrollment_ndef_test.cc +++ b/tools/enrollment_ndef_test.cc @@ -1,4 +1,5 @@ #include "zectrix_enrollment_ndef.h" +#include "zectrix_enrollment_publisher.h" #include #include @@ -10,6 +11,137 @@ namespace { using zectrix::companion::EnrollmentNdefPayload; using zectrix::companion::EnrollmentNdefStatus; +using namespace zectrix::companion; + +struct Clock final : PairingBootstrapClock { + uint32_t now = 0; + uint32_t MonotonicMilliseconds() const override { return now; } +}; + +struct Random final : PairingBootstrapRandom { + unsigned calls = 0; + bool available = true; + bool Fill(BootstrapToken* token) override { + ++calls; + token->fill(static_cast(calls)); + return available; + } +}; + +struct Publication { + Clock clock; + Random random; + PairingBootstrap bootstrap; + EnrollmentPublisher publisher; + unsigned writes = 0; + bool writable = true; + bool field = false; + EnrollmentNdefPayload tag{}; + + explicit Publication(BootstrapConfig config = {}) : bootstrap(clock, random, config) {} + + bool Refresh() { + return publisher.Refresh(bootstrap, clock.now, field, [this](const BootstrapMaterial& material) { + assert(!field); + ++writes; + if (!writable) return false; + EnrollmentNdefPayload payload{}; + payload.generation = material.generation; + payload.token = material.token; + std::vector message(EnrollmentNdefMessageSize()); + std::size_t size = 0; + assert(EncodeEnrollmentNdefMessage(payload, message.data(), message.size(), &size) == EnrollmentNdefStatus::kOk); + assert(DecodeEnrollmentNdefMessage(message.data(), size, &tag) == EnrollmentNdefStatus::kOk); + return true; + }); + } + + uint32_t Wake() const { return publisher.NextWakeMs(bootstrap, clock.now, field); } + BootstrapStatus Tap() { return publisher.OpenPairingWindow(bootstrap); } + BootstrapStatus Prove(const EnrollmentNdefPayload& proof, bool save = true) { + return bootstrap.ValidateAndPersistEnrollmentProof(42, proof.generation, + proof.token.data(), proof.token.size(), [save]() { return save; }); + } +}; + +void TestRepeatedTapsAndFailedIdentitySave() { + Publication device; + device.field = true; + assert(!device.Refresh() && device.Wake() == UINT32_MAX); + assert(device.random.calls == 0 && device.writes == 0); + assert(device.Tap() == BootstrapStatus::kInvalidState); + device.field = false; + assert(device.Refresh()); + assert(device.Wake() == 120000); + const auto first = device.tag; + assert(!device.Refresh() && device.writes == 1); + assert(device.Prove(first) == BootstrapStatus::kInvalidState); + assert(device.Tap() == BootstrapStatus::kOk); + device.field = true; + assert(device.Prove(first) == BootstrapStatus::kOk); + assert(device.Prove(first) == BootstrapStatus::kAlreadyConsumed); + assert(!device.Refresh() && device.Wake() == UINT32_MAX); + device.field = false; + assert(device.Wake() == 0 && device.Refresh()); + assert(device.tag.generation != first.generation && device.tag.token != first.token); + assert(device.Tap() == BootstrapStatus::kOk); + assert(device.Prove(first) == BootstrapStatus::kGenerationMismatch); + assert(device.Prove(device.tag, false) == BootstrapStatus::kStoreError); + assert(device.Prove(device.tag) == BootstrapStatus::kAlreadyConsumed); + assert(device.Refresh() && device.writes == 3); + assert(device.Tap() == BootstrapStatus::kOk && device.Prove(device.tag) == BootstrapStatus::kOk); +} + +void TestPublicationFailureRetriesWithoutAuthorizingStaleBytes() { + Publication device; + assert(device.Refresh()); + const auto old = device.tag; + device.clock.now = 120000; + device.writable = false; + assert(!device.Refresh() && device.Wake() == 1000); + assert(device.writes == 2 && device.tag.token == old.token); + assert(device.Tap() == BootstrapStatus::kInvalidState); + assert(device.Prove(old) == BootstrapStatus::kInvalidState); + device.clock.now += 999; + assert(!device.Refresh() && device.writes == 2 && device.Wake() == 1); + device.clock.now += 1; + device.writable = true; + assert(device.Refresh()); + assert(device.Tap() == BootstrapStatus::kOk); + assert(device.Prove(old) != BootstrapStatus::kOk); + assert(device.Prove(device.tag) == BootstrapStatus::kOk); + + device.random.available = false; + assert(!device.Refresh() && device.Wake() == 1000); + assert(device.Tap() == BootstrapStatus::kInvalidState); + device.clock.now += 1000; + device.random.available = true; + assert(device.Refresh()); + device.bootstrap.Cancel(); + device.publisher.Reset(); + assert(device.Tap() == BootstrapStatus::kInvalidState); + assert(device.Wake() == 0 && device.Refresh()); +} + +void TestHeldFieldExpiryAndMonotonicWrap() { + Publication device({120000, 50000}); + device.clock.now = UINT32_MAX - 60000; + assert(device.Refresh()); + assert(device.Tap() == BootstrapStatus::kOk); + assert(device.Wake() == 50000); + device.clock.now += 49999; + assert(device.Wake() == 1 && !device.Refresh()); + device.field = true; + device.clock.now += 10002; + assert(device.clock.now == 0); + assert(!device.Refresh() && device.Wake() == UINT32_MAX); + assert(device.Prove(device.tag) == BootstrapStatus::kExpired); + assert(!device.Refresh() && device.writes == 1); + device.field = false; + assert(device.Wake() == 0 && device.Refresh()); + assert(device.Tap() == BootstrapStatus::kOk); + assert(device.Prove(device.tag) == BootstrapStatus::kOk); +} void TestRoundTrip() { EnrollmentNdefPayload original{}; @@ -118,6 +250,9 @@ void TestRejections() { } // namespace int main() { + TestRepeatedTapsAndFailedIdentitySave(); + TestPublicationFailureRetriesWithoutAuthorizingStaleBytes(); + TestHeldFieldExpiryAndMonotonicWrap(); TestRoundTrip(); TestRejections(); return 0; diff --git a/tools/test-enrollment-ndef.sh b/tools/test-enrollment-ndef.sh index d08f18b..af8edf2 100755 --- a/tools/test-enrollment-ndef.sh +++ b/tools/test-enrollment-ndef.sh @@ -5,11 +5,13 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT -g++ -std=c++17 -Wall -Wextra -Werror -pedantic \ +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pedantic \ -I"$repo_root/components/zectrix_companion/include" \ "$repo_root/components/zectrix_companion/zectrix_enrollment_ndef.cc" \ + "$repo_root/components/zectrix_companion/zectrix_enrollment_publisher.cc" \ + "$repo_root/components/zectrix_companion/zectrix_pairing_bootstrap.cc" \ "$repo_root/tools/enrollment_ndef_test.cc" \ -o "$tmp_dir/enrollment_ndef_test" "$tmp_dir/enrollment_ndef_test" -echo "PASS: enrollment NDEF v1 codec tests." +echo "PASS: enrollment NDEF codec, publication, repeated taps and RF expiry tests."