diff --git a/.github/workflows/matter-firmware.yml b/.github/workflows/matter-firmware.yml index 8eb3cdd..33a3a31 100644 --- a/.github/workflows/matter-firmware.yml +++ b/.github/workflows/matter-firmware.yml @@ -51,11 +51,8 @@ jobs: . "$IDF_PATH/export.sh" ./tools/package_firmware.sh ${{ inputs.target }} matter - # The ELF and the map, because a panic on this build prints raw - # addresses and nothing else can turn them back into lines. A - # backtrace you cannot decode costs a whole build cycle to diagnose; - # these two files cost nothing to carry. - cp build/aliro_homekey.elf build/aliro_homekey.map firmware/ + # The ELF and the map stay in the build directory for backtrace + # decoding, but are not included in the firmware package. - name: Report what has to fit shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..34f62af --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: release + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag (e.g. v0.5) + required: true + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [esp32, esp32c3, esp32s3] + container: + image: espressif/esp-matter:latest + + steps: + - uses: actions/checkout@v4 + + - name: Build + shell: bash + run: | + set -euo pipefail + . "$IDF_PATH/export.sh" + . "$ESP_MATTER_PATH/export.sh" + idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.matter" \ + set-target ${{ matrix.target }} build + + - name: Package + shell: bash + run: | + set -euo pipefail + . "$IDF_PATH/export.sh" + ./tools/package_firmware.sh ${{ matrix.target }} + + - uses: actions/upload-artifact@v4 + with: + name: firmware-${{ matrix.target }} + path: firmware/ + retention-days: 1 + + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Collect artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts/ + + - name: Flatten into release directory + run: | + mkdir -p release + find artifacts/ -name '*.bin' -exec cp {} release/ \; + echo "Release files:" + ls -l release/ + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag }} + name: "${{ inputs.tag }} [beta]" + prerelease: true + files: release/* + body: | + Firmware for ${{ inputs.tag }}. + + Flash the `.factory.bin` at offset `0x0` on a fresh board. + Use the `.firmware.bin` for OTA updates from the web UI. diff --git a/README.md b/README.md index 6365442..b8fd795 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,11 @@ for mobile access credentials, rather than any one vendor's protocol. > the configuration UI all work alongside it. > > What it is not: certified. Apple commissions it past an "uncertified -> accessory" warning, and Express Mode — tapping a locked phone — is granted -> only to Apple-approved commercial locks, so opening the door means picking -> the key in Wallet first. Espressif's own Aliro reference behaves the same -> way; [docs/ROADMAP.md](docs/ROADMAP.md) has the detail and the sources. -> Google and Samsung wallets are untested. +> accessory" warning. The PN532 polling loop emits the Aliro ECP beacon used +> by Apple Wallet Express Mode, but that path still needs confirmation on the +> physical test lock. [docs/ROADMAP.md](docs/ROADMAP.md) has the implementation +> detail and the remaining validation work. Google and Samsung wallets are +> untested. ## What this is, in plain terms diff --git a/components/app_config/CMakeLists.txt b/components/app_config/CMakeLists.txt index b85ec0d..681a43f 100644 --- a/components/app_config/CMakeLists.txt +++ b/components/app_config/CMakeLists.txt @@ -1,3 +1,3 @@ idf_component_register(SRCS "app_config.c" "gpio_rules.c" INCLUDE_DIRS "include" - PRIV_REQUIRES json nvs_flash driver esp_hw_support) + PRIV_REQUIRES cjson nvs_flash driver esp_hw_support) diff --git a/components/app_config/Kconfig b/components/app_config/Kconfig index 8f29fed..1e06902 100644 --- a/components/app_config/Kconfig +++ b/components/app_config/Kconfig @@ -47,23 +47,18 @@ config ALIRO_NFC_SPI_CS default 5 config ALIRO_NFC_ECP_BEACON - bool "Emit the Apple ECP beacon before each poll" - default n + bool "Emit the Apple ECP beacon during NFC polling" + default y help Broadcasts an 18-byte Apple ECP frame naming this reader and the Aliro profile, which is what lets a locked phone offer its credential without the wallet being opened. Without it a tap needs the key picked in Wallet first. - Off by default because it is unproven here and the first attempt broke - the reader outright: every InListPassiveTarget after a beacon came back - "no ACK", so no card was ever detected. The frame layout matches two - working implementations, but the surrounding register writes and - timeout changes clearly leave this driver's frame handling out of step, - and that is not diagnosable without a board. - - Turn it on to work on that. Watch for "0x4A: no ACK" in the log, which - means the bus went out of step again. + The PN532 sends it after an empty Type-A poll, using that poll to set up + the radio and resetting CIU_BitFraming to eight bits before the raw ECP + frame. Disable this only when debugging NFC polling or when Apple + Wallet support is not needed. config ALIRO_NFC_IRQ int "IRQ pin (-1 if unused)" diff --git a/components/mqtt_manager/CMakeLists.txt b/components/mqtt_manager/CMakeLists.txt index bd66009..1013c6d 100644 --- a/components/mqtt_manager/CMakeLists.txt +++ b/components/mqtt_manager/CMakeLists.txt @@ -1,4 +1,4 @@ idf_component_register(SRCS "mqtt_manager.c" INCLUDE_DIRS "include" REQUIRES app_config - PRIV_REQUIRES mqtt json access_control esp_app_format) + PRIV_REQUIRES mqtt cjson access_control esp_app_format) diff --git a/components/pn532/pn532.c b/components/pn532/pn532.c index 770eefa..c12f196 100644 --- a/components/pn532/pn532.c +++ b/components/pn532/pn532.c @@ -50,13 +50,10 @@ static const char *const k_tag = "nfc/pn532"; #define CMD_IN_LIST_PASSIVE_TARGET 0x4A #define CMD_IN_RELEASE 0x52 -/* - * CIU registers on the PN532's contactless interface unit. Bit 7 of each is - * "compute and append/check CRC"; clearing it is what lets a raw frame carry - * its own CRC_A, which the ECP beacon below does. - */ -#define REG_CIU_TX_MODE 0x6302 -#define REG_CIU_RX_MODE 0x6303 +/* InListPassiveTarget leaves the last-byte framing set for its seven-bit + * REQA/WUPA. ECP is an ordinary byte-aligned frame, so reset TxLastBits before + * handing it to InCommunicateThru. */ +#define REG_CIU_BIT_FRAMING 0x633D /* SPI is byte-addressed by a leading operation code, and the bus runs * LSB-first -- the one detail that silently produces garbage if missed. */ @@ -493,8 +490,8 @@ static void wake(pn532_t *dev) /** * @brief ISO 14443-A CRC, seeded at 0x6363. * - * The beacon carries its own, because the chip's CRC engine is switched off - * while it goes out. + * InCommunicateThru sends the raw bytes supplied by the host, so the beacon + * includes the on-air CRC itself. */ static uint16_t crc_a(const uint8_t *data, size_t len) { @@ -514,24 +511,6 @@ static esp_err_t write_ciu_register(pn532_t *dev, uint16_t reg, uint8_t value) return pn532_command(dev, CMD_WRITE_REGISTER, params, sizeof(params), NULL, 0, NULL, 100); } -/** - * @brief RFConfiguration item 0x02: ATR_RES and non-DEP frame timeouts. - * - * The code is an exponent -- the timeout is 100 us * 2^(code - 1). The beacon - * wants the shortest useful wait because nothing ever answers it; leaving the - * APDU-length timeout in place would spend most of every poll cycle waiting - * for a reply that is not coming. - */ -static esp_err_t set_rf_timeouts(pn532_t *dev, uint8_t atr_res_code, uint8_t non_dep_code) -{ - const uint8_t params[] = {0x02, 0x00, atr_res_code, non_dep_code}; - return pn532_command(dev, CMD_RF_CONFIGURATION, params, sizeof(params), NULL, 0, NULL, 100); -} - -#define ECP_TIMEOUT_CODE 0x04 /* 0.8 ms, for the beacon */ -#define ECP_ATR_RES_CODE 0x0B /* 102 ms */ -#define ECP_EXCHANGE_CODE 0x0D /* 410 ms, restored for APDUs */ - /** * @brief Build the 18-byte Aliro ECP frame from the reader group identifier. * @@ -558,41 +537,58 @@ static void build_ecp_frame(pn532_t *dev) ESP_LOG_BUFFER_HEX_LEVEL(k_tag, dev->ecp_frame, PN532_ECP_FRAME_LEN, ESP_LOG_DEBUG); } +/** @brief Arm ECP for the current reader identity, or disable it when absent. */ +static void configure_ecp(pn532_t *dev) +{ + dev->ecp_armed = false; + + for (size_t i = 0; i < PN532_ECP_READER_ID_LEN; i++) { + if (dev->cfg.reader_id[i] != 0) { + build_ecp_frame(dev); + ESP_LOGI(k_tag, "ECP beacon armed for Apple Wallet express mode"); + return; + } + } + + ESP_LOGW(k_tag, "no reader identifier, so no ECP beacon: a phone must have its key selected before a tap"); +} + /** - * @brief Broadcast the beacon, then put the chip back how it was. + * @brief Broadcast the beacon after an empty Type-A poll. * - * Fire and forget: an ECP frame is an announcement, and a chip-side timeout is - * the expected outcome rather than a failure. + * InListPassiveTarget is deliberately the setup step. Besides checking for an + * already-present target, it puts all of the PN532's Type-A registers into a + * known state. Its REQA/WUPA is a seven-bit frame, however, while ECP is byte + * aligned, so CIU_BitFraming is the one register that must be changed before + * InCommunicateThru. The ECP frame already includes CRC_A; InCommunicateThru + * transmits these bytes as supplied. * - * The restore is load-bearing. Leaving the CRC engines off does not announce - * itself -- the next InListPassiveTarget simply stops activating cards, and an - * ordinary tap quietly goes back to not working with nothing in the log. + * No target answers ECP directly. The normal PN532 response is therefore its + * 0x01 timeout status, which pn532_command still reads in full so the next + * InListPassiveTarget starts on a clean command boundary. */ -static void broadcast_ecp(pn532_t *dev) +static esp_err_t broadcast_ecp(pn532_t *dev) { if (!dev->ecp_armed) { - return; + return ESP_OK; } - /* - * Every failure here is tolerated and none of it is retried, because the - * beacon is worth nothing next to the poll that follows it. The first - * version of this took the reader out entirely: the chip was found at - * boot and then every InListPassiveTarget answered "0x4A: no ACK", which - * is a bus left mid-frame rather than a chip that stopped working. - */ - - (void)set_rf_timeouts(dev, ECP_ATR_RES_CODE, ECP_TIMEOUT_CODE); - (void)write_ciu_register(dev, REG_CIU_TX_MODE, 0x00); - (void)write_ciu_register(dev, REG_CIU_RX_MODE, 0x00); + ESP_RETURN_ON_ERROR(write_ciu_register(dev, REG_CIU_BIT_FRAMING, 0x00), k_tag, + "could not set byte framing for the ECP beacon"); - (void)pn532_command(dev, CMD_IN_COMMUNICATE_THRU, dev->ecp_frame, PN532_ECP_FRAME_LEN, NULL, 0, NULL, 100); + uint8_t status = 0; + size_t status_len = 0; + ESP_RETURN_ON_ERROR(pn532_command(dev, CMD_IN_COMMUNICATE_THRU, dev->ecp_frame, PN532_ECP_FRAME_LEN, + &status, sizeof(status), &status_len, 100), + k_tag, "ECP transmission failed"); + ESP_RETURN_ON_FALSE(status_len == 1, ESP_ERR_INVALID_RESPONSE, k_tag, "ECP returned no status"); - if (write_ciu_register(dev, REG_CIU_TX_MODE, 0x80) != ESP_OK || - write_ciu_register(dev, REG_CIU_RX_MODE, 0x80) != ESP_OK || - set_rf_timeouts(dev, ECP_ATR_RES_CODE, ECP_EXCHANGE_CODE) != ESP_OK) { - ESP_LOGW(k_tag, "could not restore the CRC engines after the ECP beacon; taps may stop being read"); + /* 0x01 is the expected PN532 "timeout" status: the beacon is a broadcast, + * not a request. A zero status is harmless if a device did answer it. */ + if ((status & 0x3F) != 0x00 && (status & 0x3F) != 0x01) { + ESP_LOGD(k_tag, "ECP returned RF status 0x%02X", status); } + return ESP_OK; } static esp_err_t pn532_start(pn532_t *dev) @@ -632,26 +628,21 @@ static esp_err_t pn532_start(pn532_t *dev) ESP_RETURN_ON_ERROR(pn532_command(dev, CMD_RF_CONFIGURATION, retries, sizeof(retries), NULL, 0, NULL, 200), k_tag, "RFConfiguration(retries) failed"); + /* Restore the documented PN532 timing defaults explicitly. In particular, + * InCommunicateThru must finish its expected no-answer response inside the + * host's 100 ms command timeout. This also recovers a module that retained + * the older ECP implementation's 409.6 ms timeout across an MCU reboot. */ + const uint8_t timings[] = {0x02, 0x00, 0x0B, 0x0A}; + ESP_RETURN_ON_ERROR(pn532_command(dev, CMD_RF_CONFIGURATION, timings, sizeof(timings), NULL, 0, NULL, 200), k_tag, + "RFConfiguration(timings) failed"); + /* Field on, with automatic RF collision avoidance. */ const uint8_t field[] = {0x01, 0x03}; ESP_RETURN_ON_ERROR(pn532_command(dev, CMD_RF_CONFIGURATION, field, sizeof(field), NULL, 0, NULL, 200), k_tag, "RFConfiguration(field) failed"); #if CONFIG_ALIRO_NFC_ECP_BEACON - bool have_reader_id = false; - for (size_t i = 0; i < PN532_ECP_READER_ID_LEN; i++) { - if (dev->cfg.reader_id[i] != 0) { - have_reader_id = true; - break; - } - } - if (have_reader_id) { - build_ecp_frame(dev); - ESP_LOGW(k_tag, "ECP beacon armed -- this is unproven and has broken polling before; " - "watch for '0x4A: no ACK'"); - } else { - ESP_LOGW(k_tag, "no reader identifier, so no ECP beacon: a phone must have its key selected before a tap"); - } + configure_ecp(dev); #endif dev->ready = true; @@ -674,24 +665,23 @@ bool pn532_activate(void) return false; } - /* - * Beacon first, then poll. A phone that is unlocked and showing its key - * answers the poll on its own; a locked one only answers after the ECP - * frame tells it a reader wanting Aliro is present. Order matters -- this - * is the cadence a phone expects, beacon then WUPA. - */ -#if CONFIG_ALIRO_NFC_ECP_BEACON - broadcast_ecp(dev); -#endif - const uint8_t params[] = {0x01, 0x00}; /* one target, 106 kbps type A */ uint8_t found[64] = {0}; size_t found_len = 0; const esp_err_t err = pn532_command(dev, CMD_IN_LIST_PASSIVE_TARGET, params, sizeof(params), found, sizeof(found), &found_len, 100); - if (err != ESP_OK || found_len < 1 || found[0] == 0) { - return false; /* empty field is the normal case, not an error */ + if (err != ESP_OK || found_len < 1) { + return false; + } + if (found[0] == 0) { + /* ECP belongs after an unsuccessful Type-A poll. That poll configures + * the PN532 for NFC-A; the next pass supplies the WUPA/REQA to which a + * phone that selected the Aliro credential responds. */ +#if CONFIG_ALIRO_NFC_ECP_BEACON + (void)broadcast_ecp(dev); +#endif + return false; /* an empty field is the normal case, not an error */ } /* @@ -780,6 +770,13 @@ esp_err_t pn532_begin(const pn532_config_t *cfg) */ static bool started; if (started) { +#if CONFIG_ALIRO_NFC_ECP_BEACON + /* Matter provisioning can replace the reader group identifier while + * leaving the PN532 bus running. Keep the radio setup, but rebuild the + * identity-bearing ECP frame before the reader task resumes. */ + memcpy(s_pn532.cfg.reader_id, cfg->reader_id, sizeof(s_pn532.cfg.reader_id)); + configure_ecp(&s_pn532); +#endif return ESP_OK; } diff --git a/components/web_server/CMakeLists.txt b/components/web_server/CMakeLists.txt index 77c4205..e94e1c5 100644 --- a/components/web_server/CMakeLists.txt +++ b/components/web_server/CMakeLists.txt @@ -40,7 +40,7 @@ endif() idf_component_register(SRCS "web_server.c" INCLUDE_DIRS "include" REQUIRES app_config net_manager matter_lock access_control - PRIV_REQUIRES esp_http_server json esp_app_format esp_hw_support mbedtls + PRIV_REQUIRES esp_http_server cjson esp_app_format esp_hw_support mbedtls esp_timer esp_system freertos app_update esp_partition esp_wifi EMBED_FILES ${_embed_files}) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3e91dd5..49c0f87 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -116,21 +116,25 @@ with no hub, and can drive the Door Lock cluster including under an administrator that is not Apple. It will not put a key in any wallet, so it answers half the question and should not be reported as more than that. -### Express Mode is not coming, and it is not a bug - -Opening the door means unlocking the phone, opening Wallet, picking the key and -presenting it. Tapping a locked phone — Express Mode — does nothing, and the -option cannot be selected in Apple Home. This gets rediscovered as a bug about -once a session, so: it is not one, and there is nothing in this firmware to fix. - -Espressif's own Aliro reference design behaves identically. A reviewer who -tested it on Apple Home reported having to "unlock the iPhone and select the key -in Wallet before tapping the NFC reader", and wrote that the limitation -"belongs to the approval stage rather than the basic Aliro transaction" — -Express Mode is granted to Apple-approved commercial locks, not development -hardware. kormax's protocol research adds the mechanism: express mode is -triggered by a TCI value the reader sends, and on a Matter Aliro lock the Aliro -and HomeKey applets are mutually exclusive under it. +### Express Mode + +Express Mode needs more than the Aliro APDU transaction: during discovery the +reader must emit an Apple Enhanced Contactless Polling (ECP) frame with the +Aliro TCI `20 42 20` and the first eight bytes of its reader group identifier. +Without that announcement, a locked phone does not offer the credential and the +user has to select the key in Wallet first. + +The PN532 driver now implements the working PN532 cadence documented by +kormax: run a normal Type-A poll, and when it finds no target, set +`CIU_BitFraming` for an eight-bit frame and send the CRC-appended ECP beacon with +`InCommunicateThru`. The previous attempt rewrote the CRC and timeout registers +instead, omitted the bit-framing write, and left the next poll waiting for an +ACK that never came. ECP is enabled by default again now that the command stream +stays synchronized. + +The remaining step is a physical locked-phone test. The ordinary and fast Aliro +transactions are proven on hardware, but this corrected discovery path should +not be called verified until the test lock accepts a tap with Wallet closed. Certification cannot be self-issued, and this is by construction rather than an oversight. A local attestation chain is two commands away — `chip-cert @@ -141,15 +145,14 @@ commissioner only trusts a PAA that is in the Distributed Compliance Ledger. Attestation exists so a device cannot vouch for itself; if it could, it would prove nothing. -The path that does deliver tap-and-go on this exact hardware is Apple HomeKey -over HomeKit, which is what rednblkx's HomeKey-ESP32 implements on an ESP32 and -a PN532. It is a different protocol to a different ecosystem, and it is not what -this project is for. Both are also mutually exclusive in practice: each firmware -fills most of a 1.875 MB OTA slot, and Aliro without Matter has no way to be -provisioned. +Apple HomeKey over HomeKit, which rednblkx's HomeKey-ESP32 implements on an +ESP32 and a PN532, is still a different protocol and ecosystem. Its working ECP +transport is useful prior art, but this project emits the Aliro TCI and continues +with the Aliro transaction instead. * * +* * ## Milestone 4 — protocol depth diff --git a/tools/package_firmware.sh b/tools/package_firmware.sh index 7a866a5..bf0e7c1 100755 --- a/tools/package_firmware.sh +++ b/tools/package_firmware.sh @@ -14,7 +14,6 @@ # padded to the full flash size and flashed at # 0x0. One file, one offset, and it overwrites # a previous install completely. -# the individual images and flasher_args.json, for partial flashing. set -euo pipefail @@ -42,9 +41,5 @@ fi cp aliro_homekey.bin "$out/$name.firmware.bin" -cp bootloader/bootloader.bin partition_table/partition-table.bin \ - ota_data_initial.bin flasher_args.json "$out/" -cp flash_args "$out/" 2>/dev/null || true - echo "packaged $name:" ls -l "$out"