Skip to content

Commit 61b2949

Browse files
JonGilmoreManjunathByadagimwbourgeoisclaude
authored
feat: map BLE notif_type pushes to Notification Event sensor (#99)
Adds a Notification Event text sensor on every appliance that surfaces push notifications (washer cycle complete, fridge door left open, oven preheat reached, water/air filter expired, etc.) as human-readable event names HA automations can trigger on. Mappings come from issue #69, where users captured BLE traffic during each Sub-Zero iOS app push notification. Coverage: - Fridge (101, 102, 106-109): door open + setpoint changes + filter expiry - Range (201, 203, 205, 207-211, 213, 215, 218): preheat, probe, timers, timed cook, oven door - Dishwasher (301, 302, 304, 306, 307): cycle started/complete/cancelled, rinse aid low Unknown codes fall back to "<appliance>_event_<n>" so they remain observable in HA. Also fixes parser rejection of msg_types:4 notification-only pushes (which fridge fw 2.27 sends for filter/door events) — these were previously logged as "Parse failed or status!=0, skipping". 12 new tests (9 unit + 3 fixture pairs from real captures). Closes #69. Supersedes #96. Co-authored-by: ManjunathByadagi <ManjunathByadagi@users.noreply.github.com> Co-authored-by: mwbourgeois <mwbourgeois@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3514707 commit 61b2949

14 files changed

Lines changed: 296 additions & 6 deletions

components/subzero_appliance/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,12 @@
216216
CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC,
217217
},
218218
),
219+
(
220+
"notif_event",
221+
"Notification Event",
222+
"set_notif_event_sensor",
223+
{CONF_ICON: "mdi:bell-ring"},
224+
),
219225
]
220226

221227
# Buttons — same across all appliance types.

components/subzero_appliance/appliance_base.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ class ApplianceBase : public esphome::Component,
9797
void set_board_version_sensor(esphome::text_sensor::TextSensor *s) {
9898
common_bus()->board_version = s;
9999
}
100+
void set_notif_event_sensor(esphome::text_sensor::TextSensor *s) {
101+
common_bus()->notif_event = s;
102+
}
100103

101104
// ---- ESPHome lifecycle ----
102105

