Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .github/workflows/matter-firmware.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion components/app_config/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 6 additions & 11 deletions components/app_config/Kconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
2 changes: 1 addition & 1 deletion components/mqtt_manager/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
151 changes: 74 additions & 77 deletions components/pn532/pn532.c
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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)
{
Expand All @@ -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.
*
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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 */
}

/*
Expand Down Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion components/web_server/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Loading
Loading