Compare commits

...

1 Commits

Author SHA1 Message Date
Mike Degatano
b8a7fd4ef1 Add experimental feature toggle system
Introduces an ExperimentalFeature enum and feature_flags config to allow
toggling experimental features via the supervisor options API. The first
feature flag is 'supervisor_v2_api' to gate the upcoming V2 API.

Absent keys in options request = no change (partial update, consistent
with existing options APIs). The info endpoint always returns all known
feature flags and their current state for discoverability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 04:19:33 +00:00
5 changed files with 118 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ from ..const import (
ATTR_DEBUG_BLOCK,
ATTR_DETECT_BLOCKING_IO,
ATTR_DIAGNOSTICS,
ATTR_FEATURE_FLAGS,
ATTR_HEALTHY,
ATTR_ICON,
ATTR_IP_ADDRESS,
@@ -41,6 +42,7 @@ from ..const import (
ATTR_VERSION,
ATTR_VERSION_LATEST,
ATTR_WAIT_BOOT,
ExperimentalFeature,
LogLevel,
UpdateChannel,
)
@@ -70,6 +72,9 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(),
vol.Optional(ATTR_DETECT_BLOCKING_IO): vol.Coerce(DetectBlockingIO),
vol.Optional(ATTR_COUNTRY): str,
vol.Optional(ATTR_FEATURE_FLAGS): vol.Schema(
{vol.Coerce(ExperimentalFeature): vol.Boolean()}
),
}
)
@@ -104,6 +109,10 @@ class APISupervisor(CoreSysAttributes):
ATTR_AUTO_UPDATE: self.sys_updater.auto_update,
ATTR_DETECT_BLOCKING_IO: BlockBusterManager.is_enabled(),
ATTR_COUNTRY: self.sys_config.country,
ATTR_FEATURE_FLAGS: {
feature: self.sys_config.feature_flags.get(feature, False)
for feature in ExperimentalFeature
},
# Depricated
ATTR_WAIT_BOOT: self.sys_config.wait_boot,
ATTR_ADDONS: [
@@ -182,6 +191,10 @@ class APISupervisor(CoreSysAttributes):
if ATTR_WAIT_BOOT in body:
self.sys_config.wait_boot = body[ATTR_WAIT_BOOT]
if ATTR_FEATURE_FLAGS in body:
for feature, enabled in body[ATTR_FEATURE_FLAGS].items():
self.sys_config.set_feature_flag(feature, enabled)
# Save changes before processing addons in case of errors
await self.sys_updater.save_data()
await self.sys_config.save_data()

View File

@@ -15,6 +15,7 @@ from .const import (
ATTR_DEBUG_BLOCK,
ATTR_DETECT_BLOCKING_IO,
ATTR_DIAGNOSTICS,
ATTR_FEATURE_FLAGS,
ATTR_IMAGE,
ATTR_LAST_BOOT,
ATTR_LOGGING,
@@ -24,6 +25,7 @@ from .const import (
ENV_SUPERVISOR_SHARE,
FILE_HASSIO_CONFIG,
SUPERVISOR_DATA,
ExperimentalFeature,
LogLevel,
)
from .utils.common import FileConfiguration
@@ -195,6 +197,17 @@ class CoreConfig(FileConfiguration):
lvl = getattr(logging, self.logging.value.upper())
logging.getLogger("supervisor").setLevel(lvl)
@property
def feature_flags(self) -> dict[ExperimentalFeature, bool]:
"""Return current state of all experimental feature flags."""
return self._data.get(ATTR_FEATURE_FLAGS, {})
def set_feature_flag(self, feature: ExperimentalFeature, enabled: bool) -> None:
"""Enable or disable an experimental feature flag."""
if ATTR_FEATURE_FLAGS not in self._data:
self._data[ATTR_FEATURE_FLAGS] = {}
self._data[ATTR_FEATURE_FLAGS][feature] = enabled
@property
def last_boot(self) -> datetime:
"""Return last boot datetime."""

View File

@@ -192,6 +192,7 @@ ATTR_ENVIRONMENT = "environment"
ATTR_EVENT = "event"
ATTR_EXCLUDE_DATABASE = "exclude_database"
ATTR_EXTRA = "extra"
ATTR_FEATURE_FLAGS = "feature_flags"
ATTR_FEATURES = "features"
ATTR_FIELDS = "fields"
ATTR_FILENAME = "filename"
@@ -548,6 +549,12 @@ class CpuArch(StrEnum):
AMD64 = "amd64"
class ExperimentalFeature(StrEnum):
"""Experimental features that can be toggled."""
SUPERVISOR_V2_API = "supervisor_v2_api"
@dataclass
class HomeAssistantUser:
"""A Home Assistant Core user.

View File

@@ -20,6 +20,7 @@ from .const import (
ATTR_DISPLAYNAME,
ATTR_DNS,
ATTR_ENABLE_IPV6,
ATTR_FEATURE_FLAGS,
ATTR_FORCE_SECURITY,
ATTR_HASSOS,
ATTR_HASSOS_UNRESTRICTED,
@@ -47,6 +48,7 @@ from .const import (
ATTR_VERSION,
ATTR_WAIT_BOOT,
SUPERVISOR_VERSION,
ExperimentalFeature,
LogLevel,
UpdateChannel,
)
@@ -212,6 +214,9 @@ SCHEMA_SUPERVISOR_CONFIG = vol.Schema(
vol.Optional(ATTR_DIAGNOSTICS, default=None): vol.Maybe(vol.Boolean()),
vol.Optional(ATTR_DETECT_BLOCKING_IO, default=False): vol.Boolean(),
vol.Optional(ATTR_COUNTRY): str,
vol.Optional(ATTR_FEATURE_FLAGS, default=dict): vol.Schema(
{vol.Coerce(ExperimentalFeature): vol.Boolean()}
),
},
extra=vol.REMOVE_EXTRA,
)

View File

@@ -11,7 +11,7 @@ from awesomeversion import AwesomeVersion
from blockbuster import BlockingError
import pytest
from supervisor.const import CoreState
from supervisor.const import CoreState, ExperimentalFeature
from supervisor.core import Core
from supervisor.coresys import CoreSys
from supervisor.exceptions import HassioError, HostNotSupportedError, StoreGitError
@@ -451,3 +451,82 @@ async def test_supervisor_api_stats_failure(
"Could not inspect container 'hassio_supervisor': [500] {'message': 'fail'}"
in caplog.text
)
async def test_api_supervisor_info_feature_flags(
api_client: TestClient, coresys: CoreSys
):
"""Test that supervisor info returns all feature flags with default False."""
resp = await api_client.get("/supervisor/info")
assert resp.status == 200
result = await resp.json()
assert "feature_flags" in result["data"]
feature_flags = result["data"]["feature_flags"]
# All known experimental features should be present and default to False
for feature in ExperimentalFeature:
assert feature in feature_flags
assert feature_flags[feature] is False
async def test_api_supervisor_options_feature_flags_enable(
api_client: TestClient, coresys: CoreSys
):
"""Test enabling a feature flag via supervisor options."""
assert not coresys.config.feature_flags.get(ExperimentalFeature.SUPERVISOR_V2_API)
response = await api_client.post(
"/supervisor/options",
json={"feature_flags": {"supervisor_v2_api": True}},
)
assert response.status == 200
assert (
coresys.config.feature_flags.get(ExperimentalFeature.SUPERVISOR_V2_API) is True
)
async def test_api_supervisor_options_feature_flags_disable(
api_client: TestClient, coresys: CoreSys
):
"""Test disabling a feature flag via supervisor options."""
coresys.config.set_feature_flag(ExperimentalFeature.SUPERVISOR_V2_API, True)
assert (
coresys.config.feature_flags.get(ExperimentalFeature.SUPERVISOR_V2_API) is True
)
response = await api_client.post(
"/supervisor/options",
json={"feature_flags": {"supervisor_v2_api": False}},
)
assert response.status == 200
assert (
coresys.config.feature_flags.get(ExperimentalFeature.SUPERVISOR_V2_API) is False
)
async def test_api_supervisor_options_feature_flags_partial_update(
api_client: TestClient, coresys: CoreSys
):
"""Test that omitting a feature flag in options leaves its state unchanged."""
coresys.config.set_feature_flag(ExperimentalFeature.SUPERVISOR_V2_API, True)
# Post options without mentioning feature_flags at all
response = await api_client.post("/supervisor/options", json={"debug": False})
assert response.status == 200
# The feature flag should remain True
assert (
coresys.config.feature_flags.get(ExperimentalFeature.SUPERVISOR_V2_API) is True
)
async def test_api_supervisor_options_feature_flags_unknown_flag(
api_client: TestClient,
):
"""Test that an unknown feature flag name is rejected."""
response = await api_client.post(
"/supervisor/options",
json={"feature_flags": {"unknown_feature": True}},
)
assert response.status == 400