components/subzero_protocol/dispatch.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ inline void dispatch_common(const CommonFields &c, Bus &bus) {
6262
template <typename Bus>
6363
inline void dispatch_fridge(const FridgeState &s, Bus &bus) {
6464
dispatch_common(s.common, bus);
65+
if (s.notif_event)
66+
bus.publish_notif_event(*s.notif_event);
6567
// Fridge / Freezer
6668
if (s.ref_set_temp)
6769
bus.publish_set_temp(*s.ref_set_temp);
@@ -107,6 +109,8 @@ inline void dispatch_fridge(const FridgeState &s, Bus &bus) {
107109
template <typename Bus>
108110
inline void dispatch_dishwasher(const DishwasherState &s, Bus &bus) {
109111
dispatch_common(s.common, bus);
112+
if (s.notif_event)
113+
bus.publish_notif_event(*s.notif_event);
110114
if (s.door_ajar)
111115
bus.publish_door_ajar(*s.door_ajar);
112116
if (s.wash_cycle_on)
@@ -148,6 +152,8 @@ inline void dispatch_dishwasher(const DishwasherState &s, Bus &bus) {
148152
template <typename Bus>
149153
inline void dispatch_range(const RangeState &s, Bus &bus) {
150154
dispatch_common(s.common, bus);
155+
if (s.notif_event)
156+
bus.publish_notif_event(*s.notif_event);
151157
if (s.door_ajar)
152158
bus.publish_door_ajar(*s.door_ajar);
153159
// Primary cavity

components/subzero_protocol/dispatch_esphome.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ struct CommonBus {
7474
esphome::text_sensor::TextSensor *os_version = nullptr;
7575
esphome::text_sensor::TextSensor *rtapp_version = nullptr;
7676
esphome::text_sensor::TextSensor *board_version = nullptr;
77+
// Latest notif_type → human-readable event name (e.g. "fridge_door_open").
78+
// Updated only on push messages that carry a notif_type; HA automations
79+
// can trigger on state-changes here.
80+
esphome::text_sensor::TextSensor *notif_event = nullptr;
7781

7882
void publish_sabbath_on(bool v) { detail::publish_if(sabbath_on, v); }
7983
void publish_svc_required(bool v) { detail::publish_if(svc_required, v); }
@@ -107,6 +111,9 @@ struct CommonBus {
107111
void publish_board_version(const std::string &v) {
108112
detail::publish_if(board_version, v);
109113
}
114+
void publish_notif_event(const std::string &v) {
115+
detail::publish_if(notif_event, v);
116+
}
110117
};
111118

112119
struct FridgeBus : CommonBus {

components/subzero_protocol/protocol.cpp

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,94 @@ JsonObjectConst extract_data(JsonObjectConst root, bool &is_poll) {
6666
return JsonObjectConst();
6767
}
6868

69+
// Format the unknown-code fallback used when notif_type is present but not
70+
// recognized by the per-appliance mapping. Surfaces the raw integer to HA
71+
// (as e.g. "fridge_event_142") so users can observe and report new codes
72+
// without us hard-coding a placeholder name.
73+
std::string unknown_notif_event(const char *appliance, int notif_type) {
74+
char buf[40];
75+
std::snprintf(buf, sizeof(buf), "%s_event_%d", appliance, notif_type);
76+
return buf;
77+
}
78+
79+
// notif_type → human-readable event name. Mappings come from issue #69
80+
// debug captures, cross-referenced against the official Sub-Zero iOS app's
81+
// push notification text. Codes not in the table fall through to
82+
// "<appliance>_event_<n>" so unknown codes are still observable.
83+
std::optional<std::string> fridge_notif_event(JsonObjectConst root) {
84+
auto t = opt_int(root["notif_type"]);
85+
if (!t)
86+
return std::nullopt;
87+
switch (*t) {
88+
case 101:
89+
return std::string("fridge_door_open");
90+
case 102:
91+
return std::string("freezer_door_open");
92+
case 106:
93+
return std::string("fridge_setpoint_changed");
94+
case 107:
95+
return std::string("freezer_setpoint_changed");
96+
case 108:
97+
return std::string("water_filter_expired");
98+
case 109:
99+
return std::string("air_filter_expired");
100+
default:
101+
return unknown_notif_event("fridge", *t);
102+
}
103+
}
104+
105+
std::optional<std::string> dishwasher_notif_event(JsonObjectConst root) {
106+
auto t = opt_int(root["notif_type"]);
107+
if (!t)
108+
return std::nullopt;
109+
switch (*t) {
110+
case 301:
111+
return std::string("wash_cycle_started");
112+
case 302:
113+
return std::string("wash_cycle_complete");
114+
case 304:
115+
return std::string("rinse_aid_low");
116+
case 306:
117+
return std::string("wash_cycle_interrupted");
118+
case 307:
119+
return std::string("wash_cycle_cancelled");
120+
default:
121+
return unknown_notif_event("dishwasher", *t);
122+
}
123+
}
124+
125+
std::optional<std::string> range_notif_event(JsonObjectConst root) {
126+
auto t = opt_int(root["notif_type"]);
127+
if (!t)
128+
return std::nullopt;
129+
switch (*t) {
130+
case 201:
131+
return std::string("oven_preheat_complete");
132+
case 203:
133+
return std::string("oven_probe_in_use");
134+
case 205:
135+
return std::string("oven_probe_temp_reached");
136+
case 207:
137+
return std::string("kitchen_timer_ended");
138+
case 208:
139+
return std::string("kitchen_timer2_ended");
140+
case 209:
141+
return std::string("kitchen_timer_1min_remaining");
142+
case 210:
143+
return std::string("kitchen_timer2_1min_remaining");
144+
case 211:
145+
return std::string("timed_cook_ended");
146+
case 213:
147+
return std::string("timed_cook_1min_remaining");
148+
case 215:
149+
return std::string("oven_probe_within_10deg");
150+
case 218:
151+
return std::string("oven_door_opened");
152+
default:
153+
return unknown_notif_event("range", *t);
154+
}
155+
}
156+
69157
void fill_version(JsonObjectConst v, Version &out) {
70158
out.fw = opt_str(v["fw"]);
71159
out.api = opt_str(v["api"]);
@@ -133,12 +221,26 @@ FridgeState parse_fridge(const std::string &json) {
133221
return state;
134222
if (!doc.is<JsonObject>())
135223
return state;
224+
JsonObjectConst root = doc.as<JsonObjectConst>();
225+
// Extract notif_event from root first — `notif_type` lives at the root of
226+
// push messages, alongside `seq`/`timestamp`, NOT inside `props`.
227+
auto notif_event = fridge_notif_event(root);
136228
bool is_poll = true;
137-
JsonObjectConst data = extract_data(doc.as<JsonObjectConst>(), is_poll);
138-
if (data.isNull())
229+
JsonObjectConst data = extract_data(root, is_poll);
230+
if (data.isNull()) {
231+
// msg_types:4 notification-only push: no `props`/`resp`, just notif_type
232+
// at root. Mark valid so the bus can publish the event; leave data
233+
// fields empty.
234+
if (notif_event) {
235+
state.valid = true;
236+
state.is_poll = false;
237+
state.notif_event = std::move(notif_event);
238+
}
139239
return state;
240+
}
140241
state.valid = true;
141242
state.is_poll = is_poll;
243+
state.notif_event = std::move(notif_event);
142244
capture_keys(data, state.data_keys);
143245
fill_common(data, state.common);
144246

@@ -173,13 +275,21 @@ DishwasherState parse_dishwasher(const std::string &json) {
173275
return state;
174276
if (!doc.is<JsonObject>())
175277
return state;
176-
bool is_poll = true;
177278
JsonObjectConst root = doc.as<JsonObjectConst>();
279+
auto notif_event = dishwasher_notif_event(root);
280+
bool is_poll = true;
178281
JsonObjectConst data = extract_data(root, is_poll);
179-
if (data.isNull())
282+
if (data.isNull()) {
283+
if (notif_event) {
284+
state.valid = true;
285+
state.is_poll = false;
286+
state.notif_event = std::move(notif_event);
287+
}
180288
return state;
289+
}
181290
state.valid = true;
182291
state.is_poll = is_poll;
292+
state.notif_event = std::move(notif_event);
183293
capture_keys(data, state.data_keys);
184294
fill_common(data, state.common);
185295

@@ -220,12 +330,21 @@ RangeState parse_range(const std::string &json) {
220330
return state;
221331
if (!doc.is<JsonObject>())
222332
return state;
333+
JsonObjectConst root = doc.as<JsonObjectConst>();
334+
auto notif_event = range_notif_event(root);
223335
bool is_poll = true;
224-
JsonObjectConst data = extract_data(doc.as<JsonObjectConst>(), is_poll);
225-
if (data.isNull())
336+
JsonObjectConst data = extract_data(root, is_poll);
337+
if (data.isNull()) {
338+
if (notif_event) {
339+
state.valid = true;
340+
state.is_poll = false;
341+
state.notif_event = std::move(notif_event);
342+
}
226343
return state;
344+
}
227345
state.valid = true;
228346
state.is_poll = is_poll;
347+
state.notif_event = std::move(notif_event);
229348
capture_keys(data, state.data_keys);
230349
fill_common(data, state.common);
231350

components/subzero_protocol/protocol.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ struct FridgeState {
3838
// Top-level keys present in the data object (resp or props), in order.
3939
// Populated on every parse; the lambdas log these when debug mode is on.
4040
std::vector<std::string> data_keys;
41+
std::optional<std::string> notif_event;
4142
CommonFields common;
4243
std::optional<float> ref_set_temp;
4344
std::optional<bool> door_ajar;
@@ -60,6 +61,7 @@ struct DishwasherState {
6061
bool valid = false;
6162
bool is_poll = false;
6263
std::vector<std::string> data_keys;
64+
std::optional<std::string> notif_event;
6365
CommonFields common;
6466
std::optional<bool> door_ajar;
6567
std::optional<bool> wash_cycle_on;
@@ -82,6 +84,7 @@ struct RangeState {
8284
bool valid = false;
8385
bool is_poll = false;
8486
std::vector<std::string> data_keys;
87+
std::optional<std::string> notif_event;
8588
CommonFields common;
8689
std::optional<bool> door_ajar;
8790

tests/cpp/dispatch_test.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ struct CommonRecorder {
5151
void publish_board_version(const std::string &v) {
5252
strings["board_version"] = v;
5353
}
54+
void publish_notif_event(const std::string &v) { strings["notif_event"] = v; }
5455
};
5556

5657
struct FridgeRecorder : CommonRecorder {

0 commit comments

Comments
 (0)