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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file.
updating, and diagnostics redact both values.
- **The panel view draws only the breaker positions the panel has**, and shows a battery charge the panel has not reported as unknown rather than 0%.
- **Setup stops with a repair issue when the panel reports a hardware version this release has not been validated with.**
- **New installs let only administrators operate the panel**, and an existing install keeps the Who may operate the panel setting it had.

### Removed

Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,12 +657,12 @@ Troubleshooting.
Four options in **Settings → Devices & Services → Span Panel → Configure**. Each is enforced where commands are published rather than only at the entity, so a
refusal holds however the command arrived.

| Option | Default | What it refuses | What it does not do |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **Who may operate the panel** | Any Home Assistant user — Home Assistant's own policy, which grants every non-admin control of every entity | `Administrators only` refuses circuit switches, priority selects, the GFE override, EVSE limits and adopted controls from non-admin users. `Nobody` also stops those entities being created. | Neither touches sensors. Neither reaches an automation, which runs with no user at all — pair it with the next option. |
| **Allow control without a logged-in user** | Off for a new install. An install set up before this default changed keeps its setting: on, unless you had turned it off | While off, refuses every command with no user attached: all automations, and any script or integration an automation calls. A script a person runs keeps that person's user and is unaffected. Turn it on if your automations operate circuits. | It cannot tell a well-behaved automation from a runaway one — only that neither has a user. |
| **Control lock auto-relock** | Off, and no switch exists | A switch that, while armed, refuses every control command. Anyone can arm it; only a logged-in administrator can disarm it. `0` holds the disarm until someone re-arms; any other value re-arms after that many minutes. | It is not a password. It defends against misclicks and runaway automations, which is what a local control can actually do. |
| **Relay debounce** | 2 seconds — **the one default that is not what your panel did before this release**; set `0` to restore that | A second command to the same circuit's relay inside the window. Refused, never queued. | It is per circuit, so an automation cycling many circuits still gets through. |
| Option | Default | What it refuses | What it does not do |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **Who may operate the panel** | Administrators only for a new install. An install set up before this default changed keeps its setting: any Home Assistant user, which is Home Assistant's own policy, unless you had chosen another | `Administrators only` refuses circuit switches, priority selects, the GFE override, EVSE limits and adopted controls from non-admin users. `Nobody` also stops those entities being created. | Neither touches sensors. Neither reaches an automation, which runs with no user at all — pair it with the next option. |
| **Allow control without a logged-in user** | Off for a new install. An install set up before this default changed keeps its setting: on, unless you had turned it off | While off, refuses every command with no user attached: all automations, and any script or integration an automation calls. A script a person runs keeps that person's user and is unaffected. Turn it on if your automations operate circuits. | It cannot tell a well-behaved automation from a runaway one — only that neither has a user. |
| **Control lock auto-relock** | Off, and no switch exists | A switch that, while armed, refuses every control command. Anyone can arm it; only a logged-in administrator can disarm it. `0` holds the disarm until someone re-arms; any other value re-arms after that many minutes. | It is not a password. It defends against misclicks and runaway automations, which is what a local control can actually do. |
| **Relay debounce** | 2 seconds — **the one default that is not what your panel did before this release**; set `0` to restore that | A second command to the same circuit's relay inside the window. Refused, never queued. | It is per circuit, so an automation cycling many circuits still gets through. |

**What none of this defends against.** These options constrain callers arriving through Home Assistant, and nothing else. Anything already holding the broker
credential — a second Home Assistant instance, a script you wrote, a malicious custom integration reading it out of this process's memory — publishes to the
Expand Down
10 changes: 6 additions & 4 deletions custom_components/span_panel/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,11 @@
USE_DEVICE_PREFIX,
EntityNamingPattern,
)
from .control_gate import DEFAULT_ALLOW_CONTEXTLESS_CONTROL
from .control_gate import DEFAULT_ALLOW_CONTEXTLESS_CONTROL, DEFAULT_CONTROL_MODE
from .hardware_guard import registers_by_passphrase_only
from .options import (
ALLOW_CONTEXTLESS_CONTROL,
CONTROL_MODE,
ENERGY_DISPLAY_PRECISION,
POWER_DISPLAY_PRECISION,
)
Expand Down Expand Up @@ -178,7 +179,7 @@ class SpanPanelConfigFlow(config_entries.ConfigFlow):
"""Handle a config flow for Span Panel."""

