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.
-
+## 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 |  |  |
+| **Streamed E-Reader**
• TXT & EPUB streaming
• Algorithmic styling (bold/italic)
• CJK line wrapping & pagination |  |  |
+| **Ambient Sleep Cover**
• Daily calendar & reading snapshot
• Microamp deep-sleep standby
• Landscape, dashboard, or blank |  |  |
+| **Local Web Transfer**
• Drag-and-drop TXT/EPUB upload
• AP hotspot or saved Wi-Fi station
• Auto-shutdown on complete |  |  |
+| **Pocket Tools & Calendar**
• Focus Pomodoro timer
• 1900–2199 Gregorian calendar
• Bounded tally counter |  |  |
+
+### Status Bar Micro-Icon System (E1.10)
+
+The 24px status bar employs 1-bit monochrome micro-icon masks with fixed slots:
+
+
+
+- **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。烧录本固件
> 会替换连接设备上现有的固件,执行烧录命令前请确认设备型号和准确串口。
-
+## 界面与功能展示 (UI & Feature Showcase)
+
+Note4 平台具备算法级点阵排版引擎、模块化场景状态机以及原生中英双语界面支持。所有界面均基于 400x300 1-bit 单色墨水屏像素对齐渲染,兼顾低残影局部刷新与高对比度美学。
+
+| 场景与功能 | 简体中文界面 | English UI |
+| :--- | :---: | :---: |
+| **磁贴仪表盘主屏 (Home Dashboard)**
• 常驻 24px 顶部状态栏
• 阅读进度概览卡片与一键续读
• 快速应用导航磁贴 |  |  |
+| **流式电子书阅读 (Streamed E-Reader)**
• TXT 与 EPUB 原生轻量解析
• 算法级加粗/斜体/衬线排版
• CJK 字符智能避头尾与分页 |  |  |
+| **桌面待机画报 (Ambient Sleep Cover)**
• 日历日期与最新阅读进度仪表盘
• 灵感格言与微安级掉电休眠
• 支持仪表盘/风景画报/纯白留白 |  |  |
+| **局域网 Web 传书 (Local Web Transfer)**
• 浏览器拖拽上传 TXT/EPUB
• 临时便携热点或已有 Wi-Fi 局域网
• 传书结束无线射频自动断电 |  |  |
+| **随身极客工具箱 (Pocket Tools)**
• 静音番茄专注钟
• 1900–2199 离线万年历
• 防误触计数器与重置撤销 |  |  |
+
+### 状态栏微型图标系统 (E1.10)
+
+顶部 24px 状态栏全面采用去文本化纯点阵图形设计,划定绝对固定槽位,杜绝状态变迁时的邻近元素抖动:
+
+
+
+- **蓝牙 (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."