Skip to content

Modularize backend and refactor frontend to Svelte 5#205

Merged
rednblkx merged 279 commits into
mainfrom
dev
Nov 22, 2025
Merged

Modularize backend and refactor frontend to Svelte 5#205
rednblkx merged 279 commits into
mainfrom
dev

Conversation

@rednblkx

@rednblkx rednblkx commented Sep 26, 2025

Copy link
Copy Markdown
Owner

An extensize refactoring of the codebase that restructures it into 8 pillars of management to implement a separation of responsibilities as follows:

  • ConfigManager: to handle passing configurations to other components
  • HardwareManager: handling hardware actions (Simple GPIO and Pixel)
  • LockManager: handling the internal state for the virtual lock that gets synced with HomeKit and Home Assistant
  • NfcManager: handling all the NFC communications
  • MqttManager: handling MQTT communications
  • HomeKitLock: the star of the show, handling all HomeKit-related stuff
  • ReaderDataManager: handling the private authentication data for HomeKey
  • WebServerManager: handling the Web interface and APIs

The web interface also gets a new look using the Svelte 5 web framework and TailwindCSS + DaisyUI for the styling.

Summary by CodeRabbit

  • New Features

    • Complete rebuilt Web UI (Svelte) with Info, MQTT, Actions, System, OTA, Logs; realtime WebSocket updates, OTA upload/progress, certificate management, theming, and navigation.
  • CI / Build

    • Added ESP32-C6 builds and updated ESP‑IDF versions; new firmware artifact names and CI workflow enhancements.
  • Documentation

    • New Hugo-based docs site: setup, API reference, configuration, troubleshooting, automations, and contribution guide.
  • Improvements

    • MQTT SSL/TLS and Home Assistant discovery, Ethernet presets, realtime logging (WebSocket sink), refined LittleFS workflow, project ignore rules, and modern web/tooling scaffold.

✏️ Tip: You can customize this high-level summary in your review settings.

Added cJSONGuard wrapper to ensure proper cleanup of cJSON objects
in updateFromJson, deserializeFromJson, and serializeToJson,
preventing memory leaks on early returns or exceptions.
Invalidate NVS handle and flag after closing to prevent
potential double-free if destructor is called multiple times.
Explicitly deleted copy and move operations to prevent
unsafe duplication of NVS handle and non-movable mutex.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (4)
main/ConfigManager.cpp (4)

1078-1082: Fix error message inconsistency.

The error message at line 1080 says "key" but the code accepts "privateKey" (line 1079).

Apply this diff:

-        ESP_LOGE(TAG, "Invalid certificate type: %s. Must be 'ca', 'client', or 'key'.", certType.c_str());
+        ESP_LOGE(TAG, "Invalid certificate type: %s. Must be 'ca', 'client', or 'privateKey'.", certType.c_str());

Based on past review comments.


1-19: Add missing standard headers.

The code uses std::span (line 418), std::remove_if (line 1185), and ctype functions ::isspace/isalnum (lines 1185, 1204) but doesn't include the required headers.

Apply this diff:

 #include "ConfigManager.hpp"
 #include "cJSON.h"
 #include "config.hpp"
+#include <algorithm>
+#include <cctype>
 #include <ranges>
+#include <span>
 #include <string>
 #include <vector>

Based on past review comments.


1184-1208: Fix unsafe ctype function calls with signed char arguments.

Using ::isspace (line 1185) and isalnum (line 1204) with char arguments causes undefined behavior when char is signed and contains negative values.

Apply this diff:

