Skip to content

Commit bbac17b

Browse files
authored
Merge pull request #16 from distante/ssd/sec_touch_sniffer_functionality
feat(sec-touch): add sniffer component and prioritize SET tasks in the queue
2 parents a116c8b + af5d280 commit bbac17b

12 files changed

Lines changed: 944 additions & 67 deletions

File tree

.specs/sniffer.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Sniffer / Listener Mode
2+
3+
## Goal
4+
5+
Discover and capture UART messages sent by the hardware device for property IDs that have no registered listener, so unknown properties can be identified and mapped for future use.
6+
7+
---
8+
9+
## Behaviour
10+
11+
### Passive capture
12+
13+
When a message arrives for a property ID that no component has registered interest in, it is captured and recorded. Registered components (e.g. fan controls) are unaffected and continue to work normally.
14+
15+
### Active scan mode
16+
17+
An optional mode that can be toggled on and off by the user.
18+
19+
**When turned on:**
20+
- Regular polling stops completely. Any tasks that were already queued (e.g. pending fan SET commands) are discarded at this point.
21+
- Registered component listeners stop receiving updates.
22+
- The sniffer iterates through a configured range of property IDs, querying them one at a time. The next query is only issued after a response (or timeout) for the previous one has been processed.
23+
- Property IDs that already have registered listeners are silently skipped — they are not queried and do not appear in the output.
24+
25+
**When turned off:**
26+
- Scanning stops immediately. The last queried ID is saved.
27+
- Regular polling resumes at once, without waiting for the next scheduled poll cycle.
28+
29+
**Resume behaviour:** turning scan mode back on continues from the saved position. If the previous scan completed the full range, the next run restarts from the beginning.
30+
31+
---
32+
33+
## Recorded data
34+
35+
For each discovered property ID the sniffer stores:
36+
- The property ID.
37+
- The command type of the message (so responses to read requests can be distinguished from write notifications).
38+
- The last seen value.
39+
- The timestamp of the last update.
40+
41+
The timestamp uses the real-time clock when available; otherwise it falls back to device uptime.
42+
43+
---
44+
45+
## Outputs
46+
47+
- **Text sensor**: publishes the recorded entries as a comma-separated string in `id=value` format (e.g. `1=23, 2=13, 5=100`). The command type and timestamp are omitted from the sensor value but are still logged at `[I]` level for every discovery.
48+
- **During active scan**: the sensor is not updated while the scan is running. A single publish happens when the scan finishes (normally or when manually stopped mid-scan).
49+
- **In passive mode**: the sensor is updated on every new or changed entry, as before.
50+
- Duplicate messages that carry no new information never trigger a publish. The output is capped at 2048 characters (`MAX_STATE_LEN` in `sec_touch_sniffer.h`); entries beyond the cap are replaced with `...`.
51+
- **Switch** (optional): reflects whether scan mode is currently active and can be toggled from Home Assistant.
52+
53+
---
54+
55+
## Configuration
56+
57+
- The scan range (start and end ID) is configurable. `scan_start` must be ≤ `scan_end`; an invalid range is rejected at config validation time. If no range is configured, active scan mode is disabled and only passive capture is available.
58+
- The real-time clock source is optional.
59+
- The switch for scan state is optional.
60+
61+
---
62+
63+
## User interaction
64+
65+
A toggle switch switches scan mode on or off. No other interaction is required; the sniffer advances through IDs automatically and exits scan mode on its own when the range is exhausted.
66+
67+
---
68+
69+
## Implementation notes
70+
71+
### GET response protocol
72+
The SEC-Touch device responds to a GET request with two separate messages:
73+
1. `[STX][ACK][ETX]` — confirms receipt of the request
74+
2. `[STX]32[TAB]property_id[TAB]value[TAB]crc[ETX]` — the actual data
75+
76+
For unknown or unsupported property IDs the device sends `[STX][NAK][ETX]` instead of the data message. The component treats NAK as a failed task and advances the scan to the next ID.
77+
78+
Some property values are space-padded to a fixed width (e.g. `"00 "`). This makes the data message longer than the typical case and may cause it to arrive split across multiple UART read cycles. The `loop()` function handles this by buffering partial messages across calls and only processing once ETX is received.
79+
80+
The component keeps the task in `GET_DATA` state after receiving the ACK so that the queue-empty callback (which drives scan advancement) cannot fire before the data/NAK message arrives.
81+
82+
### Scan timeout and retry
83+
When a scan task receives no response within `TASK_TIMEOUT_MS` (2 s), the watchdog fires and the sniffer waits **5 seconds** before retrying that property ID once. If the retry also times out, the ID is logged as unresponsive and the scan advances to the next one immediately (no further wait).
84+
85+
The watchdog check runs regardless of whether the task queue is empty. Tasks are popped from the queue when dispatched, so the queue is empty while waiting for a response — the check must happen unconditionally in `loop()`, not inside the queue-empty guard.
86+
87+
If the user stops the scan during the 5-second retry wait, the pending retry is cancelled and normal polling resumes immediately.
88+
89+
### Text sensor state and MQTT
90+
The text sensor publishes a compact `id=value` list on scan completion (or manual stop). With a full scan (200+ IDs) the state string can exceed 1600 characters. If MQTT is used instead of the native API, the broker's `max_packet_size` must be set to at least 4096 bytes — Mosquitto defaults to 256 bytes and will silently drop larger publishes, causing HA to show "unknown". The ESPHome native API has no such limit.
91+
92+
Note: discovered state is held in RAM only. After a device restart or firmware flash the state is lost and the scan must be re-run.
93+
94+
### Memory on constrained hardware
95+
`discovered_ids_` is an `std::map` that grows with the number of responsive property IDs found. Each entry takes ~80–100 bytes on the heap (red-black tree node + `SniffedEntry`). A scan that discovers 200 IDs uses roughly 20 KB — safe on ESP32 (~300 KB free heap) but **not recommended on ESP8266** (~40 KB usable heap). Keep scan ranges very modest on ESP8266, or avoid active scan entirely.
96+
97+
98+
1. With scan mode disabled, captured unknown IDs appear in the text sensor output and do not interfere with existing controls.
99+
2. Turning scan mode on stops normal polling and listener updates; the text sensor populates with discovered IDs across the configured range.
100+
3. Turning scan mode off mid-scan resumes normal polling immediately; the saved position is used when scan mode is turned on again.
101+
4. When scan mode runs to completion, polling resumes automatically and the binary sensor reflects the off state.
102+
5. IDs with registered listeners do not appear in passive capture output and are skipped in scan mode, so they do not appear in scan output either.

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# ESPHome Components — Development Guidelines
2+
3+
## C++ brace style
4+
5+
Always wrap `if`/`else`/`for`/`while` bodies in braces `{}`, even single-line bodies. Never use brace-free single-line control flow.
6+
7+
## Early exit
8+
9+
Always exit early when a condition makes the rest of a function irrelevant. Check preconditions and guard clauses at the top and return immediately. Avoid wrapping the main logic in an `if` block when an inverted early return keeps the happy path at the lowest indentation level.

components/sec_touch/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515

1616

1717
CONF_SEC_TOUCH_ID = "sec_touch_id"
18+
CONF_INTER_TASK_DELAY = "inter_task_delay_ms"
1819
# The total of fan pairs that the SEC-Touch shows in the screen
1920

2021
CONFIG_SCHEMA = cv.Schema(
2122
{
2223
cv.GenerateID(): cv.declare_id(SECTouchComponent),
24+
cv.Optional(CONF_INTER_TASK_DELAY, default=30): cv.positive_int,
2325
}
2426
)
2527

@@ -44,3 +46,4 @@ async def to_code(config):
4446
var = cg.new_Pvariable(config[CONF_ID])
4547
await cg.register_component(var, config)
4648
await uart.register_uart_device(var, config)
49+
cg.add(var.set_inter_task_delay(config[CONF_INTER_TASK_DELAY]))

components/sec_touch/_definitions.h

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ constexpr const char *NAME_MAPPING[NAME_MAPPING_COUNT] = {
101101
*/
102102
using UpdateCallbackListener = std::function<void(int property_id, int new_value)>;
103103

104+
// Includes command_id so the sniffer can record whether the device message was
105+
// a data response (command_id=32) or, if the hardware ever sends unsolicited messages
106+
// with a different command_id, distinguish those as well.
107+
using RawMessageCallbackListener = std::function<void(int command_id, int property_id, int new_value)>;
108+
109+
// Called when the task queue is empty and no task is awaiting a response
110+
using QueueEmptyListener = std::function<void()>;
111+
104112
// Helper function to check if an ID is in the array
105113
template<typename T, size_t N> static bool contains(const std::array<T, N> &arr, int value) {
106114
return std::find(arr.begin(), arr.end(), value) != arr.end();
@@ -157,7 +165,7 @@ struct IncomingMessage {
157165

158166
public:
159167
char buffer[64];
160-
size_t buffer_index = -1;
168+
int buffer_index = -1;
161169

162170
void reset() {
163171
this->buffer_index = -1;
@@ -193,7 +201,7 @@ struct IncomingMessage {
193201
* @returns the index of the last byte stored in the buffer
194202
*/
195203
int store_data(uint8_t data) {
196-
if (this->buffer_index + 1 < sizeof(this->buffer) - 1) {
204+
if (this->buffer_index + 1 < (int) sizeof(this->buffer) - 1) {
197205
this->buffer_index++;
198206
this->buffer[this->buffer_index] = data;
199207

@@ -243,6 +251,11 @@ struct GetDataTask : public BaseTask {
243251
return nullptr; // Null unique_ptr if validation fails
244252
}
245253

254+
// Bypasses ID validation — for sniffer/discovery use only
255+
static std::unique_ptr<GetDataTask> create_unchecked(int property_id) {
256+
return std::unique_ptr<GetDataTask>(new GetDataTask(TaskTargetType::LEVEL, property_id));
257+
}
258+
246259
TaskType get_task_type() const override { return TaskType::GET_DATA; }
247260

248261
private:

0 commit comments

Comments
 (0)