Skip to content

Commit c030148

Browse files
committed
feat(settings): drop sub-flag cascade-clear; restore advanced_debug_logging translation; clearer Save-button copy (homeassistant-ai#1164)
Three user-reported fixes from stable-addon testing: 1. Stable add-on's ``translations/en.yaml`` was missing the ``advanced_debug_logging`` description — the schema declares the toggle in ``config.yaml:49`` but no translation ever shipped, so the addon Configuration UI showed an unlabelled checkbox. Add the missing entry (same wording as dev's translation). 2. Big "Save advanced settings" button used to say "Nothing to save." after the user toggled a feature flag (beta master, Tool Search, etc.). Feature-flag toggles auto-save on click via ``saveFeatureFlag`` — they never enter ``_advancedDirty``, so the advanced-save button sees nothing to do. When the restart banner is already showing (recent feature-flag save), surface that explicitly: "No advanced changes to save — your feature-flag toggles already saved on click. Click Restart above to apply them." Falls back to the original "Nothing to save." copy when there's no pending restart. 3. Drop the master-off cascade-clear behaviour entirely. The runtime master gate in ``_apply_feature_flag_overrides`` already forces every beta sub-flag to False whenever the master is off, so the tools stay disabled at runtime regardless of file state. Leaving the sub-flag values in the override file means toggling the master off → on restores the user's prior sub-flag selections automatically; the previous cascade-clear forced users to re-check each sub-flag after every master cycle, which was the wrong UX trade for an opt-in beta surface. The master-gate check is unchanged — it still rejects payloads that try to enable a sub-flag while the effective master is off, so the "sub true while master false in same payload" inconsistency still can't land. The cascade-clear was a separate (now-removed) defence. Tests updated: - ``test_save_features_master_off_preserves_subflag_values`` — was ``test_save_features_cascade_clears_subflags_when_master_off``; asserts the new "preserve" semantics. - ``test_save_features_master_on_restores_runtime_subflag_values`` — new round-trip test for master off → on restoring sub-flags. - ``test_save_features_payload_master_false_sub_true_rejected_by_gate`` — renamed; asserts gate rejection still covers the inconsistent payload. - ``test_save_features_cascade_reads_override_file_not_post_gate_settings`` — deleted (no cascade, no reason to test cascade's read path). - ``test_save_features_master_off_applied_dict_contains_only_master`` — new no-cascade pin; ``applied`` carries only the master flip.
1 parent f0a0d26 commit c030148

3 files changed

Lines changed: 120 additions & 121 deletions

File tree

homeassistant-addon/translations/en.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,10 @@ configuration:
7171
certificate or hostname mismatch. Disabling weakens transport
7272
security — leave on unless you know you need it. Requires restart
7373
to take effect.
74+
advanced_debug_logging:
75+
name: Advanced debug logging
76+
description: >-
77+
Captures extra diagnostic info on kill/shutdown — sender PID
78+
and memory state. Useful when reporting unexplained add-on
79+
stops or crash loops. Off by default to keep logs concise.
80+
Requires restart to take effect.

src/ha_mcp/settings_ui.py