-    base64Content.erase(std::remove_if(base64Content.begin(), base64Content.end(), ::isspace), base64Content.end());
+    base64Content.erase(std::remove_if(base64Content.begin(), base64Content.end(),
+      [](char ch){ return std::isspace(static_cast<unsigned char>(ch)); }), base64Content.end());
-        } else if (!isalnum(c) && c != '+' && c != '/') {
+        } else if (!std::isalnum(static_cast<unsigned char>(c)) && c != '+' && c != '/') {

Based on past review comments.


1104-1108: Fix error message inconsistency (duplicate of line 1080 issue).

The error message at line 1106 says "key" but the code accepts "privateKey" (line 1105).

Apply this diff:

-        ESP_LOGE(TAG, "Invalid certificate type: %s. Must be 'ca', 'client', or 'key'.", certType.c_str());
+        ESP_LOGE(TAG, "Invalid certificate type: %s. Must be 'ca', 'client', or 'privateKey'.", certType.c_str());
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e03ed0d and 280a60c.

📒 Files selected for processing (1)
  • main/ConfigManager.cpp (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
main/ConfigManager.cpp (5)
main/include/ConfigManager.hpp (21)
  • ConfigManager (24-24)
  • ConfigManager (25-25)
  • ConfigManager (28-28)
  • ConfigManager (32-32)
  • getConfig (38-38)
  • deleteConfig (41-41)
  • saveConfig (44-44)
  • key (76-76)
  • key (77-77)
  • obj (71-71)
  • serialize (74-74)
  • updateFromJson (53-53)
  • serializeToJson (50-50)
  • deserializeFromJson (47-47)
  • certType (55-55)
  • certType (56-56)
  • certType (79-79)
  • certContent (58-58)
  • certContent (80-80)
  • certContent (81-81)
  • keyContent (82-82)
main/HomeKitLock.cpp (2)
  • begin (204-242)
  • begin (204-204)
main/MqttManager.cpp (2)
  • begin (95-153)
  • begin (95-95)
main/HardwareManager.cpp (2)
  • begin (106-222)
  • begin (106-106)
main/ReaderDataManager.cpp (2)
  • begin (40-56)
  • begin (40-40)
🪛 Clang (14.0.6)
main/ConfigManager.cpp

[error] 1-1: 'ConfigManager.hpp' file not found

(clang-diagnostic-error)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: esp32s3
  • GitHub Check: esp32c6
  • GitHub Check: esp32
  • GitHub Check: esp32c3

Comment thread main/ConfigManager.cpp
Comment thread main/ConfigManager.cpp

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
main/HardwareManager.cpp (1)

461-465: Bug: stopping the wrong timer in SUCCESS feedback path

In the SUCCESS case, you correctly check m_pixelSuccessTimer but stop m_gpioFailTimer (Line 464). This leaves the pixel success timer running and can cause the NeoPixel success indication to overrun or get stuck.

                 case FeedbackType::SUCCESS:
                     ESP_LOGD(TAG, "Executing SUCCESS feedback sequence.");
                     if(esp_timer_is_active(m_gpioSuccessTimer)) esp_timer_stop(m_gpioSuccessTimer);
-                    if(esp_timer_is_active(m_pixelSuccessTimer)) esp_timer_stop(m_gpioFailTimer);
+                    if(esp_timer_is_active(m_pixelSuccessTimer)) esp_timer_stop(m_pixelSuccessTimer);
🧹 Nitpick comments (5)
docs/content/updates.md (3)

95-102: Clarify the purpose of flashing firmware to two offsets (app0 and app1).

The command flashes firmware.bin to both 0x20000 (app0) and 0x200000 (app1). While this is now clarified as intentional for OTA redundancy, the documentation does not explain why. Users will likely assume it's a typo or mistake.

Add a brief note explaining the dual-partition strategy, for example:

> [!NOTE]
> This command flashes the firmware to both OTA partitions (app0 and app1) to ensure the device can safely boot from either partition during updates.

Alternatively, if you prefer to keep this section simple, consider recommending the merged firmware approach instead (which is mentioned in the prerequisites at line 19 but not clearly linked here).


46-46: Use descriptive link text instead of "here".

The link should describe its destination rather than using generic text like "here". Change line 46 to use a descriptive label.

-*   `espota` tool (available as a Windows executable or a Python script for Linux/macOS, both available [here](https://github.com/espressif/arduino-esp32/tree/master/tools)).
+*   `espota` tool (available as a Windows executable or a Python script for Linux/macOS, both available [in the Arduino-ESP32 tools repository](https://github.com/espressif/arduino-esp32/tree/master/tools)).

51-51: Fix list indentation.

Line 51 uses 4 spaces for indentation; Markdown convention for this list is 2 spaces.

 *   The OTA password, if you've set one in the [Configuration Guide](../configuration#homespan).
-    *   The default OTA password is `homespan-ota`
+  *   The default OTA password is `homespan-ota`
main/HardwareManager.cpp (2)

50-71: Remove stray break after TAG_TAP case

There is an extra break; after the TAG_TAP case’s closing brace (Line 68). It’s harmless but dead code and can be removed to simplify the switch body.

       case TAG_TAP: {
         EventTagTap s = alpaca::deserialize<EventTagTap>(event.data, ec);
         if(!ec){
           if (m_feedbackQueue != nullptr) {
               FeedbackType feedback = FeedbackType::TAG_EVENT;
               xQueueSend(m_feedbackQueue, &feedback, 0);
           }
         }
         break;
       }
-      break;
       default:
         break;

291-296: Guard m_pixel before using it in handleTimer

The PIXEL_* timer branch unconditionally calls i->m_pixel->off();. Today, the corresponding timers are only started when m_pixel != nullptr, but this relies on that invariant never changing. A small future change could make this a null‑dereference.

Adding a simple guard keeps the timer handler robust:

-    case TimerSources::PIXEL_S:
-    case TimerSources::PIXEL_F:
-    case TimerSources::PIXEL_TAG_EVENT:
-      i->m_pixel->off();
-      ESP_LOGD(TAG, "PIXEL");
-      break;
+    case TimerSources::PIXEL_S:
+    case TimerSources::PIXEL_F:
+    case TimerSources::PIXEL_TAG_EVENT:
+      if (i->m_pixel != nullptr) {
+        i->m_pixel->off();
+      }
+      ESP_LOGD(TAG, "PIXEL");
+      break;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 82c8827 and b46ca7b.

📒 Files selected for processing (2)
  • docs/content/updates.md (1 hunks)
  • main/HardwareManager.cpp (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
main/HardwareManager.cpp (2)
main/include/HardwareManager.hpp (7)
  • HardwareManager (26-26)
  • state (37-37)
  • initiator_isr_handler (90-90)
  • arg (88-88)
  • instance (82-82)
  • instance (85-85)
  • instance (92-92)
main/include/LockManager.hpp (2)
  • state (58-58)
  • instance (79-79)
🪛 Clang (14.0.6)
main/HardwareManager.cpp

[error] 1-1: 'event_manager.hpp' file not found

(clang-diagnostic-error)

🪛 markdownlint-cli2 (0.18.1)
docs/content/updates.md

46-46: Link text should be descriptive

(MD059, descriptive-link-text)


51-51: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: esp32c6
  • GitHub Check: esp32s3
  • GitHub Check: esp32c3
  • GitHub Check: esp32

Comment thread docs/content/updates.md

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

♻️ Duplicate comments (43)
data/src/lib/stores/system.svelte.test.js (1)

5-15: Incomplete state reset in beforeEach.

Beyond the free_heap field already flagged in the past review, the reset is also missing log_level and chip_model, both present in the store definition (Lines 18-28 in system.svelte.js). This omission can cause state leakage between tests.

Apply this diff to include all missing fields:

 beforeEach(() => {
   // Reset system info to initial state before each test
   updateSystemInfo({
     deviceName: '',
     version: '',
     uptime: 0,
+    free_heap: 0,
     wifi_ssid: '',
     wifi_rssi: 0,
     eth_enabled: false,
+    log_level: 2,
+    chip_model: 0
   });
 });
.github/workflows/esp32.yml (1)

187-188: Fix inverted file deletion regex—currently deletes compressed files.

The regex pattern matches both compressed (.html.gz, .json.gz, .br) and uncompressed (.css, .js) files, contradicting the step intent to keep compressed files. The command will delete both types, leaving the filesystem empty.

To keep only compressed files and remove uncompressed originals, invert the logic:

      - name: Remove extra, only keep compressed
-       run: find build -regex ".*\.\(html.gz\|css\|br\|js\|json.gz\)" -delete
+       run: find build -type f ! \( -name '*.gz' -o -name '*.br' \) -delete
        working-directory: data

This deletes everything except .gz and .br files, preserving space efficiency.

README.md (3)

82-82: Remove hardcoded HomeKit setup code; security risk.

Publishing a default setup code (466-37-726) encourages insecure defaults and is a known security anti-pattern. Users should see the code in the device UI and change it immediately after first pairing.

   4. **Initial Setup**
      - Connect to the device's WiFi AP (`HomeSpan-Setup` / `homespan`)
      - Access the web interface at `http://192.168.4.1`
      - Configure your WiFi credentials and HomeKit setup code
-     - Pair with Apple Home using code: `466-37-726`
+     - Pair with Apple Home using the setup code shown in the Web UI
+     - **Important**: Change the setup code immediately after first pairing.

1-14: Fix markdownlint violations in header: MD023 & MD036.

The centered div wrapper causes two linting errors:

  • MD023 (line 4): Heading inside div doesn't start at column 1.
  • MD036 (line 9): Emphasis used instead of a proper heading.

Restructure to satisfy linting while preserving visual centering:

-<div align="center">
-  <img src="https://github.com/user-attachments/assets/fc93a70a-ef1e-4390-9067-6fafb255e5ac" alt="HomeKey-ESP32" width="200"/>
-  
-  # HomeKey-ESP32
-  [![Discord](https://badgen.net/discord/members/VWpZ5YyUcm?icon=discord)](https://discord.com/invite/VWpZ5YyUcm)
-  [![CI](https://github.com/rednblkx/HomeKey-ESP32/actions/workflows/esp32.yml/badge.svg?branch=main)](https://github.com/rednblkx/HomeKey-ESP32/actions/workflows/esp32.yml)
-  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
-  
-  **Apple HomeKey functionality for the rest of us**
-  
-
-  [Documentation](https://rednblkx.github.io/HomeKey-ESP32/) • [Quick Start](#-60-second-quick-start) • [Setup Guide](docs/setup.md) • [Discord](https://discord.com/invite/VWpZ5YyUcm)
-
-</div>
+# HomeKey-ESP32
+
+<div align="center">
+  <img src="https://github.com/user-attachments/assets/fc93a70a-ef1e-4390-9067-6fafb255e5ac" alt="HomeKey-ESP32" width="200"/>
+  
+  [![Discord](https://badgen.net/discord/members/VWpZ5YyUcm?icon=discord)](https://discord.com/invite/VWpZ5YyUcm)
+  [![CI](https://github.com/rednblkx/HomeKey-ESP32/actions/workflows/esp32.yml/badge.svg?branch=main)](https://github.com/rednblkx/HomeKey-ESP32/actions/workflows/esp32.yml)
+  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+  
+  Apple HomeKey functionality for the rest of us
+  
+  [Documentation](https://rednblkx.github.io/HomeKey-ESP32/) • [Quick Start](#quick-start) • [Setup Guide](https://rednblkx.github.io/HomeKey-ESP32/setup/) • [Discord](https://discord.com/invite/VWpZ5YyUcm)
+
+</div>

12-12: Fix broken link fragment: docs/setup.md doesn't exist.

The link points to a relative path that doesn't exist in the repo (docs are published via Hugo). Update to the published documentation URL:

-  [Documentation](https://rednblkx.github.io/HomeKey-ESP32/) • [Quick Start](#-60-second-quick-start) • [Setup Guide](docs/setup.md) • [Discord](https://discord.com/invite/VWpZ5YyUcm)
+  [Documentation](https://rednblkx.github.io/HomeKey-ESP32/) • [Quick Start](#quick-start) • [Setup Guide](https://rednblkx.github.io/HomeKey-ESP32/setup/) • [Discord](https://discord.com/invite/VWpZ5YyUcm)
data/src/lib/components/AppOTA.svelte (1)

229-234: State cleanup inconsistency between upload functions.

The uploadFirmware finally block only resets state when skipReboot is false, but uploadLittleFS (lines 284-287) always resets state. If uploadFirmware is called with skipReboot=true and an error occurs, uploading and currentUploadType remain set, potentially leaving the UI stuck in an uploading state.

While uploadBoth's finally block (lines 333-336) provides a safety net for sequential uploads, this creates fragile implicit coupling.

Consider always resetting state on error to prevent stuck UI:

  } finally {
-   if (!skipReboot) {
-     uploading = false;
-     currentUploadType = "";
-   }
+   // Always reset on error to prevent stuck UI
+   if (!skipReboot || otaStatus.error) {
+     uploading = false;
+     currentUploadType = "";
+   }
  }
docs/content/prerequisites.md (1)

109-109: Fix GitHub capitalization.

Line 109 uses lowercase "Github"; the official name is "GitHub".

Apply this diff:

-        *   **PCB Files:** Available on Github [here](https://github.com/lollokara/HomeKey-ESP32-PCB) and you can manufacture it where you want, or it can be ordered from PCBWay [here](https://www.pcbway.com/project/shareproject/ESP32_Homekey_77a119d7.html).
+        *   **PCB Files:** Available on GitHub [here](https://github.com/lollokara/HomeKey-ESP32-PCB) and you can manufacture it where you want, or it can be ordered from PCBWay [here](https://www.pcbway.com/project/shareproject/ESP32_Homekey_77a119d7.html).
data/src/lib/stores/websocket.svelte.test.js (2)

93-100: Backoff max‑delay test still doesn’t exercise capping logic.

This test sets backoffCurrentDelay to 40000 and asserts that same value, so it only verifies that the store can hold values above the max, not that any reconnection/backoff logic is capped at backoffMaxDelay. Either call the actual backoff/capping logic and assert the resulting delay is <= backoffMaxDelay, or rename the test to clarify that it only checks that the state can store values above the max.


162-196: “Reactive behavior” tests still don’t validate Svelte 5 reactivity.

These tests manually call a local effect() function reading from websocketState; they don’t go through Svelte 5’s $state reactivity, so they only prove that state reads reflect prior writes. Either rename this block to something like “state read/write behavior” or add component‑level tests (e.g., via Svelte Testing Library) that assert UI or derived values update when websocketState changes.

data/src/lib/components/NavigationMenu.svelte (1)

64-71: Fix ARIA roles: use plain list semantics instead of menu/menuitem.

This is a standard navigation list, not an application menu. role="menu" / role="menuitem" is semantically incorrect and was already flagged earlier; native <nav> + <ul>/<li>/<a> is sufficient.

Suggested change:

-	<ul
-		class="menu flex-1 p-4 w-full bg-base-100 lg:bg-base-200"
-		role="menu"
-		onclick={onClose}
-	>
-		<li class="my-1" role="menuitem">
+	<ul
+		class="menu flex-1 p-4 w-full bg-base-100 lg:bg-base-200"
+		onclick={onClose}
+	>
+		<li class="my-1">
@@
-		<li class="my-1" role="menuitem">
+		<li class="my-1">
@@
-		<li class="my-1" role="menuitem">
+		<li class="my-1">
@@
-		<li class="my-1" role="menuitem">
+		<li class="my-1">
@@
-		<li class="my-1" role="menuitem">
+		<li class="my-1">
@@
-		<li class="my-1" role="menuitem">
+		<li class="my-1">
@@
-		<li class="lg:mt-auto hidden lg:inline-grid" role="menuitem">
+		<li class="lg:mt-auto hidden lg:inline-grid">

Also applies to: 91-91, 111-111, 131-131, 157-157, 177-177, 197-197

data/src/lib/components/AppMisc.svelte (4)

59-71: Normalize hk_key_color to a number and derive hkFinishImage as a value.

hkFinishImage currently compares c.value (number) to miscConfig.hk_key_color, which can become a string from the radio bind:group, so the lookup can fail and always fall back to HKFinish0. Also, hkFinishImage is used as a function in the template.

You can both normalize the type and simplify usage:

-const hkFinishColors = [
+const hkFinishColors = [
 	{ name: "Tan", value: 0, image: HKFinish0 },
 	{ name: "Gold", value: 1, image: HKFinish1 },
 	{ name: "Silver", value: 2, image: HKFinish2 },
 	{ name: "Black", value: 3, image: HKFinish3 },
 ];
 
-let hkFinishImage = $derived(() => {
-	const color = hkFinishColors.find(
-		(c) => c.value === miscConfig.hk_key_color,
-	);
-	return color?.image || HKFinish0;
-});
+let hkFinishImage = $derived(
+	hkFinishColors.find(
+		(c) => c.value === Number(miscConfig.hk_key_color),
+	)?.image ?? HKFinish0,
+);
@@
-	const saveMiscConfig = async (e: any) => {
+	const saveMiscConfig = async (e: any) => {
 		e.preventDefault();
 		try {
 			if (!miscConfig || !misc) return;
+			miscConfig.hk_key_color = Number(miscConfig.hk_key_color);
 			const result = await saveConfig("misc", diff(misc, miscConfig));
@@
-									<img
-										src={hkFinishImage()}
+									<img
+										src={hkFinishImage}

This fixes the preview and ensures the saved config carries a numeric hk_key_color.

Also applies to: 80-88, 429-454


96-114: Fix NFC preset handling for string indices and disabled states.

miscConfig.nfcPinsPreset comes from a <select> binding, so it will be a string. Strict comparisons with 255 and indexing with a string will behave inconsistently, and the “Custom” sentinel (255) branch never runs when the value is "255". The disabled checks on the NFC pin inputs have the same issue.

Suggested normalization:

-	const handleNfcPresetChange = () => {
-		if (
-			miscConfig.nfcPinsPreset !== undefined &&
-			miscConfig.nfcPinsPreset !== 255 &&
-			nfcPresets
-		) {
-			const preset = nfcPresets.presets[miscConfig.nfcPinsPreset];
-			if (preset) {
-				miscConfig.nfcGpioPins = [
-					preset.gpioPins[0],
-					preset.gpioPins[1],
-					preset.gpioPins[2],
-					preset.gpioPins[3],
-				];
-			}
-		} else if (miscConfig.nfcPinsPreset === 255) {
-			miscConfig.nfcGpioPins = misc.nfcGpioPins;
-		}
-	};
+	const handleNfcPresetChange = () => {
+		const presetIndex = Number(miscConfig.nfcPinsPreset);
+
+		if (
+			Number.isInteger(presetIndex) &&
+			presetIndex !== 255 &&
+			nfcPresets
+		) {
+			const preset = nfcPresets.presets[presetIndex];
+			if (preset) {
+				miscConfig.nfcGpioPins = [
+					preset.gpioPins[0],
+					preset.gpioPins[1],
+					preset.gpioPins[2],
+					preset.gpioPins[3],
+				];
+			}
+		} else if (presetIndex === 255 && misc) {
+			miscConfig.nfcGpioPins = misc.nfcGpioPins;
+		}
+	};
@@
-									<select
-										bind:value={miscConfig.nfcPinsPreset}
-										onchange={handleNfcPresetChange}
+									<select
+										bind:value={miscConfig.nfcPinsPreset}
+										onchange={handleNfcPresetChange}
@@
-										type="number"
-										disabled={miscConfig.nfcPinsPreset !==
-											255}
+										type="number"
+										disabled={Number(miscConfig.nfcPinsPreset) !== 255}
@@
-										type="number"
-										disabled={miscConfig.nfcPinsPreset !==
-											255}
+										type="number"
+										disabled={Number(miscConfig.nfcPinsPreset) !== 255}
@@
-										type="number"
-										disabled={miscConfig.nfcPinsPreset !==
-											255}
+										type="number"
+										disabled={Number(miscConfig.nfcPinsPreset) !== 255}
@@
-										type="number"
-										disabled={miscConfig.nfcPinsPreset !==
-											255}
+										type="number"
+										disabled={Number(miscConfig.nfcPinsPreset) !== 255}

This makes presets and the “Custom” mode behave correctly regardless of string/number representation.

Also applies to: 462-479, 486-488, 502-504, 518-520, 534-536


73-78: Normalize Ethernet preset indices and fix currentEthChip for index 0 and strings.

ethActivePreset and ethPhyType come from <select> bindings, so they are strings. Comparisons with 255 and indexing into boardPresets/supportedChips will misbehave (e.g., preset "255" not treated as custom, index 0 skipped, disabled checks failing).

Also, currentEthChip uses a truthy check on ethPhyType, which breaks for index 0.

Proposed changes:

-	let currentEthChip = $derived(() => {
-		if (miscConfig.ethPhyType) {
-			return ethConfig!.supportedChips?.[miscConfig.ethPhyType];
-		}
-		return null;
-	});
+	const currentEthChip = () => {
+		const idx = Number(miscConfig.ethPhyType);
+		return Number.isInteger(idx) && idx >= 0
+			? ethConfig!.supportedChips?.[idx] ?? null
+			: null;
+	};
@@
-	const handleEthPresetChange = () => {
-		if (
-			miscConfig.ethActivePreset !== undefined &&
-			miscConfig.ethActivePreset !== 255 &&
-			ethConfig!.boardPresets
-		) {
-			const preset = ethConfig!.boardPresets[miscConfig.ethActivePreset];
+	const handleEthPresetChange = () => {
+		const presetIndex = Number(miscConfig.ethActivePreset);
+
+		if (
+			Number.isInteger(presetIndex) &&
+			presetIndex !== 255 &&
+			ethConfig!.boardPresets
+		) {
+			const preset = ethConfig!.boardPresets[presetIndex];
 			if (preset) {
 				miscConfig.ethPhyType = preset.ethChip.phy_type;
@@
-	$effect(() => {
-		if (misc?.ethActivePreset !== 255) {
-			handleEthPresetChange();
-		}
-	});
+	$effect(() => {
+		if (
+			misc?.ethActivePreset != null &&
+			Number(misc.ethActivePreset) !== 255
+		) {
+			handleEthPresetChange();
+		}
+	});
@@
-											<select
-												bind:value={
-													miscConfig.ethActivePreset
-												}
-												onchange={handleEthPresetChange}
+											<select
+												bind:value={
+													miscConfig.ethActivePreset
+												}
+												onchange={handleEthPresetChange}
@@
-											<select
-												bind:value={
-													miscConfig.ethPhyType
-												}
-												disabled={miscConfig.ethActivePreset !==
-													255}
+											<select
+												bind:value={
+													miscConfig.ethPhyType
+												}
+												disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethRmiiConfig![0]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethRmiiConfig![0]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethRmiiConfig![1]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethRmiiConfig![1]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethRmiiConfig![2]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethRmiiConfig![2]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethRmiiConfig![3]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethRmiiConfig![3]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethRmiiConfig![4]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethRmiiConfig![4]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethSpiConfig![0]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethSpiConfig![0]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethSpiConfig![1]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethSpiConfig![1]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethSpiConfig![2]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethSpiConfig![2]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethSpiConfig![3]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethSpiConfig![3]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethSpiConfig![4]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethSpiConfig![4]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethSpiConfig![5]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethSpiConfig![5]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}
@@
-															bind:value={
-																miscConfig
-																	.ethSpiConfig![6]
-															}
-															disabled={miscConfig.ethActivePreset !==
-																255}
+															bind:value={
+																miscConfig
+																	.ethSpiConfig![6]
+															}
+															disabled={Number(miscConfig.ethActivePreset) !== 255}

This makes presets work for index 0, handles string values, and ensures “Custom” behaves correctly.

Also applies to: 116-153, 161-165, 625-626, 634-639, 659-665, 676-793, 795-936


942-985: Make WebUI credentials required only when authentication is enabled.

miscConfig.webAuthEnabled toggles WebUI auth, but the Username and Password inputs are always required. When auth is disabled this will still block form submission.

Condition these attributes on webAuthEnabled:

								<div class="form-control mb-4">
@@
									<input
										type="text"
										bind:value={miscConfig.webUsername}
										placeholder="admin"
										class="input input-bordered w-full"
-										required
+										disabled={!miscConfig.webAuthEnabled}
+										required={miscConfig.webAuthEnabled}
									/>
@@
									<input
										type="password"
										bind:value={miscConfig.webPassword}
										placeholder="password"
										class="input input-bordered w-full"
-										required
+										disabled={!miscConfig.webAuthEnabled}
+										required={miscConfig.webAuthEnabled}
									/>
data/src/lib/components/AppMqtt.svelte (1)

74-91: Fix certificate upload error/progress bindings for client and private key.

The upload handler and UI currently hard‑code the CA slot:

  • Invalid file types always set uploadErrors.ca, regardless of which input was used.
  • The Client Certificate and Private Key sections bind to uploadProgress.ca / uploadErrors.ca, so their own progress and errors are never shown correctly.

Suggested fixes:

@@
-    if (!validExtensions.includes(fileExtension)) {
-      uploadErrors.ca =
-        "Invalid file type. Must be .pem, .crt, .cer, .der, or .key";
-      return;
-    }
+    if (!validExtensions.includes(fileExtension)) {
+      uploadErrors[type] =
+        "Invalid file type. Must be .pem, .crt, .cer, .der, or .key";
+      return;
+    }
@@ CA certificate section stays as-is
-                          <input
+                          <input
                             type="file"
@@
-                            disabled={uploadProgress.ca > 0 &&
-                              uploadProgress.ca < 100}
+                            disabled={uploadProgress.ca > 0 &&
+                              uploadProgress.ca < 100}
@@
-                          {#if uploadProgress.ca > 0 && uploadProgress.ca < 100}
+                          {#if uploadProgress.ca > 0 && uploadProgress.ca < 100}
@@
-                          {#if uploadErrors.ca}
+                          {#if uploadErrors.ca}
@@ Client Certificate
-                          <input
+                          <input
                             type="file"
@@
-                            disabled={uploadProgress.ca > 0 &&
-                              uploadProgress.ca < 100}
+                            disabled={uploadProgress.client > 0 &&
+                              uploadProgress.client < 100}
                             data-type="client"
                           />
-                          {#if uploadProgress.ca > 0 && uploadProgress.ca < 100}
+                          {#if uploadProgress.client > 0 && uploadProgress.client < 100}
@@
-                                style="width: {uploadProgress.ca}%"
+                                style="width: {uploadProgress.client}%"
@@
-                          {#if uploadErrors.ca}
+                          {#if uploadErrors.client}
@@
-                              {uploadErrors.ca}
+                              {uploadErrors.client}
@@ Private Key
-                          <input
+                          <input
                             type="file"
@@
-                            disabled={uploadProgress.ca > 0 &&
-                              uploadProgress.ca < 100}
+                            disabled={uploadProgress.privateKey > 0 &&
+                              uploadProgress.privateKey < 100}
                             data-type="privateKey"
                           />
-                          {#if uploadProgress.ca > 0 && uploadProgress.ca < 100}
+                          {#if uploadProgress.privateKey > 0 && uploadProgress.privateKey < 100}
@@
-                                style="width: {uploadProgress.ca}%"
+                                style="width: {uploadProgress.privateKey}%"
@@
-                          {#if uploadErrors.ca}
+                          {#if uploadErrors.privateKey}
@@
-                              {uploadErrors.ca}
+                              {uploadErrors.privateKey}

This aligns validation, progress, and error display with the actual input being used.

Also applies to: 557-584, 585-612, 613-640

data/src/lib/components/LogViewer.svelte (1)

8-18: Fix virtualListRef typing and guard scroll-to-bottom usage

virtualListRef is typed as ReturnType<typeof VirtualList> but VirtualList is a Svelte component, not a function. This doesn’t match what bind:this sets and can cause TS issues. The scroll-to-bottom handler also assumes the ref is always non-null.

Consider:

-	let virtualListRef: ReturnType<typeof VirtualList>;
+	let virtualListRef: InstanceType<typeof VirtualList> | null = null;

and:

-						onclick={async () => {
-							await virtualListRef.scroll({
-								index: filteredLogs().length - 1,
-							});
+						onclick={async () => {
+							const list = virtualListRef;
+							const lastIndex = filteredLogs().length - 1;
+							if (!list || lastIndex < 0) return;
+							await list.scroll({
+								index: lastIndex,
+							});
 							autoScrollActive = true;
 						}}

This aligns the type with the Svelte instance and prevents a possible runtime error when the button is clicked before the list is mounted or when there are no logs.

Does Svelte's `bind:this` on a component instance assign the component instance type (usable with `InstanceType<typeof Component>`) and can it be `null` before mount?

Also applies to: 446-457

data/src/routes/+layout.svelte (1)

4-8: Fix websocket store import path to match actual file and store conventions

All other stores are imported with the .svelte.js suffix, but websocketState is imported from $lib/stores/websocket.svelte. Previous tooling indicated the actual file is websocket.svelte.js, and mixing paths can create duplicate module instances under ESM or simply fail to resolve.

Update to:

-  import { websocketState } from '$lib/stores/websocket.svelte';
+  import { websocketState } from '$lib/stores/websocket.svelte.js';

and keep this consistent with other imports across the codebase.

In a SvelteKit + Vite project, can importing the same JS module once with an explicit `.js` extension and once without cause duplicate module instances or resolution errors?

Also applies to: 12-16

data/src/lib/components/AppActions.svelte (2)

58-63: Use Svelte on:submit|preventDefault instead of onsubmit

The form currently uses:

<form onsubmit={saveActionsConfig} class="flex flex-col items-center">

In Svelte, event listeners should use on:event syntax, and preventDefault is better expressed via a modifier. Also, you already call e.preventDefault() inside the handler.

Prefer:

-    <form onsubmit={saveActionsConfig} class="flex flex-col items-center">
+    <form
+      on:submit|preventDefault={saveActionsConfig}
+      class="flex flex-col items-center"
+    >

and then you can drop the explicit e.preventDefault() inside saveActionsConfig if you like.

According to the Svelte docs, is `on:submit={handler}` (with optional `|preventDefault`) the correct way to attach submit handlers, and does using plain `onsubmit` work as expected?

Also applies to: 86-87


31-43: Fix <select> bindings so booleans and enums don’t become strings

Several <select>s bind directly to boolean or numeric fields:

  • nfcSuccessHL, nfcFailHL, tagEventHL
  • gpioActionLockState, gpioActionUnlockState
  • hkGpioControlledState, hkDumbSwitchMode
  • gpioActionMomentaryEnabled (two places)
  • hkAltActionGpioState

In HTML, option values are strings, so after user interaction these fields will become "true", "false", "0", "1", etc., instead of booleans/integers expected by your config and backend.

Use string-backed intermediates plus explicit coercion, e.g.:

-      nfcSuccessHL: false,
+      nfcSuccessHL: false,
@@
-      nfcFailHL: false,
+      nfcFailHL: false,
@@
-      tagEventHL: false,
+      tagEventHL: false,
@@
-      gpioActionLockState: false,
+      gpioActionLockState: false,
@@
-      gpioActionUnlockState: false,
+      gpioActionUnlockState: false,
@@
-      hkGpioControlledState: false,
+      hkGpioControlledState: false,
@@
-      hkAltActionGpioState: 0,
+      hkAltActionGpioState: 0,

Script additions (pattern):

let nfcSuccessHLStr = String(actionsConfig.nfcSuccessHL);
let nfcFailHLStr = String(actionsConfig.nfcFailHL);
let tagEventHLStr = String(actionsConfig.tagEventHL);
let gpioLockStateStr = String(actionsConfig.gpioActionLockState);
let gpioUnlockStateStr = String(actionsConfig.gpioActionUnlockState);
let hkGpioControlledStateStr = String(actionsConfig.hkGpioControlledState);
let hkDumbSwitchModeStr = String(actionsConfig.hkDumbSwitchMode);
let gpioMomentaryStr = String(actionsConfig.gpioActionMomentaryEnabled);
let hkAltActionGpioStateStr = String(actionsConfig.hkAltActionGpioState);

Template examples:

-  <select
-    bind:value={actionsConfig.nfcSuccessHL}
-    class="select select-bordered w-full"
-  >
-    <option value={false}>LOW</option>
-    <option value={true}>HIGH</option>
-  </select>
+  <select
+    bind:value={nfcSuccessHLStr}
+    on:change={() =>
+      (actionsConfig.nfcSuccessHL = nfcSuccessHLStr === "true")}
+    class="select select-bordered w-full"
+  >
+    <option value="false">LOW</option>
+    <option value="true">HIGH</option>
+  </select>
-  <select
-    bind:value={actionsConfig.gpioActionMomentaryEnabled}
-    class="select select-bordered w-full"
-  >
-    <option value={0}>Disabled</option>
-    <option value={1}>Home App Only</option>
-    <option value={2}>Home Key Only</option>
-    <option value={3}>Home App + Home Key</option>
-  </select>
+  <select
+    bind:value={gpioMomentaryStr}
+    on:change={() =>
+      (actionsConfig.gpioActionMomentaryEnabled = Number(gpioMomentaryStr))}
+    class="select select-bordered w-full"
+  >
+    <option value="0">Disabled</option>
+    <option value="1">Home App Only</option>
+    <option value="2">Home Key Only</option>
+    <option value="3">Home App + Home Key</option>
+  </select>

Apply the same pattern for all boolean and enum selects so your config preserves the intended types.

How does Svelte handle `<select bind:value>` when the bound variable is boolean or number—does it coerce option values to strings by default?

Also applies to: 400-435, 469-479, 621-627, 696-735, 741-751, 783-788

main/LockManager.cpp (3)

185-195: Simplify momentarySources calculation for correctness and maintainability

The current expression combines bitwise OR, equality, bitwise AND, and logical negation in a ternary, which is hard to reason about and easy to misinterpret:

uint8_t momentarySources = (((m_actionsConfig.gpioActionMomentaryEnabled |
                              m_actionsConfig.gpioActionPin) == 255) &
                            !m_actionsConfig.hkDumbSwitchMode)
                               ? 0
                               : m_actionsConfig.gpioActionMomentaryEnabled;

Refactor to explicit booleans that capture the intent (configured pin, not dumb mode, momentary enabled).

-    uint8_t momentarySources = (((m_actionsConfig.gpioActionMomentaryEnabled |
-                                  m_actionsConfig.gpioActionPin) == 255) &
-                                !m_actionsConfig.hkDumbSwitchMode)
-                                   ? 0
-                                   : m_actionsConfig.gpioActionMomentaryEnabled;
+    // Momentary is active only when:
+    // 1) GPIO action pin is configured (not 255), and
+    // 2) Not in dumb‑switch mode.
+    bool gpioConfigured = (m_actionsConfig.gpioActionPin != 255);
+    bool notDumbMode = !m_actionsConfig.hkDumbSwitchMode;
+    uint8_t momentarySources =
+        (gpioConfigured && notDumbMode) ? m_actionsConfig.gpioActionMomentaryEnabled : 0;

49-63: Duplicate EventLockState s declaration (compile error and confusing logic)

Within the "lock/updateState" subscriber, EventLockState s is declared from alpaca::deserialize, then redeclared with an aggregate initializer in the same scope. This is illegal in C++ and also discards the original source value.

Reuse the existing s object instead of redeclaring it.

   espp::EventManager::get().add_subscriber(
       "lock/updateState", "LockManager", [&](const std::vector<uint8_t> &data) {
         std::error_code ec;
         EventLockState s = alpaca::deserialize<EventLockState>(data, ec);
         if (!ec) {
           m_currentState = s.currentState;
-          s.currentState = m_currentState;
-          s.targetState = m_targetState;
-          EventLockState s{
-            .currentState = static_cast<uint8_t>(m_currentState),
-            .targetState = static_cast<uint8_t>(m_targetState),
-            .source = LockManager::INTERNAL
-          };
+          s.currentState = static_cast<uint8_t>(m_currentState);
+          s.targetState = static_cast<uint8_t>(m_targetState);
           std::vector<uint8_t> d;
           alpaca::serialize(s, d);
           espp::EventManager::get().publish("lock/stateChanged", d);
         }
       }, 4096);

88-94: Unqualified enum values (LOCKED/INTERNAL/NFC) will not compile with scoped enums

This file mixes lockStates::LOCKED with bare LOCKED, and uses LockManager::INTERNAL plus NFC without the Source:: qualifier, while other code (e.g., HomeKitLock.cpp) uses Source::NFC. With enum class or namespaced enums this fails to compile.

Align all uses with the defined enum scopes.

 LockManager::LockManager(const espConfig::misc_config_t& miscConfig, const espConfig::actions_config_t& actionsConfig)
     : m_miscConfig(miscConfig),
       m_actionsConfig(actionsConfig),
       m_currentState(lockStates::LOCKED),
       m_targetState(lockStates::LOCKED)
@@
           m_currentState = s.currentState;
           s.currentState = m_currentState;
           s.targetState = m_targetState;
-          EventLockState s{
-            .currentState = static_cast<uint8_t>(m_currentState),
-            .targetState = static_cast<uint8_t>(m_targetState),
-            .source = LockManager::INTERNAL
-          };
+          EventLockState s{
+            .currentState = static_cast<uint8_t>(m_currentState),
+            .targetState = static_cast<uint8_t>(m_targetState),
+            .source = Source::INTERNAL,
+          };
@@
 void LockManager::begin() {
@@
   EventLockState s{
     .currentState = static_cast<uint8_t>(m_currentState),
     .targetState = static_cast<uint8_t>(m_targetState),
-    .source = LockManager::INTERNAL
+    .source = Source::INTERNAL,
   };
@@
 void LockManager::handleTimer(void* instance){
-  static_cast<LockManager*>(instance)->setTargetState(LOCKED, INTERNAL);
+  static_cast<LockManager*>(instance)->setTargetState(lockStates::LOCKED, Source::INTERNAL);
 }
@@
   EventLockState s{
     .currentState = m_currentState,
     .targetState = m_targetState,
-    .source = LockManager::INTERNAL
+    .source = Source::INTERNAL,
   };
@@
-    } else if((source == NFC && m_actionsConfig.hkGpioControlledState) || source != NFC) {
+    } else if ((source == Source::NFC && m_actionsConfig.hkGpioControlledState) || source != Source::NFC) {
@@
   EventLockState s{
     .currentState = static_cast<uint8_t>(m_currentState),
     .targetState = static_cast<uint8_t>(m_targetState),
-    .source = LockManager::INTERNAL
+    .source = Source::INTERNAL,
   };

Also applies to: 96-107, 120-122, 167-171, 217-221

main/HomeKitLock.cpp (2)

55-60: Surface deserialization errors for homekit/event instead of failing silently

Both HomekitEvent and EventValueChanged deserialization paths simply return on error, with no logging. This makes debugging malformed HomeKit events difficult.

Consider logging failures (including ec.message() and event.type where available) before returning so you have observability into bad payloads.


54-75: Potential null dereference when processing battery‑related HomeKit events

In the HomeKitLock constructor’s "homekit/event" subscriber, the BTR_PROP_CHANGED branch unconditionally dereferences m_statusLowBattery and m_batteryLevel:

if (s.name == "btrLevel") {
  updateBatteryStatus(s.newValue, m_statusLowBattery->getVal());
} else if (s.name == "btrLowThreshold") {
  updateBatteryStatus(m_batteryLevel->getVal(), s.newValue);
}

These members are only initialized when proxBatEnabled is true (see begin() and PhysicalLockBatteryService), so with battery service disabled they remain null and this path will crash.

Guard the pointers (and/or reuse updateBatteryStatus defaults) before reading from them.

-            case HomekitEventType::BTR_PROP_CHANGED:
-              if(s.name == "btrLevel") {
-                updateBatteryStatus(s.newValue, m_statusLowBattery->getVal());
-              } else if(s.name == "btrLowThreshold"){
-                updateBatteryStatus(m_batteryLevel->getVal(), s.newValue);
-              }
+            case HomekitEventType::BTR_PROP_CHANGED:
+              if (s.name == "btrLevel") {
+                bool low = m_statusLowBattery ? static_cast<bool>(m_statusLowBattery->getVal()) : false;
+                updateBatteryStatus(static_cast<uint8_t>(s.newValue), low);
+              } else if (s.name == "btrLowThreshold") {
+                uint8_t level = m_batteryLevel ? static_cast<uint8_t>(m_batteryLevel->getVal()) : 0;
+                updateBatteryStatus(level, static_cast<bool>(s.newValue));
+              }
               break;
data/src/lib/services/ws.js (1)

82-120: Manual reconnects permanently disable auto‑reconnect

disconnect() flips _shouldReconnect to false, but connect() never turns it back on. After using the UI to disconnect and then connect() again, future drops will not schedule _handleConnectionLoss().

Set _shouldReconnect = true at the start of connect() so manual reconnects restore auto behavior.

   /**
    * Establishes a WebSocket connection.
    * @param {string} [url] - The WebSocket URL to connect to.
    */
   connect(url) {
+    // Manual reconnects should restore auto‑reconnect behavior
+    this._shouldReconnect = true;
+
     if (this._lastState === 'connecting' || this._reconnectTimer) {
       console.log('Connection or reconnection attempt already in progress.');
       return;
@@
   /**
    * Disconnects the WebSocket connection gracefully.
    */
   disconnect() {
     console.log('Manual disconnection initiated.');
     this._shouldReconnect = false;

Also applies to: 122-136

main/CMakeLists.txt (1)

7-15: CI still skips filesystem image, and asset‑prune regex removes compressed assets

littlefs_create_partition_image is inside the if(NOT DEFINED ENV{CI}) block, so CI never builds the LittleFS image. The find regex also deletes .html.gz / .json.gz alongside uncompressed CSS/JS, which is the opposite of typical intent.

Consider:

  • Always creating the partition image (only gate the webui build on CI).
  • Pruning only uncompressed .html/.css/.js/.json and keeping precompressed *.gz / *.br.
-if(NOT DEFINED ENV{CI})
-  add_custom_target(webui 
-    WORKING_DIRECTORY ${COMPONENT_DIR}/../data 
-    COMMAND bun run build 
-    COMMAND bash -c "find build -regex '.*\\.\\(html\\.gz\\|css\\|br\\|js\\|json\\.gz\\)' -delete"
-    VERBATIM
-  )
-  littlefs_create_partition_image(spiffs ../data/build FLASH_IN_PROJECT DEPENDS webui)
-endif()
+if(NOT DEFINED ENV{CI})
+  add_custom_target(webui 
+    WORKING_DIRECTORY ${COMPONENT_DIR}/../data 
+    COMMAND bun run build 
+    # Remove only uncompressed assets; keep *.gz/*.br
+    COMMAND bash -c "find build -type f \\( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.json' \\) -delete"
+    VERBATIM
+  )
+  littlefs_create_partition_image(spiffs ../data/build FLASH_IN_PROJECT DEPENDS webui)
+else()
+  # CI: web UI is expected to be built by the pipeline; still package it.
+  littlefs_create_partition_image(spiffs ../data/build FLASH_IN_PROJECT)
+endif()
data/src/lib/types/api.ts (3)

130-146: NeoPixel color tuples should be RGB triplets, not 2‑element tuples

neopixelSuccessColor, neopixelFailureColor, and neopixelTagEventColor are documented as RGB values but typed as [number, number][]. This contradicts the intended 3‑channel color representation and will break any code indexing [r, g, b].

Update them to 3‑element tuples.

   /** RGB color values for successful NFC operations */
-  neopixelSuccessColor: [number, number][];
+  neopixelSuccessColor: [number, number, number][];
   /** RGB color values for failed NFC operations */
-  neopixelFailureColor: [number, number][];
+  neopixelFailureColor: [number, number, number][];
@@
   /** RGB color values for tag event LED animation */
-  neopixelTagEventColor: [number, number][];
+  neopixelTagEventColor: [number, number, number][];

119-123: RMII MDC pin name typo (pin_mcdpin_mdc)

The RMII config comment and EthConfig type use pin_mcd for the MDC clock pin, which appears to be a typo. Earlier tooling already flagged this.

Rename to pin_mdc and ensure any backend JSON and UI usage are updated accordingly.

-  /** RMII configuration [phy_addr, pin_mcd, pin_mdio, pin_power, pin_rmii_clock] */
+  /** RMII configuration [phy_addr, pin_mdc, pin_mdio, pin_power, pin_rmii_clock] */
   ethRmiiConfig: [number, number, number, number, number];
@@
     rmii_conf: {
       /** PHY address */
       phy_addr: number;
       /** MDC pin number */
-      pin_mcd: number;
+      pin_mdc: number;

Also applies to: 197-233


413-420: Typo in ApiSuccess JSDoc

Minor doc issue: "Message decribing result" → "Message describing result".

   /** The response data payload */
   data: T;
-  /** Message decribing result */
+  /** Message describing result */
   message: string;
main/HKServices.cpp (2)

11-15: Invalid numeric literal 26C in CUSTOM_CHAR(HardwareFinish, …)

CUSTOM_CHAR(HardwareFinish, 26C, ...) uses 26C as a numeric literal, which is invalid C++ and will fail to compile. It should be a proper integer value for the HAP UUID, e.g. 0x026C.

-CUSTOM_CHAR(HardwareFinish, 26C, PR, TLV_ENC, NULL_TLV, NULL_TLV, NULL_TLV, true)
+CUSTOM_CHAR(HardwareFinish, 0x026C, PR, TLV_ENC, NULL_TLV, NULL_TLV, NULL_TLV, true)

58-60: Make FirmwareRevision parsing robust to missing prefix/dash

Firmware revision is currently derived via a chained erase/find_first_of:

std::string app_desc = esp_app_get_description()->version;
new Characteristic::FirmwareRevision(app_desc.erase(app_desc.erase(0,1).find_first_of("-")).c_str());

If the version string is empty, lacks the leading character, or has no '-', this can invoke erase with npos and throw. It’s also hard to read.

Use explicit checks for leading v/V and '-' before erasing.

-    std::string app_desc = esp_app_get_description()->version;
-    new Characteristic::FirmwareRevision(app_desc.erase(app_desc.erase(0,1).find_first_of("-")).c_str());
+    std::string ver = esp_app_get_description()->version;
+    // Strip optional leading 'v' and any suffix after the first '-'
+    if (!ver.empty() && (ver.front() == 'v' || ver.front() == 'V')) {
+      ver.erase(0, 1);
+    }
+    auto dash = ver.find('-');
+    if (dash != std::string::npos) {
+      ver.erase(dash);
+    }
+    new Characteristic::FirmwareRevision(ver.c_str());
main/ReaderDataManager.cpp (6)

126-135: Fix msgpack offset usage to avoid UB/crash

Line 128 still passes NULL as the offset pointer to msgpack_unpack_next, which is unsafe and can crash with some builds.

Apply this diff:

     msgpack_unpacked unpacked;
     msgpack_unpacked_init(&unpacked);
-    bool success = msgpack_unpack_next(&unpacked, (const char*)buffer.data(), buffer.size(), NULL);
+    size_t off = 0;
+    bool success = msgpack_unpack_next(&unpacked, (const char*)buffer.data(), buffer.size(), &off);
     if(success) {

Based on past review comments.


192-209: Return status should reflect save outcome

Line 205 calls saveData() but ignores its return value; lines 207-208 log success unconditionally even if the save failed.

Apply this diff:

     m_readerData.reader_sk = {};
     ESP_LOGI(TAG, "In-memory reader key cleared.");
 
-    saveData();
-
-    ESP_LOGI(TAG, "Reader key successfully erased from NVS.");
-    return true;
+    if (saveData() == nullptr) {
+        ESP_LOGE(TAG, "Failed to persist erased reader key to NVS.");
+        return false;
+    }
+    ESP_LOGI(TAG, "Reader key successfully erased from NVS.");
+    return true;

Based on past review comments.


288-290: Use msgpack_pack_uint8 for bytes (current symbol likely undefined)

Lines 289, 308, 315, and 322 call msgpack_pack_unsigned_char which is not provided by msgpack-c; the correct API is msgpack_pack_uint8 for unsigned 8-bit values.

Apply this diff at each occurrence:

-    std::ranges::for_each(endpoint.endpoint_id, [&pk](const auto&o){
-      msgpack_pack_unsigned_char(pk, o);
+    std::ranges::for_each(endpoint.endpoint_id, [&pk](uint8_t o){
+      msgpack_pack_uint8(pk, o);
     });

Repeat for endpoint_pk, endpoint_pk_x, and endpoint_prst_k. Based on past review comments.

Also applies to: 307-309, 314-316, 321-323


363-368: Unpack accepts only positive ints but you pack signed

Lines 363 and 366 only check MSGPACK_OBJECT_POSITIVE_INTEGER for counter and key_type, but the corresponding pack function (lines 298, 302) uses msgpack_pack_int (signed).

Apply this diff:

-    if (obj_map.count("counter") && obj_map["counter"].type == MSGPACK_OBJECT_POSITIVE_INTEGER) {
-      endpoint.counter = obj_map["counter"].via.i64;
+    if (obj_map.count("counter") && (obj_map["counter"].type == MSGPACK_OBJECT_POSITIVE_INTEGER || obj_map["counter"].type == MSGPACK_OBJECT_NEGATIVE_INTEGER)) {
+      endpoint.counter = static_cast<int>(obj_map["counter"].via.i64);
     }
-    if (obj_map.count("key_type") && obj_map["key_type"].type == MSGPACK_OBJECT_POSITIVE_INTEGER) {
-      endpoint.key_type = obj_map["key_type"].via.i64;
+    if (obj_map.count("key_type") && (obj_map["key_type"].type == MSGPACK_OBJECT_POSITIVE_INTEGER || obj_map["key_type"].type == MSGPACK_OBJECT_NEGATIVE_INTEGER)) {
+      endpoint.key_type = static_cast<int>(obj_map["key_type"].via.i64);
     }

Based on past review comments.


402-404: Same uint8 packer issue in hkIssuer packing

Lines 403, 410, and 417 use msgpack_pack_unsigned_char which is not provided by msgpack-c. Replace with msgpack_pack_uint8.

Apply this diff:

-    std::ranges::for_each(issuer.issuer_id, [&pk](const auto&o){
-      msgpack_pack_unsigned_char(pk, o);
+    std::ranges::for_each(issuer.issuer_id, [&pk](uint8_t o){
+      msgpack_pack_uint8(pk, o);
     });

Repeat for issuer_pk and issuer_pk_x. Based on past review comments.

Also applies to: 409-411, 416-418


498-500: Same uint8 packer issue in readerData packing

Lines 499, 506, 513, 520, and 527 use msgpack_pack_unsigned_char for byte arrays. Replace with msgpack_pack_uint8.

Apply this diff for each field:

-    std::ranges::for_each(reader_data.reader_sk, [&pk](const auto&o){
-      msgpack_pack_unsigned_char(pk, o);
+    std::ranges::for_each(reader_data.reader_sk, [&pk](uint8_t o){
+      msgpack_pack_uint8(pk, o);
     });

Repeat for reader_pk, reader_pk_x, reader_gid, and reader_id. Based on past review comments.

Also applies to: 505-507, 512-514, 519-521, 526-528

main/HardwareManager.cpp (1)

463-464: Bug: stopping the wrong timer (success path).

Line 464 checks if m_pixelSuccessTimer is active but stops m_gpioFailTimer instead. This prevents the pixel success timer from being cleared and can leave the pixel stuck.

Apply this diff:

                     if(esp_timer_is_active(m_gpioSuccessTimer)) esp_timer_stop(m_gpioSuccessTimer);
-                    if(esp_timer_is_active(m_pixelSuccessTimer)) esp_timer_stop(m_gpioFailTimer);
+                    if(esp_timer_is_active(m_pixelSuccessTimer)) esp_timer_stop(m_pixelSuccessTimer);

Based on past review comments.

main/MqttManager.cpp (3)

77-84: Add EventManager subscriber cleanup to destructor.

The destructor (lines 77-84) stops and destroys the MQTT client but doesn't remove the three EventManager subscribers registered at lines 35-74 ("lock/stateChanged", "lock/altAction", "nfc/event"). This leaves dangling subscribers.

Add before the closing brace:

espp::EventManager::get().remove_subscriber("lock/stateChanged", "MqttManager");
espp::EventManager::get().remove_subscriber("lock/altAction", "MqttManager");
espp::EventManager::get().remove_subscriber("nfc/event", "MqttManager");

Based on past review comments.


464-475: Hex formatting drops leading bytes/doesn't format per-byte.

Lines 466-468 use fmt::format("{:02X}", fmt::join(...)) which applies formatting to the entire joined string rather than each byte individually, producing incorrect hex output.

Replace with a helper that formats each byte:

+    auto bytesToHex = [](const std::vector<uint8_t>& bytes) {
+        std::string out;
+        out.reserve(bytes.size() * 2);
+        for (auto b : bytes) fmt::format_to(std::back_inserter(out), "{:02X}", b);
+        return out;
+    };
     cJSON *doc = cJSON_CreateObject();
-    cJSON_AddStringToObject(doc, "issuerId", fmt::format("{:02X}", fmt::join(issuerId, "")).c_str());
-    cJSON_AddStringToObject(doc, "endpointId", fmt::format("{:02X}", fmt::join(endpointId, "")).c_str());
-    cJSON_AddStringToObject(doc, "readerId", fmt::format("{:02X}", fmt::join(readerId, "")).c_str());
+    cJSON_AddStringToObject(doc, "issuerId", bytesToHex(issuerId).c_str());
+    cJSON_AddStringToObject(doc, "endpointId", bytesToHex(endpointId).c_str());
+    cJSON_AddStringToObject(doc, "readerId", bytesToHex(readerId).c_str());

Also apply to publishUidTap (lines 493-496). Based on past review comments.


597-604: Client certificates should be optional.

Lines 601-603 return false if client certificate or key is missing, preventing standard server-side TLS (where only the server has a certificate). Most MQTT brokers don't require mutual TLS.

Apply this diff:

     if (!m_mqttSslConfig->clientCert.empty() && !m_mqttSslConfig->clientKey.empty()) {
         mqtt_cfg.credentials.authentication.certificate = m_mqttSslConfig->clientCert.c_str();
         mqtt_cfg.credentials.authentication.key = m_mqttSslConfig->clientKey.c_str();
-        ESP_LOGI(TAG, "TLS authentication configured");
+        ESP_LOGI(TAG, "Mutual TLS authentication configured");
+    } else if (!m_mqttSslConfig->clientCert.empty() || !m_mqttSslConfig->clientKey.empty()) {
+        ESP_LOGW(TAG, "Client certificate or key is incomplete - mutual TLS disabled");
     } else {
-        ESP_LOGI(TAG, "No client certificate and/or private key configured");
-        return false;
+        ESP_LOGI(TAG, "No client certificate configured - using server-side TLS only");
     }

Based on past review comments.

main/ConfigManager.cpp (2)

1-19: Add missing standard headers.

The file uses std::span (line 477), std::remove_if (line 1244), and ctype functions (isspace, isalnum at lines 1244, 1263) but doesn't include the required headers.

Apply this diff:

 #include "ConfigManager.hpp"
 #include "cJSON.h"
 #include "config.hpp"
+#include <algorithm>
+#include <cctype>
 #include <ranges>
+#include <span>
 #include <string>

Based on past review comments.


1244-1244: Fix unsafe ctype function calls with signed char arguments.

Lines 1244 and 1263 use ctype functions directly with char arguments, causing undefined behavior when char is signed and contains negative values.

Apply these diffs:

Line 1244:

-    base64Content.erase(std::remove_if(base64Content.begin(), base64Content.end(), ::isspace), base64Content.end());
+    base64Content.erase(std::remove_if(base64Content.begin(), base64Content.end(),
+        [](char ch){ return std::isspace(static_cast<unsigned char>(ch)); }), base64Content.end());

Line 1263:

-        } else if (!isalnum(c) && c != '+' && c != '/') {
+        } else if (!std::isalnum(static_cast<unsigned char>(c)) && c != '+' && c != '/') {

Based on past review comments.

Also applies to: 1263-1263

Comment thread data/.npmrc
@@ -0,0 +1 @@
engine-strict=true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Good practice: enforce strict engine checking.

Enabling engine-strict=true ensures all developers and CI use a compatible Node.js version, preventing environment-specific issues during the Svelte 5 frontend refactor.

Please verify that data/package.json defines a compatible Node.js version in the engines field. For reference, run:


🏁 Script executed:

#!/bin/bash
# Verify engines field in data/package.json
cat data/package.json | jq '.engines'

Length of output: 4


The verification reveals a critical issue: data/package.json does not define an engines field, making engine-strict=true non-functional. Let me gather more information about the package.json structure.




🏁 Script executed:

#!/bin/bash
# Check full structure of data/package.json
cat data/package.json | jq '.' | head -30

Length of output: 955


🏁 Script executed:

#!/bin/bash
# Check if root package.json has engines defined
if [ -f package.json ]; then
  echo "=== Root package.json engines field ===" 
  cat package.json | jq '.engines'
fi

Length of output: 0


Let me check the monorepo structure and whether a root package.json exists:


---

🏁 Script executed:

```shell
#!/bin/bash
# Find all package.json files
echo "=== All package.json files ===" 
find . -maxdepth 2 -name "package.json" -type f 2>/dev/null | sort

# Check if root package.json exists
if [ -f ./package.json ]; then
  echo ""
  echo "=== Root package.json engines field ===" 
  cat ./package.json | jq '.engines // "not defined"'
else
  echo ""
  echo "No root package.json found"
fi

Length of output: 78


Add engines field to data/package.json or remove engine-strict=true from .npmrc.

The engine-strict=true setting in data/.npmrc requires an engines field in data/package.json to function. Currently, data/package.json does not define engines, making the strict engine checking non-functional.

Add to data/package.json:

"engines": {
  "node": ">=X.Y.Z"
}

Replace X.Y.Z with the minimum Node.js version required for Svelte 5 and your dependencies (e.g., >=18.0.0).

🤖 Prompt for AI Agents
In data/.npmrc around line 1, engine-strict=true is enabled but
data/package.json has no engines field, so strict engine checking is
ineffective; either add an "engines" entry to data/package.json (e.g., "node":
">=18.0.0" or whatever minimum Node version your project requires for Svelte 5
and dependencies) or remove engine-strict=true from data/.npmrc; update
data/package.json to include the appropriate engines object with the chosen node
version and commit, or delete the line from data/.npmrc if you don't want engine
enforcement.

Comment on lines +6 to +13
let {
actions,
error,
}: { actions: ActionsConfig | null; error?: string | null } = $props();

let actionsConfig = $state<ActionsConfig>(
actions ?? {
neopixelSuccessColor: [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Avoid aliasing actions into actionsConfig and fix Reset behavior

When actions is non-null, actionsConfig is initialized to the same object:

let actionsConfig = $state<ActionsConfig>(actions ?? { ...defaults... });

This means:

  • All bind:value={actionsConfig.*} mutations also modify actions, so diff(actions, actionsConfig) often sees no changes.
  • resetForm = () => { actionsConfig = actions!; } just reassigns the already-mutated object; Reset doesn’t actually roll back.

Suggested pattern:

-  let actionsConfig = $state<ActionsConfig>(
-    actions ?? {
-      // defaults...
-    },
-  );
+  const defaultActions: ActionsConfig = {
+    // existing defaults...
+  };
+
+  let initialActions = $state<ActionsConfig>(
+    actions ? structuredClone(actions) : defaultActions,
+  );
+
+  let actionsConfig = $state<ActionsConfig>(structuredClone(initialActions));
@@
-  const saveActionsConfig = async (e: any): Promise<void> => {
+  const saveActionsConfig = async (e: SubmitEvent): Promise<void> => {
@@
-      if (!actionsConfig || !actions) return;
-      const result = await saveConfig("actions", diff(actions, actionsConfig));
+      const result = await saveConfig(
+        "actions",
+        diff(initialActions, actionsConfig),
+      );
       if (result.success) {
-        actionsConfig = result.data;
-        actions = result.data;
+        initialActions = structuredClone(result.data);
+        actionsConfig = structuredClone(result.data);
+        actions = result.data;
       }
@@
-  const resetForm = (): void => {
-    actionsConfig = actions!;
-  };
+  const resetForm = (): void => {
+    actionsConfig = structuredClone(initialActions);
+  };

This keeps an immutable snapshot for diffing and Reset while allowing the form to edit a separate working copy.

Also applies to: 41-56, 58-76


🌐 Web query:

In Svelte (with TypeScript), does mutating a prop-derived object used both as "original" and "edited" state break diff-based save logic (e.g., diff(old, new)) as described here?

💡 Result:

Short answer: Yes — if your "original" and "edited" vars reference the same object, in-place mutations will make them identical by reference and your diff(old, new) will miss changes (or your reactive reads won't update) — so the save/diff logic breaks.

Why (brief):

  • JS objects are reference-shared; mutating a prop-derived object changes the original reference the parent provided. [1]
  • Svelte's reactivity/derived values rely on assignments and referential differences; mutating nested properties without creating a new object can skip updates and cause derived values to appear unchanged. [2][3]

Fixes:

  • Clone the prop before editing (structuredClone, JSON, deep-clone, or shallow spread for top-level) so old and edited are different objects.
  • Make edits by replacing the object (obj = {...obj, changed: val}) so Svelte sees the assignment.
  • Or keep an immutable “original” copy and a separate “edited” copy for diffing.

Sources:
[1] Svelte docs — derived and update propagation. (svelte.dev)
[2] Practical note on mutating objects and needing reassignment in Svelte. (dev.to)
[3] Svelte 5 migration/reactivity notes (assignments, $state/$derived). (svelte.jp)

Citations:


Clone actions to prevent mutations from breaking diff and Reset logic

When actions is non-null, actionsConfig is initialized to the same object. This causes all form mutations to also modify actions, breaking the diff-based save and Reset:

  • diff(actions, actionsConfig) compares the same object (no changes detected)
  • resetForm reassigns the already-mutated object (cannot revert)

Use structuredClone to maintain an immutable snapshot for diffing and Reset:

+  const defaultActions: ActionsConfig = {
+    // existing defaults...
+  };
+
+  let initialActions = $state<ActionsConfig>(
+    actions ? structuredClone(actions) : defaultActions,
+  );
+
-  let actionsConfig = $state<ActionsConfig>(
-    actions ?? {
-      // defaults...
-    },
-  );
+  let actionsConfig = $state<ActionsConfig>(structuredClone(initialActions));
@@
-  const saveActionsConfig = async (e: any): Promise<void> => {
+  const saveActionsConfig = async (e: SubmitEvent): Promise<void> => {
@@
-      if (!actionsConfig || !actions) return;
-      const result = await saveConfig("actions", diff(actions, actionsConfig));
+      const result = await saveConfig(
+        "actions",
+        diff(initialActions, actionsConfig),
+      );
       if (result.success) {
-        actionsConfig = result.data;
-        actions = result.data;
+        initialActions = structuredClone(result.data);
+        actionsConfig = structuredClone(result.data);
+        actions = result.data;
       }
@@
-  const resetForm = (): void => {
-    actionsConfig = actions!;
-  };
+  const resetForm = (): void => {
+    actionsConfig = structuredClone(initialActions);
+  };

Also applies to: lines 41–56, 58–76

🤖 Prompt for AI Agents
In data/src/lib/components/AppActions.svelte around lines 6 to 13 (and also
apply the same change at lines 41–56 and 58–76), the component assigns
actionsConfig to the same object reference as props.actions which allows
in-place mutations to alter the original props and break diff-based save and
Reset logic; fix by creating a deep clone of actions when initializing
actionsConfig (use structuredClone(actions) when actions is non-null, otherwise
keep the default literal), and do the same wherever actionsConfig is initialized
or re-assigned so diff(actions, actionsConfig) compares different objects and
resetForm can restore the original snapshot.

Comment on lines +31 to +32
let currentTime = $state(new Date());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Use browser-safe timeout types and initialize resizeObserver defensively

NodeJS.Timeout isn’t ideal in browser code and resizeObserver is declared but not initialized as nullable, yet checked in onDestroy.

Recommend:

-	let currentTime = $state(new Date());
+	let currentTime = $state(new Date());
@@
-	let timeUpdateInterval = $state<NodeJS.Timeout>();
-  var resizeObserver : ResizeObserver;
+	let timeUpdateInterval = $state<ReturnType<typeof setInterval> | null>(null);
+	let resizeObserver: ResizeObserver | null = null;
@@
-	onDestroy(() => {
-		if (timeUpdateInterval) {
-			clearInterval(timeUpdateInterval);
-		}
+	onDestroy(() => {
+		if (timeUpdateInterval) {
+			clearInterval(timeUpdateInterval);
+			timeUpdateInterval = null;
+		}
@@
-    if (resizeObserver) {
-      resizeObserver.disconnect();
-    }
+    if (resizeObserver) {
+      resizeObserver.disconnect();
+      resizeObserver = null;
+    }

This avoids Node.js typings in the browser and matches the nullable runtime checks.

Also applies to: 149-151, 224-234


🌐 Web query:

In a Svelte (browser) project, is `ReturnType<typeof setTimeout>` the recommended type for timer handles instead of `NodeJS.Timeout`?

💡 Result:

Short answer: Yes — using ReturnType is the safest/recommended choice for a browser Svelte project if you want a portable, correct type. It matches whatever the host environment's declaration is (number in the DOM, NodeJS.Timeout in Node), avoiding errors when @types/node leaks into your build. If you are absolutely certain your compile environment uses only the DOM lib, window.setTimeout() (or number) is fine; otherwise prefer ReturnType (or window.setTimeout for browser-only code). [1][2][3]

Sources:

  • Stack Overflow discussion (ReturnType solution and browser vs Node differences). [1]
  • Explanation and examples recommending ReturnType to avoid @types/node leakage. [2]
  • Practical guide describing ReturnType workaround and window.setTimeout alternative. [3]


---

🏁 Script executed:

```shell
#!/bin/bash
# Inspect the LogViewer.svelte file at the specified lines
fd -n LogViewer.svelte --type f --exec cat {} \; | head -250 | tail -220

Length of output: 230



---

🏁 Script executed:

```shell
#!/bin/bash
# Find and display LogViewer.svelte
fd LogViewer.svelte --type f -x cat {}

Length of output: 13605


Fix timer type and initialize resizeObserver with proper null handling

Verified: The code uses NodeJS.Timeout in a browser Svelte project (lines 149) and declares resizeObserver without initialization or nullable type annotation (line 150). Web search confirms ReturnType<typeof setInterval> is the recommended approach for browser code to avoid Node.js type leakage.

-	let timeUpdateInterval = $state<NodeJS.Timeout>();
-  var resizeObserver : ResizeObserver;
+	let timeUpdateInterval = $state<ReturnType<typeof setInterval> | null>(null);
+	let resizeObserver: ResizeObserver | null = null;
@@
-	onDestroy(() => {
-		if (timeUpdateInterval) {
-			clearInterval(timeUpdateInterval);
-		}
+	onDestroy(() => {
+		if (timeUpdateInterval) {
+			clearInterval(timeUpdateInterval);
+			timeUpdateInterval = null;
+		}
@@
-    if (resizeObserver) {
-      resizeObserver.disconnect();
-    }
+    if (resizeObserver) {
+      resizeObserver.disconnect();
+      resizeObserver = null;
+    }

Also applies to: 149-151, 224-234

🤖 Prompt for AI Agents
In data/src/lib/components/LogViewer.svelte around lines 31-32 and also apply to
149-151 and 224-234: the timer and resizeObserver types are incorrect for
browser Svelte code—replace any NodeJS.Timeout annotations with
ReturnType<typeof setInterval> for browser-safe typing, declare and initialize
resizeObserver as ResizeObserver | null = null, and update any code that
sets/clears the interval or uses the observer to handle the nullable type (check
for null before calling .disconnect() or .observe()). Ensure interval variables
are assigned from setInterval and cleared with clearInterval, and add proper
null checks where resizeObserver is used.

Comment on lines +149 to +221
let timeUpdateInterval = $state<NodeJS.Timeout>();
var resizeObserver : ResizeObserver;

onMount(() => {
const savedLogLevels = localStorage.getItem("logviewer-levels");
if (savedLogLevels) {
logLevels = JSON.parse(savedLogLevels);
}
timeUpdateInterval = setInterval(() => {
currentTime = new Date();
}, 1000);
lastScrollTop =
document.querySelector("#virtual-list-viewport")?.scrollTop || 0;
const contentList = document.getElementById("virtual-list-content");
if (contentList) {
resizeObserver = new ResizeObserver(() => {
atBottom =
document.querySelector("#virtual-list-viewport")!
.scrollTop >=
document
.querySelector("#virtual-list-content")!
.getBoundingClientRect().height -
document
.querySelector("#virtual-list-container")!
.getBoundingClientRect().height;
});
resizeObserver.observe(contentList);
}
document
.querySelector("#virtual-list-viewport")
?.addEventListener("scroll", (e) => {
const target = e.target as HTMLDivElement;
const scrollTop = target.scrollTop;
const contentHeight = document
.querySelector("#virtual-list-content")!
.getBoundingClientRect().height;
const containerHeight = document
.querySelector("#virtual-list-container")!
.getBoundingClientRect().height;
atBottom = scrollTop >= contentHeight - containerHeight;
const scrollDelta = lastScrollTop - scrollTop;
const currentDirection =
scrollDelta > 0 ? 1 : scrollDelta < 0 ? -1 : 0;

if (
currentDirection !== 0 &&
currentDirection !== lastScrollDirection
) {
cumulativeScrollDistance = 0;
lastScrollDirection = currentDirection;
}

if (currentDirection !== 0) {
cumulativeScrollDistance += Math.abs(scrollDelta);
}

if (cumulativeScrollDistance > SCROLL_THRESHOLD) {
if (scrollDebounceTimer) {
clearTimeout(scrollDebounceTimer);
}

scrollDebounceTimer = setTimeout(() => {
if (currentDirection === 1) {
scrollDirection = 1; // Scrolling up
} else if (currentDirection === -1) {
scrollDirection = 0; // Scrolling down
}
cumulativeScrollDistance = 0;
}, 30);
}

lastScrollTop = scrollTop;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Avoid brittle global document.querySelector lookups and missing IDs

The scroll/resize logic relies on document.querySelector("#virtual-list-viewport"), #virtual-list-content, and #virtual-list-container, all asserted non-null with !, but this component’s template does not define elements with those IDs. If the virtual list implementation or surrounding layout does not provide matching IDs, these non-null assertions will throw at runtime.

Safer approaches:

  • Attach bind:this refs to the actual container/viewport elements you control and use those instead of global selectors.
  • If you must query by selector, remove the ! and handle null cases gracefully before reading scrollTop or getBoundingClientRect().
  • Store the scroll handler function in a variable and remove it in onDestroy with removeEventListener to avoid leaks.

At minimum, please ensure these IDs are actually present in the rendered DOM and guard against null before dereferencing.


🌐 Web query:

Does `@humanspeak/svelte-virtual-list` expose fixed IDs like `#virtual-list-viewport` and `#virtual-list-content`, or should consumers manage their own container refs?

💡 Result:

Short answer: No — it doesn't expose fixed DOM IDs. The package exposes class/test-id props (containerClass, viewportClass, contentClass, itemsClass, testId) so you can style or target elements, and consumers should provide/manage their own refs if they need direct element references. [1][2]

Sources:

  • package README / npm (props: containerClass, viewportClass, contentClass, itemsClass, testId). [1]
  • older npm/version listing showing same props and usage. [2]

The virtual list library does not expose fixed DOM IDs—these selectors will fail at runtime

The @humanspeak/svelte-virtual-list package does not expose fixed DOM IDs like #virtual-list-viewport and #virtual-list-content. Instead, it provides class and testId props (containerClass, viewportClass, contentClass, itemsClass, testId), and consumers must manage their own refs if they need direct element references.

The current code assumes these IDs exist and uses non-null assertions (!), which will throw at runtime when querySelector returns null.

Required fixes:

  • Remove all document.querySelector lookups for these IDs or guard each with null checks before dereferencing
  • Use bind:this to capture actual viewport/content/container element refs from the template
  • Reference those refs directly instead of relying on global selectors
  • Add the onDestroy cleanup to remove the scroll event listener

Comment on lines +29 to +44
let statusColor = $derived((): string => {
switch (websocketState.state) {
case "open":
return "text-success";
case "disconnected":
return "text-error";
case "failed":
return "text-error";
case "error":
return "text-warning";
case "connecting":
return "text-info";
default:
return "text-base-content";
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Make statusColor a derived string, not a function.

statusColor is currently a $derived of an arrow function, so it holds the function itself. Using {statusColor} in the class attribute will yield a function string instead of "text-success", "text-error", etc.

Refactor statusColor to derive the class string directly:

-let statusColor = $derived((): string => {
-	switch (websocketState.state) {
-		case "open":
-			return "text-success";
-		case "disconnected":
-			return "text-error";
-		case "failed":
-			return "text-error";
-		case "error":
-			return "text-warning";
-		case "connecting":
-			return "text-info";
-		default:
-			return "text-base-content";
-	}
-});
+let statusColor = $derived(
+	websocketState.state === "open"
+		? "text-success"
+		: websocketState.state === "disconnected" ||
+				websocketState.state === "failed"
+			? "text-error"
+			: websocketState.state === "error"
+				? "text-warning"
+				: websocketState.state === "connecting"
+					? "text-info"
+					: "text-base-content",
+);

The template can then keep:

<div class="stat-value text-sm {statusColor}">
	{websocketState.state}
</div>

Also applies to: 291-299


I need to verify the Svelte 5 $derived behavior and examine the actual file usage.<!-- [scratchpad_update]
[task review_validation_1/2 verify_derived_behavior]
[observations]

  • Web search confirms: "$derived(expression)" evaluates the expression and returns its value
  • Per Svelte docs 1-5: "$derived(expression) is equivalent to $derived.by(() => expression)"
  • This means if you write $derived((): string => { ... }), you're passing a function definition as the expression
  • The result would be the function object itself, NOT the return value of calling the function
    [analysis]
  • Review is CORRECT: statusColor = $derived((): string => { ... }) does make statusColor a function
  • The fix should wrap the switch statement properly so $derived receives the return value, not the function itself
  • Example fix: either $derived.by(() => { switch ... }) OR remove the arrow function syntax entirely
    [pending]
  • Verify actual file usage in template and confirm lines 291-299 issue
    [actions]
  • Run script to examine WebSocketTest.svelte file
    [scratchpad_end] -->

Now let me examine the actual file to verify the template usage and the issue at lines 291-299:


<!-- [scratchpad_update]
[task review_validation_1/2 verify_derived_behavior_file_check]
[observations]
- Need to locate WebSocketTest.svelte file in repo first
[actions]
- Run script to find and examine WebSocketTest.svelte
[scratchpad_end] -->

---

🏁 Script executed:

```shell
#!/bin/bash
# Examine the WebSocketTest.svelte file to verify the issue
fd -e svelte -n WebSocketTest | head -1 | xargs cat

Length of output: 230


🏁 Script executed:

#!/bin/bash
# Search for WebSocketTest.svelte in the repository
fd -type f -name 'WebSocketTest.svelte' 2>/dev/null | head -5

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find and examine WebSocketTest.svelte
fd -type f -name 'WebSocketTest.svelte' | head -1 | xargs cat

Length of output: 170


Let me verify the Svelte 5 $derived behavior and try a different approach to locate the file:





🏁 Script executed:

#!/bin/bash
# Search for statusColor in the codebase to locate the file
rg -l "statusColor" --type svelte

Length of output: 30


🌐 Web query:

Svelte 5 $derived rune arrow function behavior

💡 Result:

Short answer: $derived expects an expression (not a callback). If you pass an arrow function itself you get that function as the derived value (it won't be auto-invoked or tracked). For multiline or callback-style computations use $derived.by (or the documented helper) so Svelte will call the function and track dependencies. Also: derived expressions must be free of side effects (you cannot mutate $state inside them).

Examples:

  • Correct (expression): let doubled = $derived(count * 2). [1][2]
  • Wrong if you expect it to compute: let doubled = $derived(() => count * 2) — this sets doubled to a function. Use $derived.by(() => { ...; return value; }) for callback-style logic. [1][4]
  • Note: side effects inside derived expressions are disallowed. [1]

Sources: Svelte docs (/$derived) and Svelte runes announcement; GitHub discussion about callback-style $derived. [1][2][4]






🏁 Script executed:

#!/bin/bash
# Search for statusColor without type filter
rg "statusColor" -C2 | head -50

Length of output: 759


🏁 Script executed:

#!/bin/bash
# Search in data/src directory specifically
find data/src -name "WebSocketTest.svelte" 2>/dev/null

Length of output: 44



---

🏁 Script executed:

```shell
#!/bin/bash
# View lines 291-299 of WebSocketTest.svelte
sed -n '285,305p' data/src/lib/components/WebSocketTest.svelte

Length of output: 687



---

🏁 Script executed:

```shell
#!/bin/bash
# Find all $derived declarations in WebSocketTest.svelte to identify the second issue
rg '\$derived' data/src/lib/components/WebSocketTest.svelte -n -A5

Length of output: 187



---

🏁 Script executed:

```shell
#!/bin/bash
# Get all $derived declarations in the file with line numbers
rg '\$derived' data/src/lib/components/WebSocketTest.svelte -n

Length of output: 46


Make statusColor a derived string, not a function.

statusColor is currently a $derived of an arrow function, so it holds the function itself. Using {statusColor} in the class attribute will yield a function string instead of "text-success", "text-error", etc.

Refactor statusColor to derive the class string directly:

-let statusColor = $derived((): string => {
-	switch (websocketState.state) {
-		case "open":
-			return "text-success";
-		case "disconnected":
-			return "text-error";
-		case "failed":
-			return "text-error";
-		case "error":
-			return "text-warning";
-		case "connecting":
-			return "text-info";
-		default:
-			return "text-base-content";
-	}
-});
+let statusColor = $derived.by(() => {
+	switch (websocketState.state) {
+		case "open":
+			return "text-success";
+		case "disconnected":
+			return "text-error";
+		case "failed":
+			return "text-error";
+		case "error":
+			return "text-warning";
+		case "connecting":
+			return "text-info";
+		default:
+			return "text-base-content";
+	}
+});

This ensures statusColor evaluates to the returned string value, not the function itself. The template at line 294 will then correctly interpolate the class string.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let statusColor = $derived((): string => {
switch (websocketState.state) {
case "open":
return "text-success";
case "disconnected":
return "text-error";
case "failed":
return "text-error";
case "error":
return "text-warning";
case "connecting":
return "text-info";
default:
return "text-base-content";
}
});
let statusColor = $derived.by(() => {
switch (websocketState.state) {
case "open":
return "text-success";
case "disconnected":
return "text-error";
case "failed":
return "text-error";
case "error":
return "text-warning";
case "connecting":
return "text-info";
default:
return "text-base-content";
}
});
🤖 Prompt for AI Agents
In data/src/lib/components/WebSocketTest.svelte around lines 29–44, statusColor
is currently assigned using $derived of an arrow function which stores the
function itself instead of a derived string; replace this with a proper derived
store that takes the websocketState store as input (e.g. derived(websocketState,
ws => { switch (ws.state) { ... } })) so the derived returns the class string
directly, remove the leading $ when calling derived, and ensure derived is
imported from 'svelte/store' and the result is typed/returned as a string.

Comment on lines +5 to +20
## 1. Overview

The `HomeKitLock` class serves as the central bridge between the application's core logic and the Apple HomeKit ecosystem, facilitated by the HomeSpan library. It is designed as a singleton and is responsible for initializing the HomeKit accessory, defining its services and characteristics, managing the network connection (Wi-Fi or Ethernet), and synchronizing the lock's state with HomeKit.

A critical function of this class is managing the lifecycle of HomeKey issuers. It listens for changes in paired HomeKit controllers and automatically updates the `ReaderDataManager` with the necessary cryptographic keys (LTPK), ensuring that newly paired devices can use HomeKey.

### Key Responsibilities:

* **HomeSpan Initialization:** Configures and starts the HomeSpan framework, setting up the device as a HomeKit lock accessory.
* **Accessory Definition:** Creates all necessary HomeKit services, including the Lock Mechanism, Lock Management, NFC Access, and an optional Battery Service.
* **State Synchronization:** Subscribes to internal application events to update the lock's current state, target state, and battery status in HomeKit in real-time.
* **Network Management:** Handles the initialization of an Ethernet connection if configured, with a fallback to Wi-Fi managed by HomeSpan.
* **HomeKey Issuer Management:** Automatically synchronizes the list of trusted HomeKey issuers with the list of paired HomeKit admin controllers.
* **Debug Interface:** Provides a set of serial commands for runtime debugging and diagnostics.

## 2. Public API

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Docs look consistent; just re-run markdownlint to confirm

The HomeKitLock API doc reads clearly and headings/sections look structurally sound in this version. The earlier markdownlint hints (MD023/MD036/MD051) don’t obviously match the current content, so they may be stale.

I’d just re-run markdownlint-cli2 locally or rely on CI; if it still flags specific lines, we can adjust those headings or any implicit “emphasis as heading” it calls out, but from this snippet the structure is fine.

Also applies to: 44-50, 115-132


🌐 Web query:

What exact rules and sample corrections does markdownlint-cli2 suggest for MD023, MD036, and MD051 on documents similar to this HomeKitLock API doc?

💡 Result:

Here are the exact rule summaries plus the typical failing and corrected examples markdownlint/markdownlint-cli2 reports for MD023, MD036 and MD051 (suitable for API docs like a HomeKitLock spec).

  1. MD023 — “Headings must start at the beginning of the line”
  • What it checks: any heading line that is indented (leading space(s)) is flagged because many parsers will not treat it as a heading.
  • Failing example:
    (indented heading)
    # Indented heading
  • Corrected example:
    (heading at line start)

    Indented heading

  • Autofix: tooling removes the leading spaces so the heading starts at column 1.
  • Sources: markdownlint heading rules / docs. [1][2]
  1. MD036 — “Emphasis used instead of a heading”
  • What it checks: single-line paragraphs made up entirely of emphasized text (bold/italic) used as section separators — these should be real headings instead. The rule ignores emphasized text that ends with punctuation (configurable).
  • Failing example:
    My document
    Lorem ipsum…
    Another section
  • Corrected example:

    My document

    Lorem ipsum…

    Another section

  • Autofix: not always automatic — the suggested correction is to replace the emphasized paragraph with an appropriate ATX/SetExt heading. (Rule has a punctuation parameter to allow trailing punctuation.)
  • Sources: markdownlint rule docs. [3]
  1. MD051 — “Link fragments should be valid” (aka link-fragments)
  • What it checks: internal link fragments (#[anchor]) must match a heading anchor target in the same document (or valid HTML id anchors). Broken/changed headings that no longer produce the linked fragment are flagged.
  • Failing example:
    See the section ← no matching heading in the doc
  • Corrected examples:
    • Update link to match existing heading: See the section
    • Or add the matching heading: ## Non-existent heading
    • Or replace with a full URL if the target is in another file.
  • Autofix: not generally auto-fixed; the checklist is to either change the link text/fragment to match a real heading or restore the heading/HTML anchor. Recent markdownlint improvements expanded HTML anchor support and matching behavior.
  • Sources: markdownlint rule list and changelog/issue notes about MD051 improvements. [4][5]

If you want, I can run markdownlint-cli2 --fix (or a dry run) on your actual HomeKitLock API Markdown file and show the exact violations plus suggested edits (I’ll include the precise lines and proposed replacements).


I have the markdownlint-cli2 rule definitions now. To verify whether the original review comment is accurate, I need to inspect the actual HomeKitLock.md file at the line ranges mentioned (especially 44-50 and 115-132 where issues were flagged).


🏁 Script executed:

#!/bin/bash
# Retrieve the HomeKitLock.md file and examine the specified line ranges
fd -t f "HomeKitLock.md" | head -1 | xargs -I {} sh -c '
  echo "=== Lines 5-20 ==="
  sed -n "5,20p" "$1"
  echo ""
  echo "=== Lines 44-50 ==="
  sed -n "44,50p" "$1"
  echo ""
  echo "=== Lines 115-132 ==="
  sed -n "115,132p" "$1"
' _ {}

Length of output: 3041


The file content reveals a real violation. Let me scan for all standalone bold/italic lines that would trigger MD036:


🏁 Script executed:

#!/bin/bash
# Find all standalone bold/italic lines that could be MD036 violations
# (lines that are entirely or primarily bold/italic, not within lists or inline)
fd -t f "HomeKitLock.md" | head -1 | xargs -I {} sh -c '
  echo "=== Checking for standalone bold/italic lines (MD036 candidates) ==="
  rg -n "^\s*\*\*[^*]+\*\*\s*$|^\s*__[^_]+__\s*$|^\s*_[^_]+_\s*$|^\s*\*[^*]+\*\s*$" "$1"
' _ {}

Length of output: 280


Now let me check for MD051 violations (broken link fragments):


🏁 Script executed:

#!/bin/bash
# Find internal markdown links with fragments to check for MD051 violations
fd -t f "HomeKitLock.md" | head -1 | xargs -I {} sh -c '
  echo "=== Checking for internal links with fragments (MD051 candidates) ==="
  rg -n "\[.*\]\(#[^)]+\)" "$1"
' _ {}

Length of output: 69


Convert bold section labels to proper heading syntax

Verification confirms MD036 violations at lines 28, 38, 50, 58, 69, 74, 82, 87, 97, and 106. These bold lines (**Signature:**, **Parameters:**, etc.) are being used as section separators and must be converted to proper markdown heading syntax (### or ####) instead. MD023 and MD051 are not violated.

Replace each standalone bold line with an appropriate heading level. For example:

  • **Signature:**### Signature
  • **Parameters:**### Parameters
  • **It performs the following key actions:**### It performs the following key actions
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

9-9: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)


12-12: Link fragments should be valid

(MD051, link-fragments)

🤖 Prompt for AI Agents
In docs/content/API/HomeKitLock.md around lines 5 to 20, standalone bold section
labels (e.g., **Signature:**, **Parameters:**, **It performs the following key
actions:**) must be converted to proper Markdown headings to address MD036;
replace each bold-only line with an appropriate heading (for example convert
`**Signature:**` and `**Parameters:**` to `### Signature` / `### Parameters`,
and convert phrase labels like `**It performs the following key actions:**` to
`### It performs the following key actions` or a lower-level heading if nested),
ensure you remove the surrounding asterisks and trailing colon and keep the rest
of the text unchanged, preserving indentation and any following content.

Comment thread docs/content/mqtt.md
Comment on lines +30 to +57
| Topic | Description | Payload Examples |
| :---- | :---------- | :--------------- |
| `<CLIENT_ID>/homekit/state` | **Publishes** the current state of the lock. This topic is retained, meaning new subscribers will immediately receive the last known state. | `0` (UNLOCKED), `1` (LOCKED), `2` (JAMMED), `3` (UNKNOWN), `4` (UNLOCKING), `5` (LOCKING) |
| `<CLIENT_ID>/homekit/set_state` | **Subscribes** to this topic to set both the current and target state of the lock. | To unlock: `0` To lock: `1` |
| `<CLIENT_ID>/homekit/set_current_state` | **Subscribes** to this topic to set the current state of the lock. Useful for external systems to update the device's perceived state. | `0` (UNLOCKED), `1` (LOCKED), `2` (JAMMED), `3` (UNKNOWN) |
| `<CLIENT_ID>/homekit/set_target_state` | **Subscribes** to this topic to set the target state of the lock. | To unlock: `0` To lock: `1` |
| `<CLIENT_ID>/homekit/set_battery_lvl` | **Subscribes** to this topic to set the battery level to be shown in HomeKit. This is used if "Proxy Battery Enabled" is active in the WebUI. | `85` (for 85% battery) |
| `<CLIENT_ID>/alt_action` | **Publishes** the status of the Alternate Action. | (Specific payload depends on configuration) |

> [!NOTE]
> You will notice all topics set by default are prefixed by the client id, this was done so the topics are nicely organized under a unique identifier, however, you can set the topics to whatever you wish from the [WebUI](../configuration#core-topics)

**Example: Manually Locking Your Door via MQTT**

Let's say you want to lock your door using a command-line MQTT client like `mosquitto_pub`.

```bash
mosquitto_pub -h your_mqtt_broker_address -p 1883 -t homekit/set_state -m "1"
```
(Replace `your_mqtt_broker_address` with your actual broker's IP or hostname.)

**Example: Subscribing to Lock State Updates**

To see your lock's current state in real-time:

```bash
mosquitto_sub -h your_mqtt_broker_address -p 1883 -t homekit/state
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align CLI MQTT topic examples with <CLIENT_ID> prefix usage.

The core topics table uses <CLIENT_ID>/homekit/..., but the mosquitto_pub/mosquitto_sub examples later use homekit/set_state and homekit/state without the prefix. For readers who copy‑paste these commands, this mismatch can be confusing. Either:

  • Include the <CLIENT_ID>/ prefix in the CLI examples (e.g., -t <CLIENT_ID>/homekit/set_state), or
  • Add a short note that the example assumes you’ve customized topics to drop the client‑ID prefix.
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

53-53: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)


55-55: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)


56-56: Unordered list indentation
Expected: 2; Actual: 4

(MD007, ul-indent)

🤖 Prompt for AI Agents
In docs/content/mqtt.md around lines 30 to 57, the CLI examples omit the
<CLIENT_ID>/ prefix used in the topics table which can confuse readers; update
the two command examples so they reference the prefixed topics (e.g., use
<CLIENT_ID>/homekit/set_state for mosquitto_pub and <CLIENT_ID>/homekit/state
for mosquitto_sub) or alternatively add a one‑line note above the examples
stating that the shown commands assume topics were customized to drop the
client-id prefix — make the change consistently in both commands and keep the
placeholder text (<CLIENT_ID>) so users can replace it with their actual client
id.

Comment thread main/HardwareManager.cpp
Comment on lines +30 to +94
HardwareManager::HardwareManager(const espConfig::actions_config_t& miscConfig)
: m_miscConfig(miscConfig),
m_feedbackTaskHandle(nullptr),
m_feedbackQueue(nullptr),
m_lockControlTaskHandle(nullptr),
m_lockControlQueue(nullptr)
{
espp::EventManager::get().add_subscriber(
"lock/action", "HardwareManager",
[&](const std::vector<uint8_t> &data) {
std::error_code ec;
EventLockState s = alpaca::deserialize<EventLockState>(data, ec);
if(!ec) {
setLockOutput(s.targetState);
}
}, 3072);
espp::EventManager::get().add_subscriber("nfc/event", "HardwareManager", [&](const std::vector<uint8_t> &data){
std::error_code ec;
NfcEvent event = alpaca::deserialize<NfcEvent>(data, ec);
if(ec) return;
switch(event.type) {
case HOMEKEY_TAP: {
EventHKTap s = alpaca::deserialize<EventHKTap>(event.data, ec);
if(!ec){
if(s.status) {showSuccessFeedback();triggerAltAction();} else showFailureFeedback();
}
break;
}
case TAG_TAP: {
EventTagTap s = alpaca::deserialize<EventTagTap>(event.data, ec);
if(!ec){
if (m_feedbackQueue != nullptr) {
FeedbackType feedback = FeedbackType::TAG_EVENT;
xQueueSend(m_feedbackQueue, &feedback, 0);
}
}
break;
}
break;
default:
break;
}
}, 4096);
espp::EventManager::get().add_subscriber(
"hardware/gpioPinChanged", "HardwareManager",
[&](const std::vector<uint8_t> &data) {
std::error_code ec;
EventValueChanged s = alpaca::deserialize<EventValueChanged>(data, ec);
if(!ec) {
uint8_t state = 0;
if(s.oldValue != 255 && s.oldValue < GPIO_NUM_MAX){
state = gpio_get_level(gpio_num_t(s.oldValue));
gpio_reset_pin(gpio_num_t(s.oldValue));
gpio_pullup_dis(gpio_num_t(s.oldValue));
}
if(s.newValue != 255){
pinMode(s.newValue, OUTPUT);
if(s.name == "gpioActionPin")
digitalWrite(s.newValue, state);
}
}
}, 3072);
espp::EventManager::get().add_publisher("lock/updateState", "HardwareManager");
espp::EventManager::get().add_publisher("lock/altAction", "HardwareManager");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify EventManager subscriber cleanup in destructor.

The constructor registers three EventManager subscribers ("lock/action", "nfc/event", "hardware/gpioPinChanged") at lines 37-91 with [&] capture. If the HardwareManager destructor doesn't remove these subscribers, they will outlive the object.

Verify the destructor removes all three subscribers:

If cleanup is missing, add removal calls for all three topics in the destructor.


🏁 Script executed:

#!/bin/bash
# Check if HardwareManager destructor removes EventManager subscribers
rg -n "~HardwareManager" main/HardwareManager.cpp -A 15
rg -n "remove_subscriber.*HardwareManager" main/

Length of output: 0



🏁 Script executed:

#!/bin/bash
# Search for HardwareManager class definition and destructor in header and cpp
echo "=== Searching for HardwareManager class definition ==="
fd -e h -e hpp -e cpp | xargs rg -l "class HardwareManager"

echo -e "\n=== Checking for destructor in header files ==="
fd -e h -e hpp | xargs rg -n "~HardwareManager|class HardwareManager" -B 2 -A 10

echo -e "\n=== Checking entire cpp file for destructor pattern ==="
rg -n "HardwareManager::" main/HardwareManager.cpp | head -20

echo -e "\n=== Searching for any destructor pattern in HardwareManager files ==="
rg -n "^\s*~" main/HardwareManager.cpp

Length of output: 3550


🏁 Script executed:

#!/bin/bash
# Read the complete HardwareManager header file
echo "=== HardwareManager.hpp content ==="
cat git/main/include/HardwareManager.hpp

echo -e "\n=== HardwareManager.cpp content ==="
cat git/main/HardwareManager.cpp

Length of output: 202



🏁 Script executed:

#!/bin/bash
# Understand directory structure and locate files
echo "=== Current directory ==="
pwd

echo -e "\n=== List root directory contents ==="
ls -la

echo -e "\n=== Find HardwareManager files ==="
find . -name "*HardwareManager*" -type f 2>/dev/null

echo -e "\n=== Check if git directory exists ==="
ls -la git/ 2>/dev/null || echo "No git directory"

Length of output: 1681


🏁 Script executed:

#!/bin/bash
# Read the complete HardwareManager header and implementation files
echo "=== HardwareManager.hpp content ==="
cat ./main/include/HardwareManager.hpp

echo -e "\n=== HardwareManager.cpp content ==="
cat ./main/HardwareManager.cpp

Length of output: 25478


Add destructor to remove EventManager subscribers.

The HardwareManager constructor registers three EventManager subscribers ("lock/action", "nfc/event", "hardware/gpioPinChanged") with [&] capture, but the class has no destructor to clean them up. When the HardwareManager object is destroyed, these subscribers remain registered with stale references, causing use-after-free when events are published to these topics.

Add a destructor in HardwareManager.hpp and implement it in HardwareManager.cpp:

// Header
~HardwareManager();

// Implementation
HardwareManager::~HardwareManager() {
    espp::EventManager::get().remove_subscriber("lock/action", "HardwareManager");
    espp::EventManager::get().remove_subscriber("nfc/event", "HardwareManager");
    espp::EventManager::get().remove_subscriber("hardware/gpioPinChanged", "HardwareManager");
}
🤖 Prompt for AI Agents
In main/HardwareManager.cpp around lines 30 to 94, the constructor registers
three EventManager subscribers with lambda captures but there is no destructor
to unregister them, leaving dangling subscribers after object destruction; add a
destructor declaration ~HardwareManager() to HardwareManager.hpp and implement
it in HardwareManager.cpp to call espp::EventManager::get().remove_subscriber
for "lock/action", "nfc/event", and "hardware/gpioPinChanged" using the same
subscriber name "HardwareManager" so the callbacks are removed when the object
is destroyed.

Comment thread main/NfcManager.cpp
Comment on lines +29 to +56
NfcManager::NfcManager(ReaderDataManager& readerDataManager,const std::array<uint8_t, 4> &nfcGpioPins)
: nfcGpioPins(nfcGpioPins),
m_readerDataManager(readerDataManager),
m_pollingTaskHandle(nullptr),
m_retryTaskHandle(nullptr),
m_ecpData({ 0x6A, 0x2, 0xCB, 0x2, 0x6, 0x2, 0x11, 0x0 })
{
espp::EventManager::get().add_publisher("nfc/event", "NfcManager");
espp::EventManager::get().add_subscriber("homekit/internal", "NfcManager", [&](const std::vector<uint8_t> &data){
std::error_code ec;
HomekitEvent event = alpaca::deserialize<HomekitEvent>(data, ec);
if(ec) return;
switch(event.type) {
case ACCESSDATA_CHANGED:
updateEcpData();
break;
case DEBUG_AUTH_FLOW: {
EventValueChanged s = alpaca::deserialize<EventValueChanged>(event.data, ec);
if(!ec){
authFlow = KeyFlow(s.newValue);
}
break;
}
default:
break;
}
}, 3072);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify EventManager subscriber cleanup in destructor.

The constructor registers an EventManager subscriber at lines 37-55 with [&] capture. If the NfcManager destructor doesn't call espp::EventManager::get().remove_subscriber("homekit/internal", "NfcManager"), the subscriber will outlive the object and access dangling references.

Verify the destructor removes this subscriber:

If the destructor doesn't remove the subscriber, add cleanup similar to the MqttManager pattern.


🏁 Script executed:

#!/bin/bash
# Check if NfcManager destructor removes EventManager subscribers
rg -n "~NfcManager" main/NfcManager.cpp -A 10
rg -n "remove_subscriber.*NfcManager" main/

Length of output: 0


I need to search more comprehensively. Let me check the header file and verify the destructor definition:

Now let me search the codebase more broadly for the NfcManager header and any destructor:


🏁 Script executed:

#!/bin/bash
# Find NfcManager header file
find main -name "*.h" -type f | xargs grep -l "class NfcManager"

# Also check for destructor in any C++ files for NfcManager
find main -type f \( -name "*.cpp" -o -name "*.h" \) | xargs grep -n "~NfcManager"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Broader search for NfcManager files anywhere
find . -type f \( -name "*.h" -o -name "*.cpp" -o -name "*.hpp" \) | xargs grep -l "class NfcManager\|~NfcManager" 2>/dev/null | head -20

# Also check what files exist in main directory
ls -la main/ | grep -i nfc

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Read the NfcManager header file
cat ./main/include/NfcManager.hpp

Length of output: 1288


Now let me verify that the EventManager API has a remove_subscriber method and check the MqttManager pattern for reference:


🏁 Script executed:

#!/bin/bash
# Check MqttManager for the correct pattern
grep -n "remove_subscriber" main/MqttManager.cpp -B 2 -A 2

# Also search for remove_subscriber usage patterns in codebase
rg -n "remove_subscriber" --type cpp --type hpp -C 3

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Search for remove_subscriber without file type restriction
rg -n "remove_subscriber" --type-list | head -5

# Try simpler approach - just search for the string
rg -n "remove_subscriber" -C 3

# Also check MqttManager destructor
rg -n "~MqttManager" -A 10

Length of output: 2808


🏁 Script executed:

#!/bin/bash
# Search for EventManager usage in MqttManager
rg -n "EventManager" main/MqttManager.cpp -B 2 -A 2

# Search for EventManager API definition
find . -name "*.h" -o -name "*.hpp" | xargs grep -l "class EventManager" 2>/dev/null

# Search for any add_subscriber calls to see patterns
rg -n "add_subscriber" -C 2

Length of output: 8112


🏁 Script executed:

#!/bin/bash
# Search for espp library files that might define EventManager
find . -path "*/espp/*" -name "*.h" -o -path "*/espp/*" -name "*.hpp" | head -20

# Also check if there's documentation or comments about cleanup
rg -n "unsubscribe\|remove_subscriber\|cleanup.*subscriber" -i

# Check MqttManager destructor to confirm it does NOT clean EventManager subscribers
cat main/MqttManager.cpp | grep -A 15 "MqttManager::~MqttManager"

Length of output: 575


🌐 Web query:

espp EventManager add_subscriber remove_subscriber API documentation

💡 Result:

The EventManager API (ESPP) exposes these relevant functions:

  • bool add_subscriber(const std::string &topic, const std::string &component, espp::EventManager::event_callback_fn callback, espp::Task::BaseConfig task_config = {}); — register a subscriber callback for topic (returns true on success). [1]

  • bool remove_subscriber(const std::string &topic, const std::string &component); — remove the subscriber previously registered for topic/component (returns true if removed). [1]

Reference docs (API page + component registry). [1][2]

Sources
[1] Event Manager APIs — ESPP (API reference & examples).
[2] espp/event_manager — ESP Component Registry (version info).


Add destructor to NfcManager to unsubscribe from EventManager.

The constructor at lines 37-55 registers an EventManager subscriber with [&] reference capture. The espp::EventManager API provides remove_subscriber() to clean up subscribers, but NfcManager lacks a destructor. When the object is destroyed, the subscriber remains active and any subsequent "homekit/internal" events will invoke the lambda with dangling references to destroyed members.

Add a destructor to main/include/NfcManager.hpp:

~NfcManager();

And implement it in main/NfcManager.cpp:

NfcManager::~NfcManager() {
  espp::EventManager::get().remove_subscriber("homekit/internal", "NfcManager");
}
🤖 Prompt for AI Agents
In main/NfcManager.cpp around lines 29-56 and corresponding header
main/include/NfcManager.hpp, the constructor registers a "homekit/internal"
subscriber with a lambda that captures by reference but there is no destructor
to remove the subscriber, causing potential use-after-free when NfcManager is
destroyed; add a destructor declaration (~NfcManager();) to
main/include/NfcManager.hpp and implement NfcManager::~NfcManager() in
main/NfcManager.cpp to call
espp::EventManager::get().remove_subscriber("homekit/internal", "NfcManager") so
the subscriber is removed during object teardown.

Comment thread README.md

### Project Structure

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language specifier to code fence (MD040).

The project structure code block is missing a language identifier, triggering markdownlint MD040:

-```
+```text
 HomeKey-ESP32/
 ├── main/                    # Core ESP32 application
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

228-228: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In README.md around line 228, the code fence for the project structure is
missing a language specifier which triggers markdownlint MD040; update the
opening triple-backtick to include a language identifier (e.g., ```text or
```bash) so the fenced code block has an explicit language, leaving the block
content unchanged.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
docs/content/setup.md (1)

106-106: Fix GitHub capitalization (duplicate issue).

Line 106 uses lowercase "Github"; the official name is "GitHub". This was previously flagged and should have been corrected.

-    * Download from the Github releases page [here](https://github.com/espressif/esptool/releases/latest)
+    * Download from the GitHub releases page [here](https://github.com/espressif/esptool/releases/latest)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6cee1b8 and 671ed0f.

⛔ Files ignored due to path filters (1)
  • docs/content/images/IMG_4025.jpeg is excluded by !**/*.jpeg
📒 Files selected for processing (2)
  • .github/workflows/esp32.yml (8 hunks)
  • docs/content/setup.md (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/esp32.yml
🧰 Additional context used
🪛 LanguageTool
docs/content/setup.md

[uncategorized] ~14-~14: The official name of this software platform is spelled with a capital “H”.
Context: ...kx/HomeKey-ESP32/releases/latest" icon="github" >}} 2. **Download the Latest Release:*...

(GITHUB)


[uncategorized] ~106-~106: The official name of this software platform is spelled with a capital “H”.
Context: ... ``` Or * Download from the Github releases page [here](https://github.com...

(GITHUB)

🪛 markdownlint-cli2 (0.18.1)
docs/content/setup.md

14-14: Bare URL used

(MD034, no-bare-urls)


34-34: Blank line inside blockquote

(MD028, no-blanks-blockquote)


106-106: Link text should be descriptive

(MD059, descriptive-link-text)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: esp32s3
  • GitHub Check: esp32c3
  • GitHub Check: esp32
  • GitHub Check: esp32c6

Comment thread docs/content/setup.md
Comment on lines +32 to +38
> [!NOTE]
> Both the ESP32 and the PN532 must use the same power supply to communicate.

> [!IMPORTANT]
> The PN532 has to be set to SPI mode, not I2C mode.
> The common red board has a DIP switch on a cornerside of the board that switches between I2C and SPI mode. See the image below for reference.
> ![PN532 SPI Mode](/images/IMG_4025.jpeg)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove blank line inside blockquote.

Markdown blockquotes should not contain blank lines between consecutive blockquote statements. Line 34 creates an unintended separation between the NOTE and IMPORTANT blockquotes.

 > [!NOTE]
 >
 > Both the ESP32 and the PN532 must use the same power supply to communicate.
-
-> [!IMPORTANT]
+> [!IMPORTANT]
 >
 > The PN532 has to be set to SPI mode, not I2C mode.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
> [!NOTE]
> Both the ESP32 and the PN532 must use the same power supply to communicate.
> [!IMPORTANT]
> The PN532 has to be set to SPI mode, not I2C mode.
> The common red board has a DIP switch on a cornerside of the board that switches between I2C and SPI mode. See the image below for reference.
> ![PN532 SPI Mode](/images/IMG_4025.jpeg)
> [!NOTE]
> Both the ESP32 and the PN532 must use the same power supply to communicate.
> [!IMPORTANT]
> The PN532 has to be set to SPI mode, not I2C mode.
> The common red board has a DIP switch on a cornerside of the board that switches between I2C and SPI mode. See the image below for reference.
> ![PN532 SPI Mode](/images/IMG_4025.jpeg)
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

34-34: Blank line inside blockquote

(MD028, no-blanks-blockquote)

🤖 Prompt for AI Agents
In docs/content/setup.md around lines 32 to 38, there is an extra blank line
between two Markdown blockquote lines causing the NOTE and IMPORTANT blockquotes
to be separated; remove the blank line (delete the empty line at line 34) so the
blockquote lines are consecutive and render as intended.

@rednblkx
rednblkx merged commit 6b22e91 into main Nov 22, 2025
7 checks passed
@rednblkx
rednblkx deleted the dev branch November 22, 2025 20:15
MadeInHelpup pushed a commit to MadeInHelpup/HomeKey-ESP32-1wire-lox that referenced this pull request May 22, 2026
* 📝 Add docstrings to `dev`

Docstrings generation was requested by @rednblkx.

* rednblkx#205 (comment)

The following files were modified:

* `data/src/services/api.js`
* `main/ConfigManager.cpp`
* `main/HKServices.cpp`
* `main/HardwareManager.cpp`
* `main/HomeKitLock.cpp`
* `main/LockManager.cpp`
* `main/MqttManager.cpp`
* `main/NfcManager.cpp`
* `main/ReaderDataManager.cpp`
* `main/WebServerManager.cpp`
* `main/WebSocketLogSinker.cpp`
* `main/include/WebServerManager.hpp`
* `main/include/WebSocketLogSinker.h`
* `main/include/config.hpp`
* `main/include/utils.hpp`
* `main/main.cpp`

* docs: fix wording

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: White Red <rednblkx@protonmail.com>
MadeInHelpup pushed a commit to MadeInHelpup/HomeKey-ESP32-1wire-lox that referenced this pull request May 22, 2026
Modularize backend and refactor frontend to Svelte 5

An extensize refactoring of the codebase that restructures it into 8 pillars of management to implement a separation of responsibilities as follows:
- ConfigManager: to handle passing configurations to other components
- HardwareManager: handling hardware actions (Simple GPIO and Pixel)
- LockManager: handling the internal state for the virtual lock that gets synced with HomeKit and Home Assistant
- NfcManager: handling all the NFC communications
- MqttManager: handling MQTT communications
- HomeKitLock: the star of the show, handling all HomeKit-related stuff
- ReaderDataManager: handling the private authentication data for HomeKey
- WebServerManager: handling the Web interface and APIs

The web interface also gets a new look using the Svelte 5 web framework and TailwindCSS + DaisyUI for the styling.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants