commit 9aa14718c9fab29f9240a112df535b1aa65cfcb4
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-03T21:39:40Z |
| subject | Initial HomeKey reader prototype |
commit 9aa14718c9fab29f9240a112df535b1aa65cfcb4
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-03T21:39:40Z
Initial HomeKey reader prototype
---
.gitignore | 7 +
README.md | 69 +++++++
custom_components/homekey_reader/__init__.py | 20 ++
custom_components/homekey_reader/config_flow.py | 99 ++++++++++
custom_components/homekey_reader/const.py | 5 +
custom_components/homekey_reader/manifest.json | 16 ++
custom_components/homekey_reader/sensor.py | 136 ++++++++++++++
custom_components/homekey_reader/strings.json | 22 +++
.../homekey_reader/translations/en.json | 22 +++
esp32/CMakeLists.txt | 4 +
esp32/main/CMakeLists.txt | 5 +
esp32/main/main.c | 208 +++++++++++++++++++++
esp32/sdkconfig.defaults | 6 +
hacs.json | 4 +
14 files changed, 623 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3d72984
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+# ESP-IDF
+esp32/build/
+esp32/sdkconfig
+esp32/sdkconfig.old
+
+# macOS
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..220eccc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,69 @@
+# HomeKey Reader Relay
+`PN532 -> ESP32-C3 (this part) -> Home Assistant`
+
+In development. NOT FOR USE!
+
+## Home Assistant UI setup
+
+HACS
+
+1. Settings -> Devices & services -> Add Integration.
+2. Search for `HomeKey Reader`.
+3. Pick the advertising ESP32-C3 reader.
+
+Because the manifest also contains a Bluetooth matcher, Home Assistant can offer
+the reader automatically when it sees the service-data advertisement.
+
+
+## Install toolchain
+
+```
+brew install libgcrypt glib pixman sdl2 libslirp dfu-util cmake python
+
+brew tap espressif/eim
+brew install eim
+
+eim install
+
+source "$HOME/.espressif/tools/activate_idf_v6.x.x.sh"
+```
+
+### Flash the firmware
+
+```sh
+cd esp32
+idf.py set-target esp32c3
+idf.py -p /dev/cu.usbmodem101 flash monitor
+```
+
+## Mock Flow
+
+```text
+ESP32-C3
+ mock PN532 UID (DE:AD:BE:EF)
+ |
+ v
+BLE service-data advertisement
+ |
+ v
+Home Assistant Bluetooth
+ |
+ +--> HomeKey Reader config entry
+ +--> sensor: Last NFC UID
+ +--> event: homekey_reader_packet
+```
+
+Service UUID:
+
+```text
+7b1e0001-7b1e-4a11-9abc-0123456789ab
+```
+
+Advertisement payload after the UUID:
+
+```text
+byte 0 protocol version = 1
+byte 1 message type = 1 (mock PN532 tag)
+byte 2..3 sequence, uint16 little-endian
+byte 4..7 mock UID = DE AD BE EF
+```
diff --git a/custom_components/homekey_reader/__init__.py b/custom_components/homekey_reader/__init__.py
new file mode 100644
index 0000000..a15f2be
--- /dev/null
+++ b/custom_components/homekey_reader/__init__.py
@@ -0,0 +1,20 @@
+"""HomeKey Reader integration."""
+
+from __future__ import annotations
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+PLATFORMS = [Platform.SENSOR]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up HomeKey Reader from a config entry."""
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a HomeKey Reader config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/custom_components/homekey_reader/config_flow.py b/custom_components/homekey_reader/config_flow.py
new file mode 100644
index 0000000..a28b511
--- /dev/null
+++ b/custom_components/homekey_reader/config_flow.py
@@ -0,0 +1,99 @@
+"""Config flow for HomeKey Reader."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components import bluetooth
+from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS
+
+from .const import DOMAIN, SERVICE_UUID
+
+
+class HomeKeyReaderConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for HomeKey Reader."""
+
+ VERSION = 1
+
+ def __init__(self) -> None:
+ self._discovery_info: BluetoothServiceInfoBleak | None = None
+ self._discovered: dict[str, BluetoothServiceInfoBleak] = {}
+
+ async def async_step_bluetooth(
+ self, discovery_info: BluetoothServiceInfoBleak
+ ) -> ConfigFlowResult:
+ """Handle Bluetooth discovery."""
+ await self.async_set_unique_id(discovery_info.address)
+ self._abort_if_unique_id_configured()
+
+ self._discovery_info = discovery_info
+ name = discovery_info.name or discovery_info.address
+ self.context["title_placeholders"] = {"name": name}
+ return await self.async_step_bluetooth_confirm()
+
+ async def async_step_bluetooth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm a Bluetooth-discovered reader."""
+ assert self._discovery_info is not None
+
+ if user_input is not None:
+ return self.async_create_entry(
+ title=self._discovery_info.name or "HomeKey Reader",
+ data={CONF_ADDRESS: self._discovery_info.address},
+ )
+
+ self._set_confirm_only()
+ return self.async_show_form(
+ step_id="bluetooth_confirm",
+ description_placeholders={
+ "name": self._discovery_info.name or self._discovery_info.address,
+ "address": self._discovery_info.address,
+ },
+ )
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Let the user choose a currently visible reader."""
+ if user_input is not None:
+ address = user_input[CONF_ADDRESS]
+ info = self._discovered[address]
+
+ await self.async_set_unique_id(address, raise_on_progress=False)
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(
+ title=info.name or "HomeKey Reader",
+ data={CONF_ADDRESS: address},
+ )
+
+ configured = self._async_current_ids(include_ignore=False)
+ wanted_uuid = SERVICE_UUID.lower()
+
+ for info in bluetooth.async_discovered_service_info(
+ self.hass, connectable=False
+ ):
+ service_data_uuids = {uuid.lower() for uuid in info.service_data}
+ if wanted_uuid not in service_data_uuids:
+ continue
+ if info.address in configured:
+ continue
+ self._discovered[info.address] = info
+
+ if not self._discovered:
+ return self.async_abort(reason="no_devices_found")
+
+ choices = {
+ address: f"{info.name or 'HomeKey Reader'} ({address})"
+ for address, info in self._discovered.items()
+ }
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(choices)}),
+ )
diff --git a/custom_components/homekey_reader/const.py b/custom_components/homekey_reader/const.py
new file mode 100644
index 0000000..e51805c
--- /dev/null
+++ b/custom_components/homekey_reader/const.py
@@ -0,0 +1,5 @@
+"""Constants for HomeKey Reader."""
+
+DOMAIN = "homekey_reader"
+SERVICE_UUID = "7b1e0001-7b1e-4a11-9abc-0123456789ab"
+EVENT_PACKET = "homekey_reader_packet"
diff --git a/custom_components/homekey_reader/manifest.json b/custom_components/homekey_reader/manifest.json
new file mode 100644
index 0000000..bc56041
--- /dev/null
+++ b/custom_components/homekey_reader/manifest.json
@@ -0,0 +1,16 @@
+{
+ "domain": "homekey_reader",
+ "name": "HomeKey Reader",
+ "version": "0.2.0",
+ "config_flow": true,
+ "dependencies": ["bluetooth_adapters"],
+ "bluetooth": [
+ {
+ "service_data_uuid": "7b1e0001-7b1e-4a11-9abc-0123456789ab",
+ "connectable": false
+ }
+ ],
+ "codeowners": [],
+ "integration_type": "device",
+ "iot_class": "local_push"
+}
diff --git a/custom_components/homekey_reader/sensor.py b/custom_components/homekey_reader/sensor.py
new file mode 100644
index 0000000..12543ee
--- /dev/null
+++ b/custom_components/homekey_reader/sensor.py
@@ -0,0 +1,136 @@
+"""Sensor platform for HomeKey Reader."""
+
+from __future__ import annotations
+
+import logging
+
+from homeassistant.components import bluetooth
+from homeassistant.components.sensor import SensorEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_ADDRESS
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import DOMAIN, EVENT_PACKET, SERVICE_UUID
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up the HomeKey Reader sensor."""
+ async_add_entities([HomeKeyReaderUidSensor(hass, entry)])
+
+
+class HomeKeyReaderUidSensor(SensorEntity):
+ """Last NFC UID received from one HomeKey Reader."""
+
+ _attr_has_entity_name = True
+ _attr_name = "Last NFC UID"
+ _attr_icon = "mdi:nfc"
+
+ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ self.hass = hass
+ self._entry = entry
+ self._address = entry.data[CONF_ADDRESS]
+ self._attr_unique_id = f"{self._address}_last_uid"
+ self._attr_native_value = None
+ self._attr_available = False
+ self._attrs: dict[str, object] = {}
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._address)},
+ name=entry.title,
+ manufacturer="HomeKey Reader",
+ model="ESP32-C3 + PN532",
+ )
+
+ @property
+ def extra_state_attributes(self) -> dict[str, object]:
+ return self._attrs
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to Bluetooth advertisements."""
+
+ @callback
+ def _packet_seen(
+ service_info: bluetooth.BluetoothServiceInfoBleak,
+ change: bluetooth.BluetoothChange,
+ ) -> None:
+ payload = service_info.service_data.get(SERVICE_UUID)
+ if payload is None:
+ return
+
+ if len(payload) < 8:
+ _LOGGER.warning(
+ "Short HomeKey Reader packet from %s: %s",
+ service_info.address,
+ payload.hex(),
+ )
+ return
+
+ version = payload[0]
+ msg_type = payload[1]
+ seq = int.from_bytes(payload[2:4], "little")
+ data = payload[4:]
+
+ if version != 1 or msg_type != 1 or len(data) < 4:
+ _LOGGER.warning(
+ "Unsupported HomeKey Reader packet from %s: %s",
+ service_info.address,
+ payload.hex(),
+ )
+ return
+
+ uid = ":".join(f"{byte:02X}" for byte in data[:4])
+
+ self._attr_native_value = uid
+ self._attr_available = True
+ self._attrs = {
+ "address": service_info.address,
+ "rssi": service_info.rssi,
+ "sequence": seq,
+ "protocol_version": version,
+ "message_type": msg_type,
+ "raw": payload.hex(),
+ }
+ self.async_write_ha_state()
+
+ self.hass.bus.async_fire(
+ EVENT_PACKET,
+ {
+ "uid": uid,
+ **self._attrs,
+ },
+ )
+
+ @callback
+ def _unavailable(_service_info: bluetooth.BluetoothServiceInfoBleak) -> None:
+ self._attr_available = False
+ self.async_write_ha_state()
+
+ self.async_on_remove(
+ bluetooth.async_register_callback(
+ self.hass,
+ _packet_seen,
+ {
+ "address": self._address,
+ "service_data_uuid": SERVICE_UUID,
+ "connectable": False,
+ },
+ bluetooth.BluetoothScanningMode.ACTIVE,
+ replay=bluetooth.BluetoothCallbackReplay.NEWEST_FIRST,
+ )
+ )
+ self.async_on_remove(
+ bluetooth.async_track_unavailable(
+ self.hass,
+ _unavailable,
+ self._address,
+ connectable=False,
+ )
+ )
diff --git a/custom_components/homekey_reader/strings.json b/custom_components/homekey_reader/strings.json
new file mode 100644
index 0000000..b3f286e
--- /dev/null
+++ b/custom_components/homekey_reader/strings.json
@@ -0,0 +1,22 @@
+{
+ "title": "HomeKey Reader",
+ "config": {
+ "step": {
+ "user": {
+ "title": "Select a HomeKey Reader",
+ "description": "Choose a currently visible reader.",
+ "data": {
+ "address": "Reader"
+ }
+ },
+ "bluetooth_confirm": {
+ "title": "Set up {name}",
+ "description": "Set up HomeKey Reader {name} at {address}?"
+ }
+ },
+ "abort": {
+ "already_configured": "This reader is already configured.",
+ "no_devices_found": "No HomeKey Reader is currently visible over Bluetooth."
+ }
+ }
+}
diff --git a/custom_components/homekey_reader/translations/en.json b/custom_components/homekey_reader/translations/en.json
new file mode 100644
index 0000000..b3f286e
--- /dev/null
+++ b/custom_components/homekey_reader/translations/en.json
@@ -0,0 +1,22 @@
+{
+ "title": "HomeKey Reader",
+ "config": {
+ "step": {
+ "user": {
+ "title": "Select a HomeKey Reader",
+ "description": "Choose a currently visible reader.",
+ "data": {
+ "address": "Reader"
+ }
+ },
+ "bluetooth_confirm": {
+ "title": "Set up {name}",
+ "description": "Set up HomeKey Reader {name} at {address}?"
+ }
+ },
+ "abort": {
+ "already_configured": "This reader is already configured.",
+ "no_devices_found": "No HomeKey Reader is currently visible over Bluetooth."
+ }
+ }
+}
diff --git a/esp32/CMakeLists.txt b/esp32/CMakeLists.txt
new file mode 100644
index 0000000..9aa2abf
--- /dev/null
+++ b/esp32/CMakeLists.txt
@@ -0,0 +1,4 @@
+cmake_minimum_required(VERSION 3.16)
+
+include($ENV{IDF_PATH}/tools/cmake/project.cmake)
+project(homekey_reader)
diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt
new file mode 100644
index 0000000..f6903aa
--- /dev/null
+++ b/esp32/main/CMakeLists.txt
@@ -0,0 +1,5 @@
+idf_component_register(
+ SRCS "main.c"
+ INCLUDE_DIRS "."
+ REQUIRES bt nvs_flash
+)
diff --git a/esp32/main/main.c b/esp32/main/main.c
new file mode 100644
index 0000000..c5d1103
--- /dev/null
+++ b/esp32/main/main.c
@@ -0,0 +1,208 @@
+#include <assert.h>
+#include <stdint.h>
+#include <string.h>
+
+#include "esp_err.h"
+#include "esp_log.h"
+#include "nvs_flash.h"
+
+#include "nimble/nimble_port.h"
+#include "nimble/nimble_port_freertos.h"
+#include "host/ble_gap.h"
+#include "host/ble_hs.h"
+#include "host/util/util.h"
+#include "services/gap/ble_svc_gap.h"
+
+#define DEVICE_NAME "homekey-reader"
+#define ADV_DURATION_MS 2000
+
+/*
+ * Canonical UUID:
+ * 7b1e0001-7b1e-4a11-9abc-0123456789ab
+ *
+ * BLE advertising puts 128-bit UUIDs on the wire least-significant byte first.
+ */
+static const uint8_t service_uuid_le[16] = {
+ 0xab, 0x89, 0x67, 0x45,
+ 0x23, 0x01, 0xbc, 0x9a,
+ 0x11, 0x4a, 0x1e, 0x7b,
+ 0x01, 0x00, 0x1e, 0x7b,
+};
+
+static const char *TAG = "homekey-reader";
+static uint8_t own_addr_type;
+static uint16_t mock_seq;
+
+static void start_advertising(void);
+
+static int gap_event(struct ble_gap_event *event, void *arg)
+{
+ switch (event->type) {
+ case BLE_GAP_EVENT_CONNECT:
+ if (event->connect.status == 0) {
+ ESP_LOGI(TAG, "BLE central connected, handle=%u",
+ event->connect.conn_handle);
+ } else {
+ ESP_LOGW(TAG, "BLE connection failed, status=%d",
+ event->connect.status);
+ start_advertising();
+ }
+ return 0;
+
+ case BLE_GAP_EVENT_DISCONNECT:
+ ESP_LOGI(TAG, "BLE central disconnected, reason=%d",
+ event->disconnect.reason);
+ start_advertising();
+ return 0;
+
+ case BLE_GAP_EVENT_ADV_COMPLETE:
+ /*
+ * Change the sequence number so Home Assistant sees changing
+ * service data instead of deduplicating identical advertisements.
+ */
+ mock_seq++;
+ start_advertising();
+ return 0;
+
+ default:
+ return 0;
+ }
+}
+
+static void build_adv_data(uint8_t adv[29])
+{
+ size_t p = 0;
+
+ /* Flags AD structure: general discoverable, BR/EDR not supported. */
+ adv[p++] = 0x02;
+ adv[p++] = 0x01;
+ adv[p++] = 0x06;
+
+ /*
+ * 128-bit Service Data AD structure (type 0x21):
+ *
+ * UUID[16]
+ * version[1] = 1
+ * type[1] = 1 (mock PN532 tag)
+ * seq[2] = little endian
+ * data[4] = fake NFCID1 / UID: DE:AD:BE:EF
+ *
+ * AD length is 1 byte type + 16 byte UUID + 8 byte payload = 25.
+ */
+ adv[p++] = 25;
+ adv[p++] = 0x21;
+
+ memcpy(&adv[p], service_uuid_le, sizeof(service_uuid_le));
+ p += sizeof(service_uuid_le);
+
+ adv[p++] = 1; /* protocol version */
+ adv[p++] = 1; /* message type: mock PN532 tag */
+ adv[p++] = (uint8_t)(mock_seq & 0xff);
+ adv[p++] = (uint8_t)(mock_seq >> 8);
+ adv[p++] = 0xde;
+ adv[p++] = 0xad;
+ adv[p++] = 0xbe;
+ adv[p++] = 0xef;
+
+ assert(p == 29);
+}
+
+static void start_advertising(void)
+{
+ uint8_t adv[29];
+ struct ble_gap_adv_params params = {0};
+ int rc;
+
+ build_adv_data(adv);
+
+ rc = ble_gap_adv_set_data(adv, sizeof(adv));
+ if (rc != 0) {
+ ESP_LOGE(TAG, "ble_gap_adv_set_data failed: %d", rc);
+ return;
+ }
+
+ params.conn_mode = BLE_GAP_CONN_MODE_UND;
+ params.disc_mode = BLE_GAP_DISC_MODE_GEN;
+ params.itvl_min = BLE_GAP_ADV_ITVL_MS(250);
+ params.itvl_max = BLE_GAP_ADV_ITVL_MS(300);
+
+ rc = ble_gap_adv_start(
+ own_addr_type,
+ NULL,
+ ADV_DURATION_MS,
+ ¶ms,
+ gap_event,
+ NULL
+ );
+ if (rc != 0) {
+ ESP_LOGE(TAG, "ble_gap_adv_start failed: %d", rc);
+ return;
+ }
+
+ ESP_LOGI(TAG,
+ "advertising mock PN532 packet: seq=%u uid=DE:AD:BE:EF",
+ mock_seq);
+}
+
+static void on_reset(int reason)
+{
+ ESP_LOGE(TAG, "NimBLE reset, reason=%d", reason);
+}
+
+static void on_sync(void)
+{
+ int rc;
+ uint8_t addr[6];
+
+ rc = ble_hs_util_ensure_addr(0);
+ assert(rc == 0);
+
+ rc = ble_hs_id_infer_auto(0, &own_addr_type);
+ assert(rc == 0);
+
+ rc = ble_hs_id_copy_addr(own_addr_type, addr, NULL);
+ assert(rc == 0);
+
+ ESP_LOGI(TAG,
+ "BLE address %02X:%02X:%02X:%02X:%02X:%02X",
+ addr[5], addr[4], addr[3], addr[2], addr[1], addr[0]);
+
+ start_advertising();
+}
+
+static void host_task(void *param)
+{
+ ESP_LOGI(TAG, "NimBLE host started");
+ nimble_port_run();
+ nimble_port_freertos_deinit();
+}
+
+void app_main(void)
+{
+ esp_err_t err;
+ int rc;
+
+ err = nvs_flash_init();
+ if (err == ESP_ERR_NVS_NO_FREE_PAGES ||
+ err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
+ ESP_ERROR_CHECK(nvs_flash_erase());
+ err = nvs_flash_init();
+ }
+ ESP_ERROR_CHECK(err);
+
+ err = nimble_port_init();
+ if (err != ESP_OK) {
+ ESP_LOGE(TAG, "nimble_port_init failed: %s", esp_err_to_name(err));
+ return;
+ }
+
+ ble_hs_cfg.reset_cb = on_reset;
+ ble_hs_cfg.sync_cb = on_sync;
+
+ ble_svc_gap_init();
+ rc = ble_svc_gap_device_name_set(DEVICE_NAME);
+ assert(rc == 0);
+
+ ESP_LOGI(TAG, "starting BLE mock PN532 transmitter");
+ nimble_port_freertos_init(host_task);
+}
diff --git a/esp32/sdkconfig.defaults b/esp32/sdkconfig.defaults
new file mode 100644
index 0000000..a387e58
--- /dev/null
+++ b/esp32/sdkconfig.defaults
@@ -0,0 +1,6 @@
+CONFIG_BT_ENABLED=y
+CONFIG_BT_NIMBLE_ENABLED=y
+CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y
+CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y
+CONFIG_BT_NIMBLE_GATT_SERVER=y
+CONFIG_BT_NIMBLE_GAP_SERVICE=y
diff --git a/hacs.json b/hacs.json
new file mode 100644
index 0000000..a00763d
--- /dev/null
+++ b/hacs.json
@@ -0,0 +1,4 @@
+{
+ "name": "HomeKey Reader",
+ "render_readme": true
+}