Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions packages/blaueis-core/src/blaueis/core/data/glossary.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1406,9 +1406,11 @@ fields:
- own captures Sessions 4-8
catch_cold:
description: 'Anti-cold / cold prevention: prevents blowing cold air during heat-up.'
signoff: tbd
field_class: stateful_bool
feature_available: always
feature_available: excluded
excluded_reasons:
- never_observed
note: 'Write not adopted by the test unit (2026-10-18, heat mode, two attempts): the status reply kept 0, although heat is the mode this function applies to.'
data_type: bool
device_context: indoor_unit
alt_names:
Expand Down Expand Up @@ -1495,7 +1497,6 @@ fields:
never_tested_write caveat shows in the banner.
cleanup:
description: Clean up / purifier mode active.
signoff: tbd
field_class: stateful_bool
_migration:
_previous_feature_available: always
Expand Down Expand Up @@ -1713,9 +1714,11 @@ fields:
- own research
dry_clean:
description: 'Dry clean / X-Fan: runs fan after cooling to dry the evaporator and prevent mold.'
signoff: tbd
field_class: stateful_bool
feature_available: always
feature_available: excluded
excluded_reasons:
- never_observed
note: 'Write not adopted by the test unit (2026-10-18, heat mode, two attempts): the status reply kept 0. Other clients only allow it in cool or dry mode; retest there before exposing it.'
data_type: bool
device_context: indoor_unit
alt_names:
Expand Down Expand Up @@ -1873,9 +1876,11 @@ fields:
- midea-local
energy_save:
description: Energy save mode. Different from ECO mode and power_save.
signoff: tbd
field_class: stateful_bool
feature_available: always
feature_available: excluded
excluded_reasons:
- never_observed
note: 'Write not adopted by the test unit (2026-10-18, heat mode, two attempts): the status reply kept 0. Other clients do not send this bit; on some firmware the same status bit reports independent PTC state.'
data_type: bool
device_context: indoor_unit
alt_names:
Expand Down Expand Up @@ -2644,14 +2649,16 @@ fields:
confidence: hypothesis
low_frequency_fan:
description: Low frequency / quiet fan mode. Reduces fan noise.
signoff: tbd
field_class: stateful_bool
_migration:
_previous_feature_available: always
_inferred_reason: '§10 default-deny audit: hypothesis confidence, decode-only evidence (false in S7/S11 ground truth),
no active state-change capture, no independent source confirmation. Demoted until round-trip verified.'
_source: §10 audit 2026-04-10
feature_available: readable
feature_available: excluded
excluded_reasons:
- never_observed
note: 'Write not adopted by the test unit (2026-10-18, heat mode, two attempts): the status reply kept 0. Other clients do not send this bit.'
data_type: bool
device_context: indoor_unit
alt_names:
Expand Down Expand Up @@ -3292,9 +3299,11 @@ fields:
- Session 11 (all frames show 0x7F = timer OFF)
power_save:
description: Power save mode. Different from ECO mode and energy_save.
signoff: tbd
field_class: stateful_bool
feature_available: always
feature_available: excluded
excluded_reasons:
- never_observed
note: 'Write not adopted by the test unit (2026-10-18, heat mode, two attempts): the status reply kept 0. Not yet tried in cool mode.'
data_type: bool
device_context: indoor_unit
alt_names:
Expand Down Expand Up @@ -3530,9 +3539,11 @@ fields:
- Session 1 B5 query
resume:
description: 'Power resume: restore previous operating state on power recovery after outage.'
signoff: tbd
field_class: stateful_bool
feature_available: always
feature_available: excluded
excluded_reasons:
- never_observed
note: 'Write not adopted by the test unit (2026-10-18, heat mode, two attempts): the status reply kept 0. Other clients do not send this bit.'
data_type: bool
device_context: indoor_unit
alt_names:
Expand Down Expand Up @@ -4369,7 +4380,6 @@ fields:
temperature_unit:
description: 'Display temperature unit: Celsius or Fahrenheit. Changes display only — all protocol values remain in
°C.'
signoff: tbd
field_class: stateful_bool
feature_available: readable
data_type: bool
Expand Down
16 changes: 11 additions & 5 deletions packages/blaueis-core/tests/test_build_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,16 @@ def check(name, condition, detail=""):
)

