Skip to content

Commit 99f6c97

Browse files
authored
Merge pull request #31 from AndrewDemsDS/fix/display-tristate-52
fix(display): make the Display switch actually control the panel
2 parents c559472 + 380e099 commit 99f6c97

11 files changed

Lines changed: 247 additions & 40 deletions

File tree

firmware/esp32-matter/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# . ~/esp/esp-idf-v5.5.4/export.sh && . $ESP_MATTER_PATH/export.sh
33
# idf.py set-target esp32 && idf.py build flash monitor
44
cmake_minimum_required(VERSION 3.16)
5-
set(PROJECT_VER "1.0.10")
5+
set(PROJECT_VER "1.0.11")
66

77
# Unified versioning (issue #77): the Matter softwareVersion INT is DERIVED from PROJECT_VER --
88
# MAJOR*10000+MINOR*100+PATCH -> a readable, strictly-monotonic uint32. This keeps the human

firmware/esp32-matter/main/app_main.cpp

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ static constexpr uint32_t kMfgClusterId = 0xFFF1FC00;
8383
// reference so the first combined frame is always valid.
8484
static HisenseCommand s_cmd = { HISENSE_MODE_COOL, 24, false,
8585
HISENSE_FAN_AUTO, HISENSE_SWING_OFF,
86-
HISENSE_SWING_OFF, HISENSE_FEATURE_NONE, false };
86+
HISENSE_SWING_OFF, HISENSE_FEATURE_NONE,
87+
HISENSE_DISPLAY_NOCHANGE };
8788
static volatile bool s_from_bus = false;// true while pushing status->attrs: suppress
8889
// the resulting PRE/POST_UPDATE from re-sending a cmd
8990

@@ -152,6 +153,22 @@ static void flush_cmd()
152153
if (hisense_send_frame(f, n)) arm_sync_hold(); // arm the settle only if it enqueued
153154
else ESP_LOGW(TAG, "cmd dropped (TX queue full)");
154155
}
156+
/* Bench bridge for the diag console's `tx` (#52 display-byte hunt). Lives here, not
157+
* in diag_console.cpp, so s_cmd and arm_sync_hold stay private: the probe frame is
158+
* built from the CURRENT command state, so it differs from what the A/C is already
159+
* running by exactly the byte under test. Arms the settle window like any other
160+
* send, otherwise the next status frame resyncs s_cmd mid-probe.
161+
* Returns 0 sent, -1 offset/build rejected, -2 TX queue full. */
162+
extern "C" int diag_tx_override(int off, uint8_t val)
163+
{
164+
uint8_t f[HISENSE_CMD_FRAME_LEN + 2];
165+
size_t n = hisense_build_command_override(&s_cmd, f, sizeof(f), off, val);
166+
if (!n) return -1;
167+
if (!hisense_send_frame(f, n)) return -2;
168+
arm_sync_hold();
169+
return 0;
170+
}
171+
155172
static void send_power(bool on)
156173
{
157174
uint8_t f[HISENSE_CMD_FRAME_LEN];
@@ -192,13 +209,20 @@ static void apply_sleep(uint8_t profile)
192209
size_t n = hisense_build_sleep_frame(profile, f, sizeof(f));
193210
if (n && hisense_send_frame(f, n)) arm_sync_hold();
194211
}
195-
// #19 cheap win: panel display on/off. display_on rides the combined command frame (@20:
196-
// 0xC0 on / 0x40 off); flush_cmd() rebuilds + sends it. No status feedback (the A/C doesn't
197-
// report display state), so the OnOff attr is optimistic — reflects the last command.
212+
// #19 cheap win: panel display on/off. `display` rides the combined command frame (@20:
213+
// 0xC0 on / 0x40 off / 0x00 leave-alone); flush_cmd() rebuilds + sends it. No status feedback
214+
// (the A/C doesn't report display state), so the OnOff attr is optimistic — reflects the last
215+
// command.
216+
//
217+
// ONE-SHOT (#52): reset to NOCHANGE after the frame goes out. `display` is packed into EVERY
218+
// combined command, so leaving ON/OFF latched would re-assert the panel state on every later
219+
// mode/setpoint/fan change and fight the user's remote. Leave-alone is the only correct resting
220+
// value.
198221
static void apply_display(bool on)
199222
{
200-
s_cmd.display_on = on;
223+
s_cmd.display = on ? HISENSE_DISPLAY_ON : HISENSE_DISPLAY_OFF;
201224
flush_cmd();
225+
s_cmd.display = HISENSE_DISPLAY_NOCHANGE;
202226
}
203227