VERSION = 7
MINOR_VERSION = 3
MINOR_VERSION = 4
domain = DOMAIN

def is_matching(self, other_flow: SpanPanelConfigFlow) -> bool:
Expand Down Expand Up @@ -1365,10 +1366,11 @@ def create_new_entry(
POWER_DISPLAY_PRECISION: self.power_display_precision,
ENERGY_DISPLAY_PRECISION: self.energy_display_precision,
ENABLE_ENERGY_DIP_COMPENSATION: self._enable_dip_compensation,
# Stored rather than left to the default, so a release from
# Both stored rather than left to the default, so a release from
# before that default existed reads this entry the same way
# instead of treating the absent key as on.
# instead of treating the absent key as its own, looser default.
ALLOW_CONTEXTLESS_CONTROL: DEFAULT_ALLOW_CONTEXTLESS_CONTROL,
CONTROL_MODE: DEFAULT_CONTROL_MODE.value,
},
)

Expand Down
8 changes: 5 additions & 3 deletions custom_components/span_panel/config_flow_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)
from .control_gate import (
DEFAULT_ALLOW_CONTEXTLESS_CONTROL,
DEFAULT_CONTROL_MODE,
DEFAULT_RELAY_DEBOUNCE_SECONDS,
ControlMode,
)
Expand Down Expand Up @@ -103,9 +104,10 @@ def get_general_options_defaults(
# Every default below reproduces what an entry already does. A silent
# tightening on upgrade would break a household's automations with an
# error the user cannot diagnose from the entity. Contextless control
# can default to off only because migration 7.2 stored the old `True`
# in every entry that predates it.
CONTROL_MODE: config_entry.options.get(CONTROL_MODE, ControlMode.ALL_USERS.value),
# can default to off, and the mode to administrators, only because
# migrations 7.2 and 7.4 stored the old values in every entry that
# predates them.
CONTROL_MODE: config_entry.options.get(CONTROL_MODE, DEFAULT_CONTROL_MODE.value),
ALLOW_CONTEXTLESS_CONTROL: config_entry.options.get(
ALLOW_CONTEXTLESS_CONTROL, DEFAULT_ALLOW_CONTEXTLESS_CONTROL
),
Expand Down
20 changes: 15 additions & 5 deletions custom_components/span_panel/control_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ class ControlMode(StrEnum):
see the platforms."""


DEFAULT_CONTROL_MODE = ControlMode.ADMIN_ONLY
"""Who may operate the panel, for an entry that never said.

Administrators, because an install that has made no choice should not hand
every non-admin user the panel's relays. Entries created before this default
existed allowed any user; migration 7.4 writes that into each one's options, so
this constant only ever decides for an entry created since.
"""


@dataclass(frozen=True, slots=True)
class ControlCaller:
"""Who issued the command currently being published, if anyone.
Expand Down Expand Up @@ -143,9 +153,9 @@ class ControlPolicy:
Every default reproduces the behaviour an existing entry already has. A
silent tightening on upgrade that breaks a household's automations is a worse
outcome than the status quo, and the user cannot diagnose it from the
entity's error. `allow_contextless` departs from what an older entry did, and
can only because migration 7.2 stored that older behaviour in every such
entry before this reads it.
entity's error. `mode` and `allow_contextless` depart from what an older
entry did, and can only because migrations 7.4 and 7.2 stored that older
behaviour in every such entry before this reads it.
"""

mode: ControlMode
Expand All @@ -166,7 +176,7 @@ def default(cls) -> ControlPolicy:
four constants repeated at each reader.
"""
return cls(
mode=ControlMode.ALL_USERS,
mode=DEFAULT_CONTROL_MODE,
allow_contextless=DEFAULT_ALLOW_CONTEXTLESS_CONTROL,
lock_timeout_minutes=None,
relay_debounce_seconds=DEFAULT_RELAY_DEBOUNCE_SECONDS,
Expand All @@ -177,7 +187,7 @@ def from_options(cls, options: Mapping[str, object]) -> ControlPolicy:
"""Resolve the policy from one entry's options."""
raw_mode = options.get(CONTROL_MODE)
try:
mode = ControlMode(raw_mode) if isinstance(raw_mode, str) else ControlMode.ALL_USERS
mode = ControlMode(raw_mode) if isinstance(raw_mode, str) else DEFAULT_CONTROL_MODE
except ValueError:
_LOGGER.warning("Unknown control_mode %r; falling back to all_users", raw_mode)
mode = ControlMode.ALL_USERS
Expand Down
29 changes: 26 additions & 3 deletions custom_components/span_panel/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from homeassistant.helpers import entity_registry as er

from .const import CONF_API_VERSION, CONF_HOP_PASSPHRASE, PANEL_CA_PENDING
from .options import ALLOW_CONTEXTLESS_CONTROL
from .control_gate import ControlMode
from .options import ALLOW_CONTEXTLESS_CONTROL, CONTROL_MODE

if TYPE_CHECKING:
from .runtime import SpanPanelConfigEntry
Expand All @@ -19,17 +20,19 @@

# Must match the storage version produced by the latest supported entry format.
CURRENT_CONFIG_VERSION = 7
CURRENT_CONFIG_MINOR_VERSION = 3
CURRENT_CONFIG_MINOR_VERSION = 4

_UNMAPPED_TAB_SENSORS_OPTION = "enable_unmapped_circuit_sensors"
"""The retired option's key, spelled out because nothing else reads it any more."""

_CONTROL_MODE_VALUES = frozenset(mode.value for mode in ControlMode)


async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfigEntry) -> bool:
"""Migrate config entry through successive versions.

Supports upgrades from v1.3.1+ (config version 2) through to the
current version 7.3. Each step mutates only the fields relevant to
current version 7.4. Each step mutates only the fields relevant to
that version boundary.

Core also calls this for an entry a newer release has already moved past
Expand Down Expand Up @@ -203,6 +206,26 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: SpanPanelConfig
)
_LOGGER.debug("Migrated config entry %s to version 7.3", config_entry.entry_id)

# --- v7.3 → v7.4: pin who may operate the panel to what it was ---
if config_entry.version == 7 and config_entry.minor_version < 4:
updated_options = dict(config_entry.options)
# The default for an entry that never stored this became administrators
# only. Every entry reaching this step was created before that, when
# anything but a known mode resolved to any user, so that is written
# down here rather than letting an upgrade take the panel's controls
# away from a household's non-admin users. A known mode is the user's
# own choice and is kept.
stored_mode = updated_options.get(CONTROL_MODE)
if not (isinstance(stored_mode, str) and stored_mode in _CONTROL_MODE_VALUES):
updated_options[CONTROL_MODE] = ControlMode.ALL_USERS.value

hass.config_entries.async_update_entry(
config_entry,
options=updated_options,
minor_version=4,
)
_LOGGER.debug("Migrated config entry %s to version 7.4", config_entry.entry_id)

return True


Expand Down
10 changes: 5 additions & 5 deletions tests/test_control_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,16 +668,16 @@ def test_only_failed_means_it_will_never_be_delivered(


def test_an_entry_with_no_control_options_gets_the_defaults() -> None:
"""Permissive except for contextless control, which an older entry has stored.
"""Administrators only, with no contextless control, both of which an older entry has stored.

A silent tightening on upgrade is worse than the status quo, so the one
default that tightens is safe only because migration 7.2 wrote the old
behaviour into every entry from before it; see the two tests above.
A silent tightening on upgrade is worse than the status quo, so the two
defaults that tighten are safe only because migrations 7.2 and 7.4 wrote
the old behaviour into every entry from before them.
"""
policy = ControlPolicy.from_options({})

assert policy == ControlPolicy.default()
assert policy.mode is ControlMode.ALL_USERS
assert policy.mode is ControlMode.ADMIN_ONLY
assert policy.allow_contextless is False
assert policy.lock_enabled is False
assert policy.relay_debounce_seconds == 2.0
Expand Down
Loading
Loading