penguin/homekey-relay

homekey-relay

commit 9aa14718c9fab29f9240a112df535b1aa65cfcb4

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-03T21:39:40Z
subjectInitial 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,
+        &params,
+        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
+}