204228
static void on_recommission(uint8_t reason); // fwd decl (defined in the "77" section below)

firmware/esp32-matter/main/diag_console.cpp

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// for Matter coexistence: diag_on_status() only snapshots (no socket I/O under the
44
// CHIP stack lock); the `watch` stream and the TCP accept loop run in their own tasks.
55
#include <stdio.h>
6+
#include <stdlib.h>
67
#include <string.h>
78
#include <ctype.h>
89
#include <unistd.h>
@@ -176,13 +177,52 @@ static int cmd_decode(int argc, char **argv)
176177
return 0;
177178
}
178179

180+
// Bench probe for #52 (display byte unknown). Sends the CURRENT command frame with one
181+
// payload byte replaced, so a miss is a no-op rather than a surprise mode/setpoint change.
182+
//
183+
// Deliberately single-shot: there is no auto-sweep. hisense_parse_status() decodes no
184+
// display state (power_display/q_display are ProductType CAPABILITY bits, not live
185+
// status), so the only oracle is the panel LED and every probe needs a human looking at
186+
// the unit before the next one goes out. An unattended sweep would just log offsets.
187+
static int cmd_tx(int argc, char **argv)
188+
{
189+
if (argc < 3) {
190+
printf("usage: tx <offset> <value> (both accept 0x.. or decimal)\r\n"
191+
" payload offsets %u..%u; header and checksum/terminator are rejected\r\n"
192+
" current suspect (#52): tx 36 0xC0 then tx 36 0x40\r\n"
193+
" watch the PANEL after each probe -- there is no status read-back\r\n",
194+
(unsigned) HISENSE_CMD_HEADER_LEN, (unsigned) HISENSE_CMD_CHK_OFFSET - 1);
195+
return 1;
196+
}
197+
long off = strtol(argv[1], NULL, 0);
198+
long val = strtol(argv[2], NULL, 0);
199+
if (val < 0 || val > 0xFF) { printf("value out of range (0..0xFF)\r\n"); return 1; }
200+
201+
switch (diag_tx_override((int) off, (uint8_t) val)) {
202+
case -1:
203+
printf("rejected: offset %ld is outside the payload [%u,%u)\r\n",
204+
off, (unsigned) HISENSE_CMD_HEADER_LEN, (unsigned) HISENSE_CMD_CHK_OFFSET);
205+
return 1;
206+
case -2:
207+
printf("TX queue full -- NOT sent, retry\r\n");
208+
return 1;
209+
default:
210+
break;
211+
}
212+
printf("sent: frame[%ld] = 0x%02lX (every other byte = current A/C state)\r\n"
213+
" -> check the panel now; any change is attributable to this byte alone\r\n"
214+
" -> to undo, drive the unit normally from HA (rebuilds the baseline frame)\r\n",
215+
off, (unsigned long) val);
216+
return 0;
217+
}
218+
179219
// Compact codec self-check (subset of the host golden vectors).
180220
static int cmd_selftest(int, char **)
181221
{
182222
int fails = 0;
183223
uint8_t f[64];
184224
HisenseCommand c = { HISENSE_MODE_COOL, 22, false, HISENSE_FAN_LOW,
185-
HISENSE_SWING_OFF, HISENSE_SWING_OFF, HISENSE_FEATURE_ECO, false };
225+
HISENSE_SWING_OFF, HISENSE_SWING_OFF, HISENSE_FEATURE_ECO, HISENSE_DISPLAY_NOCHANGE };
186226
size_t n = hisense_build_command(&c, f, sizeof(f));
187227
if (!(n && f[16] == 0x0B && f[18] == 0x50 && f[19] == 0x2D && f[33] == 0x30)) fails++;
188228
uint8_t hi = 0, lo = 0;
@@ -290,6 +330,8 @@ extern "C" void diag_console_start(void)
290330
{ "watch", "stream decoded frames ~1Hz: watch [on|off]", NULL, cmd_watch, NULL, NULL },
291331
{ "decode", "offline-decode a pasted hex frame", NULL, cmd_decode, NULL, NULL },
292332
{ "selftest", "compact on-target codec self-check", NULL, cmd_selftest, NULL, NULL },
333+
{ "tx", "#52 bench probe: tx <offset> <value> — current frame, one byte overridden",
334+
NULL, cmd_tx, NULL, NULL },
293335
};
294336
for (size_t i = 0; i < sizeof(cmds) / sizeof(cmds[0]); i++) esp_console_cmd_register(&cmds[i]);
295337
}

firmware/esp32-matter/main/diag_console.h

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Diagnostic telnet console (:2323) embedded in the esp-matter A/C firmware, so the
22
// node stays a Matter device AND exposes recon-style diagnostics over the network:
3-
// nc <node-ip> 2323 -> token | poll | watch | decode | selftest | help
3+
// nc <node-ip> 2323 -> token | poll | watch | decode | selftest | tx | help
44
#pragma once
55
#include "hisense_rs485.h"
66

@@ -16,6 +16,11 @@ void diag_console_start(void);
1616
// bus callback while the CHIP stack lock is held.
1717
void diag_on_status(const HisenseState *st);
1818

19+
// Implemented in app_main.cpp. Sends the current command frame with ONE payload byte
20+
// overridden (see hisense_build_command_override). Backs the console's `tx`.
21+
// Returns 0 sent, -1 offset/build rejected, -2 TX queue full.
22+
int diag_tx_override(int off, uint8_t val);
23+
1924
#ifdef __cplusplus
2025
}
2126
#endif

firmware/esp32-matter/sdkconfig.defaults

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ CONFIG_CUSTOM_DEVICE_INFO_PROVIDER=y
4646
# so it (and the fallback NUMBER) MUST stay equal to PROJECT_VER or the device reports a stale
4747
# softwareVersionString. Edit PROJECT_VER in CMakeLists.txt, then update both lines below.
4848
# esp32-lint.sh enforces this equality (fails the commit/CI if they drift).
49-
CONFIG_DEVICE_SOFTWARE_VERSION_NUMBER=10010
50-
CONFIG_DEVICE_SOFTWARE_VERSION_STRING="1.0.10"
49+
CONFIG_DEVICE_SOFTWARE_VERSION_NUMBER=10011
50+
CONFIG_DEVICE_SOFTWARE_VERSION_STRING="1.0.11"
5151
# --- OTA hardening (faster + reliable on marginal Wi-Fi) ---
5252
# Delta OTA: ship a diff (tens of KB) instead of the full ~1.5MB image over BDX.
5353
CONFIG_ENABLE_DELTA_OTA=y

firmware/src/rs485-driver/hisense_rs485.cpp

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,14 @@ size_t hisense_build_sleep_frame(uint8_t profile, uint8_t *out, size_t out_cap)
347347
return hisense_build_single_field(17, v, out, out_cap);
348348
}
349349

350-
size_t hisense_build_command(const HisenseCommand *cmd, uint8_t *out, size_t out_cap)
350+
/* Shared body for hisense_build_command() and hisense_build_command_override().
351+
* ovr_off < 0 means "no override" and reproduces the original behaviour byte for
352+
* byte, so the golden vectors keep passing. A non-negative ovr_off patches ONE
353+
* pre-checksum byte just before finalize_frame(), which is the only correct place
354+
* to do it: the checksum is computed over the patched frame and the 0xF4 stuffing
355+
* happens afterwards, so callers can address true frame offsets and ignore both. */
356+
static size_t build_command_impl(const HisenseCommand *cmd, uint8_t *out, size_t out_cap,
357+
int ovr_off, uint8_t ovr_val)
351358
{
352359
if (cmd == NULL || out == NULL || out_cap < HISENSE_CMD_FRAME_LEN + 2) {
353360
return 0;
@@ -448,15 +455,46 @@ size_t hisense_build_command(const HisenseCommand *cmd, uint8_t *out, size_t out
448455
// offset 35: baseline filler 0x00 (majority of single-purpose samples).
449456
frame[35] = 0x00;
450457

451-
// offset 36: display. 0xC0 on / 0x40 off / 0x00 "leave alone".
452-
frame[36] = cmd->display_on ? 0xC0 : 0x00;
458+
// offset 36: display. 0xC0 on / 0x40 off / 0x00 "leave alone". All three CONFIRMED
459+
// on hardware 2026-07-19 (#52) -- previously this sent 0x00 for "off", i.e. the
460+
// leave-alone value, which is why the ep9 Display switch never turned the panel off.
461+
switch (cmd->display) {
462+
case HISENSE_DISPLAY_ON: frame[36] = 0xC0; break;
463+
case HISENSE_DISPLAY_OFF: frame[36] = 0x40; break;
464+
case HISENSE_DISPLAY_NOCHANGE:
465+
default: frame[36] = 0x00; break;
466+
}
467+
468+
// Bench override (#52 display-byte hunt): patch one payload byte AFTER the
469+
// normal packing so the frame is otherwise a known-good current-state command,
470+
// and BEFORE the checksum so the result is still a valid frame on the wire.
471+
if (ovr_off >= 0 && (size_t) ovr_off < HISENSE_CMD_FRAME_LEN) {
472+
frame[ovr_off] = ovr_val;
473+
}
453474

454475
finalize_frame(frame, HISENSE_CMD_CHK_OFFSET, HISENSE_CMD_END_OFFSET);
455476

456477
// Byte-stuff an 0xF4 checksum byte before it goes on the wire.
457478
return hisense_stuff_checksum(frame, HISENSE_CMD_FRAME_LEN, out, out_cap);
458479
}
459480

481+
size_t hisense_build_command(const HisenseCommand *cmd, uint8_t *out, size_t out_cap)
482+
{
483+
return build_command_impl(cmd, out, out_cap, -1, 0);
484+
}
485+
486+
size_t hisense_build_command_override(const HisenseCommand *cmd, uint8_t *out, size_t out_cap,
487+
int ovr_off, uint8_t ovr_val)
488+
{
489+
// Refuse the header and the checksum/terminator: patching those produces a
490+
// frame the A/C drops (or, worse, a reframe), which reads as "this offset does
491+
// nothing" and would silently poison a sweep.
492+
if (ovr_off < (int) HISENSE_CMD_HEADER_LEN || ovr_off >= (int) HISENSE_CMD_CHK_OFFSET) {
493+
return 0;
494+
}
495+
return build_command_impl(cmd, out, out_cap, ovr_off, ovr_val);
496+
}
497+
460498
/* ---------------------------------------------------------------------------
461499
* Status parsing (input already un-stuffed by the bus task)
462500
* -------------------------------------------------------------------------*/

firmware/src/rs485-driver/hisense_rs485.h

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@ typedef enum {
209209
// simultaneously will just pick turbo.
210210
} HisenseFeature;
211211

212+
/* Panel display / LED, frame[36] (= payload @20). All three values CONFIRMED on the
213+
* bench 2026-07-19 against a live W41H1 (issue #52): 0x40 darkened the panel and 0xC0
214+
* lit it again, with every other byte held at the unit's current state.
215+
*
216+
* The tri-state is REQUIRED, not a nicety. `display` rides the combined command frame,
217+
* so it is present on every mode / setpoint / fan / swing change. A plain bool mapping
218+
* false->0x40 would force the display off on every one of those commands; NOCHANGE's
219+
* 0x00 ("leave alone") is what keeps ordinary traffic from touching the panel. Only an
220+
* explicit display request should send ON/OFF, and it should be one-shot (reset to
221+
* NOCHANGE after the frame goes out), otherwise later frames keep re-asserting it and
222+
* fight the user's remote. */
223+
typedef enum {
224+
HISENSE_DISPLAY_NOCHANGE = 0, // frame[36]=0x00 leave the panel alone (default)
225+
HISENSE_DISPLAY_ON = 1, // frame[36]=0xC0 CONFIRMED lights the panel
226+
HISENSE_DISPLAY_OFF = 2, // frame[36]=0x40 CONFIRMED darkens the panel
227+
} HisenseDisplay;
228+
212229
/* ---------------------------------------------------------------------------
213230
* Command struct: everything hisense_build_command() can pack into ONE
214231
* combined "write" frame.
@@ -236,13 +253,8 @@ typedef struct {
236253
HisenseSwingMode vswing;
237254
HisenseSwingMode hswing;
238255
HisenseFeature feature; // eco / turbo / none
239-
bool display_on; // VERIFY: 0x00 body byte 20 is
240-
// assumed "don't touch display"; only
241-
// display_on={true->0xC0,false->0x40}
242-
// triggers an explicit write. There's
243-
// no tri-state "leave alone" flag here
244-
// -- add one if the bench test shows
245-
// 0x00 actually forces display off.
256+
HisenseDisplay display; // panel display/LED. CONFIRMED on the bench
257+
// 2026-07-19 (see HisenseDisplay).
246258
} HisenseCommand;
247259

248260
/* ---------------------------------------------------------------------------
@@ -462,6 +474,23 @@ void hisense_deinit(void);
462474
// return value, never assume HISENSE_CMD_FRAME_LEN.
463475
size_t hisense_build_command(const HisenseCommand *cmd, uint8_t *out, size_t out_cap);
464476

477+
// BENCH ONLY (#52 display-byte hunt). Same frame as hisense_build_command(), with
478+
// exactly ONE pre-checksum byte replaced. Every other byte stays at the caller's
479+
// current known-good state, so a failed probe is a no-op rather than a surprise
480+
// mode/setpoint change -- that is what makes an offset sweep safe to run against a
481+
// live A/C.
482+
//
483+
// `ovr_off` is an ABSOLUTE frame offset and must land in the payload:
484+
// [HISENSE_CMD_HEADER_LEN, HISENSE_CMD_CHK_OFFSET). Header and checksum/terminator
485+
// offsets are rejected (returns 0) because patching them yields a frame the A/C
486+
// simply drops, which is indistinguishable from "this byte does nothing" and would
487+
// poison a sweep with false negatives.
488+
//
489+
// Returns the on-the-wire length (byte-stuffing included), or 0 on a rejected
490+
// offset / the same errors as hisense_build_command().
491+
size_t hisense_build_command_override(const HisenseCommand *cmd, uint8_t *out, size_t out_cap,
492+
int ovr_off, uint8_t ovr_val);
493+
465494
// Builds the literal power on/off frame, ported byte-for-byte from
466495
// messages.h `on[]`/`off[]` (NOT synthesized -- these carry several bytes
467496
// whose individual semantics are unconfirmed, see hisense_rs485.cpp).

firmware/src/sdk-edits/matter_drivers.cpp

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ static const chip::EndpointId kDisplayEp = 9; // OnOff -> panel display (#19
9898
* ------------------------------------------------------------------------ */
9999
static HisenseCommand s_cmd = { HISENSE_MODE_COOL, 24, false,
100100
HISENSE_FAN_AUTO, HISENSE_SWING_OFF,
101-
HISENSE_SWING_OFF, HISENSE_FEATURE_NONE, false };
101+
HISENSE_SWING_OFF, HISENSE_FEATURE_NONE, HISENSE_DISPLAY_NOCHANGE };
102102

103103
/* Latest parsed A/C status, written by the bus task, read by the downlink
104104
* handler. Poll cadence is seconds apart so a plain snapshot copy is adequate. */
@@ -494,13 +494,18 @@ static void hisense_apply_mute(bool on)
494494
if (n) hisense_send_frame(f, n);
495495
}
496496

497-
/* Panel display on/off (#19 parity with the esp32 build). display_on rides the
498-
* combined command frame (@20: 0xC0 on / 0x40 off); flush rebuilds + sends it.
499-
* Write-only -- the A/C reports no display state, so there's no status echo. */
497+
/* Panel display on/off (#19 parity with the esp32 build). `display` rides the
498+
* combined command frame (@20: 0xC0 on / 0x40 off / 0x00 leave-alone); flush rebuilds
499+
* + sends it. Write-only -- the A/C reports no display state, so there's no status echo.
500+
*
501+
* ONE-SHOT (#52): reset to NOCHANGE afterwards. The field is packed into EVERY combined
502+
* command, so a latched ON/OFF would re-assert the panel on every later mode/setpoint/fan
503+
* change and fight the user's remote. */
500504
static void hisense_apply_display(bool on)
501505
{
502-
s_cmd.display_on = on;
506+
s_cmd.display = on ? HISENSE_DISPLAY_ON : HISENSE_DISPLAY_OFF;
503507
hisense_flush_command();
508+
s_cmd.display = HISENSE_DISPLAY_NOCHANGE;
504509
}
505510

506511
/* Sleep profile (0=off, 1..4 = General/Old/Young/Kids). Driven from two places --

firmware/src/version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.14
1+
1.2.15

0 commit comments

Comments
 (0)