Lines changed: 42 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2338,15 +2338,14 @@ def apply_tool_visibility(
23382338
// sub-row dimming reflects the new state immediately. The
23392339
// save POST still proceeds in the background.
23402340
//
2341-
// Sub-flag VALUES are intentionally NOT flipped here. The
2342-
// server-side cascade in ``_save_feature_flags`` clears them
2343-
// on disk, but the live UI keeps the user's prior sub-flag
2344-
// selections visible (checked-but-dimmed-with-disabled-input)
2345-
// until the next page load so flipping master off doesn't
2346-
// visually wipe state the user might want context on. After
2347-
// refresh, the cascaded-cleared values show through. If save
2348-
// fails (CONNECTION_FAILED etc.), the visible checked state
2349-
// matches the actual on-disk state, which is the right UX.
2341+
// Sub-flag VALUES are intentionally NOT flipped here. Neither
2342+
// is the server's persisted state — the runtime gate in
2343+
// ``_apply_feature_flag_overrides`` is the only thing that
2344+
// forces sub-flags off when master is off, and it does so
2345+
// without mutating the saved values. Result: turning the
2346+
// master off then back on restores the user's prior sub-flag
2347+
// selections automatically, which is the intended UX for an
2348+
// opt-in beta surface.
23502349
if (isMaster) {
23512350
if (_lastFeatureFlags[fieldName]) {
23522351
_lastFeatureFlags[fieldName] = {
@@ -3546,7 +3545,24 @@ def apply_tool_visibility(
35463545
return;
35473546
}
35483547
if (Object.keys(_advancedDirty).length === 0) {
3549-
_setAdvSaveStatus('Nothing to save.');
3548+
// Feature-flag toggles (master beta, Tool Search, etc.) auto-save
3549+
// on click via ``saveFeatureFlag`` — they don't pass through
3550+
// ``_advancedDirty``. If a feature-flag save just landed,
3551+
// ``restartNotice`` is showing and the user should click Restart,
3552+
// not Save again. Tell them that explicitly so the big Save
3553+
// button doesn't look broken when they were toggling beta flags
3554+
// (#1164 follow-up).
3555+
const restartNotice = document.getElementById('restartNotice');
3556+
const restartShowing =
3557+
restartNotice && restartNotice.classList.contains('show');
3558+
if (restartShowing) {
3559+
_setAdvSaveStatus(
3560+
'No advanced changes to save — your feature-flag toggles already ' +
3561+
'saved on click. Click Restart above to apply them.'
3562+
);
3563+
} else {
3564+
_setAdvSaveStatus('Nothing to save.');
3565+
}
35503566
return;
35513567
}
35523568
_setAdvSaveDisabled(true);
@@ -4455,9 +4471,6 @@ async def _save_feature_flags(request: Request) -> JSONResponse:
44554471
from .config import (
44564472
BETA_FEATURE_FIELDS as _BETA_SUB,
44574473
)
4458-
from .config import (
4459-
_read_feature_flag_override_file,
4460-
)
44614474

44624475
effective_master = bool(
44634476
raw_flags.get(
@@ -4571,38 +4584,23 @@ async def _save_feature_flags(request: Request) -> JSONResponse:
45714584
status_code=400,
45724585
)
45734586

4574-
# Master-off cascade clear (#1164 follow-up): flipping the master
4575-
# OFF also writes False for every beta sub-flag in the same
4576-
# save. Without this, sub-flags stay True in the override file
4577-
# (or in addon options) and resume the prior state the moment
4578-
# the master is flipped back on — the user would have to
4579-
# uncheck each sub-flag individually to "really" disable the
4580-
# beta block.
4581-
#
4582-
# Read the persisted override file directly here (NOT
4583-
# get_global_settings() — the master gate in
4584-
# _apply_feature_flag_overrides already forced sub-flags to
4585-
# False on the resolved Settings object, so we'd miss stale-
4586-
# true override values). Cascade writes False for every
4587-
# sub-flag that's not already False at the persistence layer.
4587+
# Master-off no longer cascades into sub-flag values (#1164
4588+
# follow-up). The runtime master gate in
4589+
# ``_apply_feature_flag_overrides`` continues to force every
4590+
# beta sub-flag to False whenever the master is off, so the
4591+
# tools stay disabled at runtime regardless of file state.
4592+
# Leaving the sub-flag values in the override file means
4593+
# re-enabling the master restores the user's prior sub-flag
4594+
# selections automatically — without it the user had to
4595+
# re-check each sub-flag individually after every
4596+
# master-off / master-on cycle, which is the wrong UX trade
4597+
# for an opt-in beta surface.
45884598
#
4589-
# ALSO force False even if the payload set the sub-flag to True
4590-
# — accepting an explicit ``{master: false, sub: true}`` would
4591-
# land an inconsistent persisted state that the runtime gate
4592-
# then ignores, leaving a "true-but-ignored" trap on the UI.
4593-
if new_overrides.get("enable_beta_features") is False:
4594-
overrides_on_disk = _read_feature_flag_override_file()
4595-
for sub in _BETA_SUB:
4596-
explicit = new_overrides.get(sub)
4597-
if explicit is False:
4598-
continue # already correctly False in this payload
4599-
# Persisted truthy OR payload-explicit-True both get
4600-
# force-cleared. Inspect both sources so the file
4601-
# cascade doesn't miss an override the user set
4602-
# earlier in standalone mode.
4603-
file_truthy = bool(overrides_on_disk.get(sub, False))
4604-
if explicit is True or file_truthy:
4605-
new_overrides[sub] = False
4599+
# The master-gate check above still rejects payloads that try
4600+
# to enable a sub-flag while the effective master is off, so
4601+
# users can't land a "sub=true while master=false in same
4602+
# payload" inconsistency. Pre-existing sub-flag truthy values
4603+
# in the file are kept verbatim.
46064604

46074605
# Addon-mode writes go to Supervisor instead of the override file:
46084606
# ``start.py`` reads ``config.yaml`` options on every boot and

tests/src/unit/test_settings_ui.py

Lines changed: 71 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -2767,16 +2767,17 @@ async def test_save_features_rejects_subflag_when_payload_turns_master_off(
27672767
_reset_global_settings()
27682768

27692769
@pytest.mark.asyncio
2770-
async def test_save_features_cascade_clears_subflags_when_master_off(
2770+
async def test_save_features_master_off_preserves_subflag_values(
27712771
self, monkeypatch, tmp_path
27722772
):
2773-
"""Master-off cascade clear (#1164 follow-up).
2774-
2775-
Flipping the master OFF in isolation must also write False for
2776-
every beta sub-flag currently truthy. Without the cascade,
2777-
sub-flags stay True in the override file and resume the
2778-
moment the master is flipped back on — UX bug the user
2779-
reported as "having to turn off every toggle individually."
2773+
"""Master-off does NOT cascade into sub-flag values (#1164
2774+
follow-up update). The runtime master gate forces sub-flags off
2775+
at runtime, but the persisted values stay so flipping the
2776+
master back on restores the user's prior sub-flag selections.
2777+
2778+
The previous cascade-clear behavior forced users to re-check
2779+
every sub-flag after every master-off/on cycle, which is the
2780+
wrong UX trade for an opt-in beta surface.
27802781
"""
27812782
from ha_mcp.config import (
27822783
BETA_FEATURE_FIELDS,
@@ -2804,31 +2805,29 @@ async def test_save_features_cascade_clears_subflags_when_master_off(
28042805
req.json = AsyncMock(return_value={"flags": {"enable_beta_features": False}})
28052806
resp = await handlers["save_feature_flags"](req)
28062807
assert resp.status_code == 200, json.loads(resp.body)
2807-
# Verify the cascade landed in the override file: every beta
2808-
# sub-flag must now read False.
2808+
# Master is now off in the file.
28092809
on_disk = json.loads((tmp_path / "feature_flags.json").read_text())
28102810
assert on_disk["enable_beta_features"] is False
2811+
# Sub-flag values are PRESERVED so toggling the master back on
2812+
# later restores them automatically.
28112813
for sub in BETA_FEATURE_FIELDS:
2812-
assert on_disk[sub] is False, (
2813-
f"cascade missed {sub} — stays {on_disk.get(sub)!r} after master-off save"
2814+
assert on_disk[sub] is True, (
2815+
f"sub-flag {sub} was clobbered on master-off — should have stayed True"
28142816
)
28152817
get_data_dir.cache_clear()
28162818
_reset_global_settings()
28172819

28182820
@pytest.mark.asyncio
2819-
async def test_save_features_cascade_clears_subflag_even_when_payload_says_true(
2821+
async def test_save_features_master_on_restores_runtime_subflag_values(
28202822
self, monkeypatch, tmp_path
28212823
):
2822-
"""In-payload ``{master: false, sub: true}`` would land an
2823-
inconsistent persisted state — runtime gate forces sub False
2824-
but the file still says True, looking on the UI like the
2825-
cascade didn't run. The cascade now force-clears any sub
2826-
explicitly set True in the same payload as master=false
2827-
(#1164 follow-up review).
2824+
"""Round-trip: master off → master on → previously-truthy sub-flags
2825+
come back through the runtime master gate (no manual re-toggle).
28282826
"""
28292827
from ha_mcp.config import (
28302828
FEATURE_FLAG_FIELDS,
28312829
_reset_global_settings,
2830+
get_global_settings,
28322831
)
28332832
from ha_mcp.settings_ui import build_settings_handlers
28342833
from ha_mcp.utils.data_paths import get_data_dir
@@ -2839,57 +2838,50 @@ async def test_save_features_cascade_clears_subflag_even_when_payload_says_true(
28392838
for _fname, ename, _ftype in FEATURE_FLAG_FIELDS:
28402839
monkeypatch.delenv(ename, raising=False)
28412840
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
2842-
# Pre-existing state: master on, both sub-flags on.
2841+
# File: master off + sub-flags true. Runtime gate forces sub
2842+
# to False on read.
28432843
(tmp_path / "feature_flags.json").write_text(
28442844
json.dumps(
28452845
{
2846-
"enable_beta_features": True,
2846+
"enable_beta_features": False,
28472847
"enable_yaml_config_editing": True,
28482848
"enable_filesystem_tools": True,
28492849
}
28502850
)
28512851
)
28522852
_reset_global_settings()
2853+
settings = get_global_settings()
2854+
assert settings.enable_beta_features is False
2855+
assert settings.enable_yaml_config_editing is False # gated off
2856+
assert settings.enable_filesystem_tools is False # gated off
2857+
# User flips master back on.
28532858
handlers = build_settings_handlers(server=None)
28542859
req = MagicMock()
2855-
# Caller's payload tries the inconsistent state — master off
2856-
# but yaml_config still asserted true. Master-gate check
2857-
# rejects this first (because beta sub-flag write is gated
2858-
# behind effective_master), so the response should 409 with
2859-
# the gate message, NOT silently land the inconsistent state.
2860-
req.json = AsyncMock(
2861-
return_value={
2862-
"flags": {
2863-
"enable_beta_features": False,
2864-
"enable_yaml_config_editing": True,
2865-
}
2866-
}
2867-
)
2860+
req.json = AsyncMock(return_value={"flags": {"enable_beta_features": True}})
28682861
resp = await handlers["save_feature_flags"](req)
2869-
assert resp.status_code == 409, json.loads(resp.body)
2870-
# Sanity check: file unchanged.
2871-
on_disk = json.loads((tmp_path / "feature_flags.json").read_text())
2872-
assert on_disk["enable_yaml_config_editing"] is True
2862+
assert resp.status_code == 200, json.loads(resp.body)
2863+
_reset_global_settings()
2864+
settings = get_global_settings()
2865+
# Sub-flags resume their prior True state because the file
2866+
# still has them.
2867+
assert settings.enable_beta_features is True
2868+
assert settings.enable_yaml_config_editing is True
2869+
assert settings.enable_filesystem_tools is True
28732870
get_data_dir.cache_clear()
28742871
_reset_global_settings()
28752872

28762873
@pytest.mark.asyncio
2877-
async def test_save_features_cascade_reads_override_file_not_post_gate_settings(
2874+
async def test_save_features_payload_master_false_sub_true_rejected_by_gate(
28782875
self, monkeypatch, tmp_path
28792876
):
2880-
"""Cascade-clear must read the persisted override file directly,
2881-
not ``get_global_settings()`` — the master gate already forced
2882-
sub-flags to False on the resolved Settings, so a read of
2883-
Settings would think there's nothing to clear and miss the
2884-
stale-true override (#1164 follow-up review).
2885-
2886-
Scenario: master was previously off in the override file but a
2887-
sub-flag was True (perhaps written via a manual edit before
2888-
the cascade-clear feature landed). User saves master=false.
2889-
After save, the file must show the sub-flag = False.
2877+
"""In-payload ``{master: false, sub: true}`` is rejected by the
2878+
master-gate check (effective_master=false → sub-flag write
2879+
not allowed). The persisted file is unchanged. Sub-flag values
2880+
are no longer cascade-cleared, so the gate rejection is the
2881+
only thing preventing the user from landing an inconsistent
2882+
"sub true while master will be false at runtime" state.
28902883
"""
28912884
from ha_mcp.config import (
2892-
BETA_FEATURE_FIELDS,
28932885
FEATURE_FLAG_FIELDS,
28942886
_reset_global_settings,
28952887
)
@@ -2902,42 +2894,48 @@ async def test_save_features_cascade_reads_override_file_not_post_gate_settings(
29022894
for _fname, ename, _ftype in FEATURE_FLAG_FIELDS:
29032895
monkeypatch.delenv(ename, raising=False)
29042896
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
2905-
# Master previously off, sub-flag True on disk (the master
2906-
# gate would force sub_X to False on the live Settings, so
2907-
# the old cascade impl reading Settings would miss this).
2897+
# Pre-existing state: master on, both sub-flags on.
29082898
(tmp_path / "feature_flags.json").write_text(
29092899
json.dumps(
29102900
{
2911-
"enable_beta_features": False,
2901+
"enable_beta_features": True,
2902+
"enable_yaml_config_editing": True,
29122903
"enable_filesystem_tools": True,
29132904
}
29142905
)
29152906
)
29162907
_reset_global_settings()
29172908
handlers = build_settings_handlers(server=None)
29182909
req = MagicMock()
2919-
req.json = AsyncMock(return_value={"flags": {"enable_beta_features": False}})
2910+
# Caller's payload tries the inconsistent state — master off
2911+
# but yaml_config still asserted true. Master-gate check
2912+
# rejects this first (because beta sub-flag write is gated
2913+
# behind effective_master), so the response should 409 with
2914+
# the gate message, NOT silently land the inconsistent state.
2915+
req.json = AsyncMock(
2916+
return_value={
2917+
"flags": {
2918+
"enable_beta_features": False,
2919+
"enable_yaml_config_editing": True,
2920+
}
2921+
}
2922+
)
29202923
resp = await handlers["save_feature_flags"](req)
2921-
assert resp.status_code == 200, json.loads(resp.body)
2924+
assert resp.status_code == 409, json.loads(resp.body)
2925+
# Sanity check: file unchanged.
29222926
on_disk = json.loads((tmp_path / "feature_flags.json").read_text())
2923-
assert on_disk["enable_filesystem_tools"] is False, (
2924-
f"cascade missed stale-true override; file: {on_disk}"
2925-
)
2926-
# Other sub-flags untouched (no stale-true to clear).
2927-
for sub in BETA_FEATURE_FIELDS:
2928-
if sub == "enable_filesystem_tools":
2929-
continue
2930-
assert sub not in on_disk or on_disk[sub] is False
2927+
assert on_disk["enable_yaml_config_editing"] is True
29312928
get_data_dir.cache_clear()
29322929
_reset_global_settings()
29332930

29342931
@pytest.mark.asyncio
2935-
async def test_save_features_master_off_does_not_clobber_already_false_subflags(
2932+
async def test_save_features_master_off_applied_dict_contains_only_master(
29362933
self, monkeypatch, tmp_path
29372934
):
2938-
"""Cascade only flips truthy sub-flags. False ones are left
2939-
alone (no redundant writes that would cause the response
2940-
``applied`` dict to balloon).
2935+
"""Master-off save's ``applied`` dict carries only the master
2936+
change — no synthetic sub-flag entries from a cascade. Pins
2937+
the no-cascade behavior so a future regression that re-adds
2938+
cascade-clear shows up in this test's diff.
29412939
"""
29422940
from ha_mcp.config import (
29432941
BETA_FEATURE_FIELDS,
@@ -2953,7 +2951,6 @@ async def test_save_features_master_off_does_not_clobber_already_false_subflags(
29532951
for _fname, ename, _ftype in FEATURE_FLAG_FIELDS:
29542952
monkeypatch.delenv(ename, raising=False)
29552953
monkeypatch.delenv("SUPERVISOR_TOKEN", raising=False)
2956-
# Master + one sub-flag on; rest at default (False).
29572954
(tmp_path / "feature_flags.json").write_text(
29582955
json.dumps(
29592956
{"enable_beta_features": True, "enable_yaml_config_editing": True}
@@ -2967,14 +2964,11 @@ async def test_save_features_master_off_does_not_clobber_already_false_subflags(
29672964
assert resp.status_code == 200, json.loads(resp.body)
29682965
body = json.loads(resp.body)
29692966
applied = body.get("applied", {})
2970-
# Master flipped + one sub-flag cleared. No noise for already-
2971-
# False sub-flags.
2972-
assert applied.get("enable_beta_features") is False
2973-
assert applied.get("enable_yaml_config_editing") is False
2967+
assert applied == {"enable_beta_features": False}, (
2968+
f"expected applied to contain only the master flip; got {applied}"
2969+
)
2970+
# Sub-flag values are NOT touched.
29742971
for sub in BETA_FEATURE_FIELDS:
2975-
if sub != "enable_yaml_config_editing":
2976-
assert sub not in applied, (
2977-
f"cascade clobbered already-False {sub} — wasted write"
2978-
)
2972+
assert sub not in applied
29792973
get_data_dir.cache_clear()
29802974
_reset_global_settings()

0 commit comments

Comments
 (0)