diff --git a/packages/blaueis-core/src/blaueis/core/data/glossary.yaml b/packages/blaueis-core/src/blaueis/core/data/glossary.yaml index a4bfcb1..f67f39c 100644 --- a/packages/blaueis-core/src/blaueis/core/data/glossary.yaml +++ b/packages/blaueis-core/src/blaueis/core/data/glossary.yaml @@ -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: @@ -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 @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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 diff --git a/packages/blaueis-core/tests/test_build_status.py b/packages/blaueis-core/tests/test_build_status.py index 0e39d47..83b5256 100644 --- a/packages/blaueis-core/tests/test_build_status.py +++ b/packages/blaueis-core/tests/test_build_status.py @@ -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 @@ -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", @@ -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", @@ -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", diff --git a/packages/blaueis-core/tests/test_default_constraints.py b/packages/blaueis-core/tests/test_default_constraints.py index e50fe69..94297e7 100644 --- a/packages/blaueis-core/tests/test_default_constraints.py +++ b/packages/blaueis-core/tests/test_default_constraints.py @@ -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: diff --git a/packages/blaueis-core/tests/test_set_command_preflight.py b/packages/blaueis-core/tests/test_set_command_preflight.py index 392adcc..70d725e 100644 --- a/packages/blaueis-core/tests/test_set_command_preflight.py +++ b/packages/blaueis-core/tests/test_set_command_preflight.py @@ -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, @@ -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}", ) @@ -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, @@ -232,10 +232,10 @@ 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, @@ -243,14 +243,14 @@ def test_shared_byte_stale(): ) 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}", ) @@ -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( @@ -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] @@ -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']}", ) @@ -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}", ) @@ -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, @@ -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, "")