# feature_available distribution — snapshot of the current glossary (sums to
# 198): always 10, readable 58, capability 71, excluded 48, readable-opt 11.
# 198): always 5, readable 57, capability 71, excluded 54, readable-opt 11.
# The extensive exclusion work (vane fields, group-unknown bytes,
# decode-unverified/never-observed, etc.) moved many fields
# readable -> excluded/capability over time; every excluded field carries a
# schema-enforced excluded_reasons entry.
fa_counts = Counter(f["feature_available"] for f in fields.values())
check("always == 10", fa_counts["always"] == 10, f"got {fa_counts.get('always', 0)}")
check("readable == 58", fa_counts["readable"] == 58, f"got {fa_counts.get('readable', 0)}")
check("always == 5", fa_counts["always"] == 5, f"got {fa_counts.get('always', 0)}")
check("readable == 57", fa_counts["readable"] == 57, f"got {fa_counts.get('readable', 0)}")
check("capability == 71", fa_counts["capability"] == 71, f"got {fa_counts.get('capability', 0)}")
check("excluded == 48", fa_counts["excluded"] == 48, f"got {fa_counts.get('excluded', 0)}")
check("excluded == 54", fa_counts["excluded"] == 54, f"got {fa_counts.get('excluded', 0)}")

# Required keys on every field. The new shape is flat: sources +
# default_priority back the per-frame storage; the rest are
Expand Down Expand Up @@ -133,12 +133,14 @@ def check(name, condition, detail=""):
never_fields = [n for n, f in fields.items() if f["feature_available"] == "excluded"]
expected_never = {
"alarm_sleep",
"catch_cold",
"child_sleep",
"cosy_sleep",
"cosy_sleep_switch",
"pre_cool_hot",
"current_session_time",
"current_work_time",
"dry_clean",
"energy_save",
"fan_speed_timer_bit",
"group12_unknown_byte4",
"group12_unknown_byte6",
Expand All @@ -154,6 +156,7 @@ def check(name, condition, detail=""):
"indoor_humidity",
"ipm_module_temp",
"local_body_sense",
"low_frequency_fan",
"mode_query_value",
"night_light",
"outdoor_return_air_temp",
Expand All @@ -164,8 +167,11 @@ def check(name, condition, detail=""):
"power_on_time",
"power_on_time_value",
"power_on_timer",
"power_save",
"pre_cool_hot",
"protocol_bit1",
"rate_select",
"resume",
"total_worked_time",
"turbo_mode",
"vane_lr_angle",
Expand Down
6 changes: 3 additions & 3 deletions packages/blaueis-core/tests/test_default_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@ def check(name, condition, detail=""):
status = build_status("test", glossary)
fields = status["fields"]
fa = Counter(f["feature_available"] for f in fields.values())
check("always == 10", fa["always"] == 10, f"got {fa['always']}")
check("readable == 58", fa["readable"] == 58, f"got {fa['readable']}")
check("always == 5", fa["always"] == 5, f"got {fa['always']}")
check("readable == 57", fa["readable"] == 57, f"got {fa['readable']}")
check("capability == 71", fa["capability"] == 71, f"got {fa['capability']}")
check("excluded == 48", fa["excluded"] == 48, f"got {fa['excluded']}")
check("excluded == 54", fa["excluded"] == 54, f"got {fa['excluded']}")

# All 8 promoted fields are now `readable`
for fname in PROMOTED_FIELDS:
Expand Down
70 changes: 35 additions & 35 deletions packages/blaueis-core/tests/test_set_command_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def test_boot_status_blocks_change():
status = build_status(device="test", glossary=glossary)
# boot status: every field has last_updated=None

result = build_command_body(status, {"power": True}, glossary)
result = build_command_body(status, {"sleep_mode": True}, glossary)
check(
"body is None (preflight blocked)",
result["body"] is None,
Expand All @@ -144,11 +144,11 @@ def test_boot_status_blocks_change():
all(e["severity"] == "error" for e in result["preflight"]),
"mixed severities",
)
# power is in body[1] sharing with buzzer/protocol_bit1/resume
# sleep_mode is in body[10] sharing with temperature_unit (among others)
error_fields = {e["field"] for e in result["preflight"]}
check(
"resume is flagged as never_read sibling of power",
"resume" in error_fields,
"temperature_unit is flagged as never_read sibling of sleep_mode",
"temperature_unit" in error_fields,
f"errors on {error_fields}",
)

Expand All @@ -161,7 +161,7 @@ def test_force_bypass():
glossary = load_glossary()
status = build_status(device="test", glossary=glossary)

result = build_command_body(status, {"power": True}, glossary, skip_preflight=True)
result = build_command_body(status, {"sleep_mode": True}, glossary, skip_preflight=True)
check(
"body returned despite stale state",
result["body"] is not None,
Expand Down Expand Up @@ -232,25 +232,25 @@ def test_shared_byte_stale():
now = datetime.now(UTC)

_populate_all_cmd_0x40_fields(status, glossary, age_seconds=1.0, now=now)
# Make resume stale (60s > 30s default)
_populate_field(status, "resume", False, age_seconds=60.0, now=now)
# Make temperature_unit stale (60s > 30s default)
_populate_field(status, "temperature_unit", False, age_seconds=60.0, now=now)

result = build_command_body(status, {"power": True}, glossary, now=now)
result = build_command_body(status, {"sleep_mode": True}, glossary, now=now)
check(
"stale sibling blocks: body is None",
result["body"] is None,
f"got body={result['body']}",
)
stale_errors = [e for e in result["preflight"] if e["reason"] == "stale"]
check(
"validation includes stale entry for resume",
any(e["field"] == "resume" for e in stale_errors),
"validation includes stale entry for temperature_unit",
any(e["field"] == "temperature_unit" for e in stale_errors),
f"errors: {result['preflight']}",
)
if stale_errors:
ages = [e["age_seconds"] for e in stale_errors if e["field"] == "resume"]
ages = [e["age_seconds"] for e in stale_errors if e["field"] == "temperature_unit"]
check(
"resume age ~60s",
"temperature_unit age ~60s",
ages and 55 < ages[0] < 65,
f"age={ages}",
)
Expand All @@ -266,17 +266,17 @@ def test_never_read_distinct():
now = datetime.now(UTC)

_populate_all_cmd_0x40_fields(status, glossary, age_seconds=1.0, now=now)
# Reset resume to never_read by clearing its sources slot. The
# Reset temperature_unit to never_read by clearing its sources slot. The
# preflight reads via read_field — an empty sources dict resolves to
# None, which the preflight reports as never_read.
status["fields"]["resume"]["sources"] = {}
status["fields"]["temperature_unit"]["sources"] = {}

result = build_command_body(status, {"power": True}, glossary, now=now)
nr = [e for e in result["preflight"] if e["field"] == "resume"]
result = build_command_body(status, {"sleep_mode": True}, glossary, now=now)
nr = [e for e in result["preflight"] if e["field"] == "temperature_unit"]
check(
"resume flagged",
"temperature_unit flagged",
len(nr) == 1,
f"got {len(nr)} entries for resume",
f"got {len(nr)} entries for temperature_unit",
)
if nr:
check(
Expand Down Expand Up @@ -419,7 +419,7 @@ def test_validation_schema():
status = build_status(device="test", glossary=glossary)
now = datetime.now(UTC)

result = build_command_body(status, {"power": True}, glossary, now=now)
result = build_command_body(status, {"sleep_mode": True}, glossary, now=now)
check("at least one validation error to inspect", len(result["preflight"]) > 0, "")
if result["preflight"]:
err = result["preflight"][0]
Expand All @@ -430,13 +430,13 @@ def test_validation_schema():
f"missing: {required - err.keys()}",
)
check(
"shared_with == power",
err["shared_with"] == "power",
"shared_with == sleep_mode",
err["shared_with"] == "sleep_mode",
f"got {err['shared_with']}",
)
check(
"position == body[1]",
err["position"] == "body[1]",
"position == body[10]",
err["position"] == "body[10]",
f"got {err['position']}",
)

Expand All @@ -451,13 +451,13 @@ def test_now_parameter():
fixed_now = datetime(2026, 4, 10, 12, 0, 0, tzinfo=UTC)

_populate_all_cmd_0x40_fields(status, glossary, age_seconds=1.0, now=fixed_now)
# Mark resume 45s old relative to fixed_now
_populate_field(status, "resume", False, age_seconds=45.0, now=fixed_now)
# Mark temperature_unit 45s old relative to fixed_now
_populate_field(status, "temperature_unit", False, age_seconds=45.0, now=fixed_now)

result = build_command_body(status, {"power": True}, glossary, now=fixed_now)
stale = [e for e in result["preflight"] if e["field"] == "resume" and e["reason"] == "stale"]
result = build_command_body(status, {"sleep_mode": True}, glossary, now=fixed_now)
stale = [e for e in result["preflight"] if e["field"] == "temperature_unit" and e["reason"] == "stale"]
check(
"resume age == 45.0 ± 0.01",
"temperature_unit age == 45.0 ± 0.01",
stale and abs(stale[0]["age_seconds"] - 45.0) < 0.01,
f"got {stale[0]['age_seconds'] if stale else None}",
)
Expand Down Expand Up @@ -598,13 +598,13 @@ def test_integration_refresh_after_stale():

# Try to send a change 120s later — must fail
fixed_now = datetime.now(UTC) + timedelta(seconds=120)
r1 = build_command_body(status, {"power": False}, glossary, now=fixed_now)
r1 = build_command_body(status, {"sleep_mode": False}, glossary, now=fixed_now)
check("stale at t+120s: preflight blocks", r1["body"] is None, "")

# Re-process the same C0 frame at the new "now" — process_frame uses
# datetime.now(UTC) internally so this writes a fresh timestamp.
process_raw_frame(status, body, glossary)
r2 = build_command_body(status, {"power": False}, glossary)
r2 = build_command_body(status, {"sleep_mode": False}, glossary)
check(
"after re-process: preflight passes",
r2["body"] is not None,
Expand All @@ -622,14 +622,14 @@ def test_threshold_parameter():
now = datetime.now(UTC)

_populate_all_cmd_0x40_fields(status, glossary, age_seconds=1.0, now=now)
_populate_field(status, "resume", False, age_seconds=60.0, now=now)
_populate_field(status, "temperature_unit", False, age_seconds=60.0, now=now)

# Tight threshold (10s): 60s resume should fail
r1 = build_command_body(status, {"power": True}, glossary, preflight_threshold_seconds=10.0, now=now)
# Tight threshold (10s): 60s temperature_unit should fail
r1 = build_command_body(status, {"sleep_mode": True}, glossary, preflight_threshold_seconds=10.0, now=now)
check("threshold=10s: stale 60s blocks", r1["body"] is None, "")

# Loose threshold (120s): 60s resume should pass
r2 = build_command_body(status, {"power": True}, glossary, preflight_threshold_seconds=120.0, now=now)
# Loose threshold (120s): 60s temperature_unit should pass
r2 = build_command_body(status, {"sleep_mode": True}, glossary, preflight_threshold_seconds=120.0, now=now)
check("threshold=120s: stale 60s passes", r2["body"] is not None, "")


Expand Down
Loading