Skip to content
1 change: 1 addition & 0 deletions .github/release-notes/2.0.0b16.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ See the cover docs for the details and the known limits.

- **Diagnostics**: the gateway profile block reports the OWNd profile's real `model_name`, `display_name`, `event_keepalive_interval` and `keepalive_summary` instead of `Generic` / 90 s for every gateway.
- **Setup**: when `myhome.yaml` has gateway sections but none under this gateway's MAC, a warning names the entry MAC and the section keys that match no configured gateway (#106).
- **Setup**: a gateway added manually is asked who it is once the password is accepted (`*#13**15##`, then `*#1013**1##` on the shared code 200, the MAC only from models known to answer `*#13**12##`); a typed placeholder MAC or model is corrected from the reply.
- **Expose Home Assistant to the bus**: a declarative `expose` engine projects HA entities and the clock onto free SCS addresses: virtual WHO 1 / WHO 2 actuators that a wall control can drive (mirroring HA state back to its LEDs), WHO 4 virtual temperature probes, WHO 18 virtual energy meters and the WHO 13 bus clock. Custom actions run as Home Assistant scripts (#750).
- **Covers**: `class:` in `myhome.yaml` now reaches WHO 2 covers (a `curtain` is no longer shown as a shutter; #39); Venetian blind slat tilt as timed slat pulses (`slat_tilt`, `slat_time`) (#508); impulse garage and gate covers on WHO 1 relays with optional safety approval, door-entry strike locks and doorbell events (#667); native broadcast for general covers behind an interface (#679); a cover stop overtakes other queued frames (#694); cover kinematics split into pluggable strategies (#739).
- **Lighting**: 2-digit Area 0 point addresses `01`–`09` (#713); ON dimmers on a fresh install are probed for brightness support (#742); timed turn-on uses the OWNd builders (#659); lights the startup sweep already reported are not polled again (#701); area and group re-syncs only skip once every member has echoed, counting only the members' own echoes in the commanded state (#740, #749).
Expand Down
13 changes: 11 additions & 2 deletions custom_components/myhome/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@
TEST_CONNECTION_ABORT_REASONS,
TEST_CONNECTION_RETRY_DELAY,
ConfigFlowDiscoveryMixin,
GatewayIdentity,
MACAddress,
_get_serial_ports,
identify_gateway,
)
from .config_flow_expose import ExposeSubentryFlowHandler
from .config_flow_options import MyhomeOptionsFlowHandler
Expand Down Expand Up @@ -73,6 +75,8 @@ def __init__(self): # type: ignore
self.gateway_handler: Optional[OWNGateway] = None
self.discovered_gateways: Optional[Dict[str, dict[str, typing.Any]]] = None
self._existing_entry: ConfigEntry | None = None
# Set by the manual step: the MAC and model were typed, not discovered.
self._identify_after_auth = False
# (title, data, options) of an entry held back by the bus topology step
self._pending_entry: (
tuple[str, dict[str, typing.Any], dict[str, typing.Any]] | None
Expand Down Expand Up @@ -302,8 +306,11 @@ async def async_step_custom_manual(self, user_input=None, errors=None): # type:
user_input["modelNumber"] = None
user_input["UDN"] = None
self.gateway_handler = OWNGateway(user_input)
await self.async_set_unique_id(user_input["serialNumber"], raise_on_progress=False)
self._abort_if_unique_id_configured()
# Typed by hand: once authenticated, ask the gateway for its MAC and model.
# The unique_id is set from the MAC the gateway then reports (or the typed
# one), so a second gateway entered with the form's placeholder MAC is not
# refused before it could say who it is.
self._identify_after_auth = True
return await self.async_step_test_connection()

address_val = getattr(self, "_custom_address", "192.168.1.100")
Expand Down Expand Up @@ -353,4 +360,6 @@ async def async_step_custom_manual(self, user_input=None, errors=None): # type:
"command_session_default",
"find_gateways",
"get_gateway",
"GatewayIdentity",
"identify_gateway",
]
261 changes: 248 additions & 13 deletions custom_components/myhome/config_flow_discovery.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""SSDP discovery, reconfigure, reauth, connection test, and authentication steps for MyHome config flow (#732)."""
from __future__ import annotations

import asyncio
import contextlib
import ipaddress
import logging
import re
import typing

Expand All @@ -17,6 +20,9 @@
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from OWNd.connection import OWNCommandSession
from OWNd.message import OWNGatewayCommand, OWNGatewayDiagnosticCommand
from OWNd.profiles import get_gateway_profile
from voluptuous import All, Coerce, In, Range, Required

from .const import (
Expand All @@ -33,6 +39,14 @@
LOGGER,
)
from .gateway import command_session_default
from .identity import (
UNTRUSTED_MODEL_LABELS,
CodeReading,
GatewayIdentityEvidence,
read_who13,
read_who1013,
resolve_gateway_identity,
)
from .typing_compat import flow_schema

TEST_CONNECTION_ABORT_REASONS = frozenset(
Expand All @@ -48,6 +62,142 @@
}
)
TEST_CONNECTION_RETRY_DELAY: float = 1.5
IDENTIFY_TIMEOUT: float = 15.0
# Models a physical unit answered *#13**12## with its real MAC (F461 2.0.11,
# MyHomeServer1 3.87.13, both 2026-10-24). Real H4684 020054 and MH200 020102
# daemons crash on the request, and the absence of a crash on an emulated daemon
# is no proof of safety, so an unknown or unlisted model is never asked.
MAC_REQUEST_SAFE_MODELS: frozenset[str] = frozenset({"F461", "MYHOMESERVER1"})
# A reported MAC that names no interface: the all-zero factory value (also the
# "Custom (IP Gateway)" sentinel of the first form) and the broadcast address.
UNUSABLE_MACS: frozenset[str] = frozenset({"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"})



class _RecordAtDebug(logging.Filter):
"""Lower every record of the identification probe to DEBUG.

The probe is best effort and its refusals are expected: a gateway with an open
monitor session (`*99*1##`) and a password-protected command session (`*99*0##`)
answers it with `password_required`, which OWNd would otherwise log at WARNING and
ERROR in the middle of a successful setup.
"""

def filter(self, record: logging.LogRecord) -> bool:
record.levelno = logging.DEBUG
record.levelname = logging.getLevelName(logging.DEBUG)
return True


# A registered child logger, so logging.getLogger(PROBE_LOGGER.name) is this object.
PROBE_LOGGER: logging.Logger = LOGGER.getChild("identify")
PROBE_LOGGER.addFilter(_RecordAtDebug())


class GatewayIdentity(typing.NamedTuple):
"""What ``identify_gateway`` learned: the three answers, and whether the command session wants a password."""

mac: str | None
who13_code: str | None
who1013_code: str | None
password_required: bool = False


def mac_request_is_safe(who13_code: str | None, who1013_code: str | None = None) -> bool:
"""Whether every model the reported codes can name is known to answer *#13**12##.

The WHO=1013 code decides when present (it is only asked on the shared code 200);
otherwise the WHO=13 device type does.
"""
if who1013_code:
models = read_who1013(who1013_code).models
elif who13_code and not read_who13(who13_code).shared:
models = read_who13(who13_code).models
else:
return False
normalised = {m.strip().upper().replace(" ", "").replace("-", "").replace("_", "") for m in models}
return bool(normalised) and normalised <= MAC_REQUEST_SAFE_MODELS


def validated_reported_mac(mac: typing.Any) -> str | None:
"""The MAC a gateway reported, normalised, or ``None`` when it cannot name the gateway.

OWNd formats any six integers of ``*#13**12*a*b*c*d*e*f##`` without range checks,
so an octet above 255 yields a 13-digit string, and a factory configuration answers
six zeros; neither may replace the MAC the user typed.
"""
if not mac:
return None
try:
formatted = dr.format_mac(str(MACAddress(str(mac))))
except ValueError:
return None
return None if formatted in UNUSABLE_MACS else formatted


def _inband_reading(who13_code: str | None, who1013_code: str | None) -> CodeReading:
"""The code reading the resolver labelled the gateway from: WHO=1013 when known, else WHO=13."""
if who1013_code and read_who1013(who1013_code).known:
return read_who1013(who1013_code)
return read_who13(who13_code or "")


async def identify_gateway(gateway: typing.Any) -> GatewayIdentity:
"""Ask an authenticated gateway who it is: ``(mac, who13_code, who1013_code)``.

A command session refused with ``password_required`` yields ``password_required=True``
and nothing else: the gateway protects ``*99*0##`` although its monitor session was
open, and the runtime command workers will need that password anyway.

The order follows the runtime identification (#420): ``*#13**15##`` first, which
every gateway answers; ``*#1013**1##`` only when that answer is the shared code
``200`` (F454 / F461 / MyHomeServer1 class), keeping older gateways away from a
diagnostic frame they may not support; ``*#13**12##`` (MAC) only from a model known
to answer it (``MAC_REQUEST_SAFE_MODELS``): it crashes a daemon on the H4684 and the
MH200 in the firmware oracle. Best effort: any failure or silence yields ``None``.
"""
mac: str | None = None
code13: str | None = None
code1013: str | None = None
session = OWNCommandSession(gateway=gateway, logger=PROBE_LOGGER)

async def _ask(request: typing.Any) -> list[typing.Any]:
replies = await session.send(request, is_status_request=True)
return replies if isinstance(replies, list) else []

try:
async with asyncio.timeout(IDENTIFY_TIMEOUT):
res = await session.connect()
if not (isinstance(res, dict) and res.get("Success")):
refused = isinstance(res, dict) and res.get("Message") == "password_required"
return GatewayIdentity(None, None, None, password_required=bool(refused))
for reply in await _ask(OWNGatewayCommand.request_device_type()):
value = getattr(reply, "device_type_code", None)
if getattr(reply, "who", None) == 13 and value:
code13 = code13 or str(value)
# A shared code (WHO13_SHARED_DEVICE_TYPES) names no model: ask WHO=1013 (#420)
if code13 and read_who13(code13).shared:
for reply in await _ask(OWNGatewayDiagnosticCommand.request_diagnostic_object_model()):
if getattr(reply, "who", None) == 1013 and getattr(reply, "object_model", None):
code1013 = code1013 or str(reply.object_model)
if mac_request_is_safe(code13, code1013):
for reply in await _ask(OWNGatewayCommand.request_mac_address()):
mac = mac or getattr(reply, "mac_address", None)
else:
LOGGER.debug(
"Gateway %s reports WHO=13 device type %s / WHO=1013 model %s; not asking its MAC "
"(*#13**12##), which is only known to be answered by %s",
getattr(gateway, "address", None),
code13,
code1013,
", ".join(sorted(MAC_REQUEST_SAFE_MODELS)),
)
except Exception as err: # pylint: disable=broad-except
LOGGER.debug("Gateway %s identification failed: %r", getattr(gateway, "address", None), err)
finally:
with contextlib.suppress(Exception):
await session.close()
return GatewayIdentity(mac, code13, code1013)


class MACAddress:
Expand Down Expand Up @@ -303,18 +453,7 @@ async def async_step_test_connection( # pylint: disable=unused-argument
gateway = self.gateway_handler
assert gateway is not None

self.context.update( # type: ignore[attr-defined]
{
CONF_HOST: gateway.host,
CONF_NAME: gateway.model_name,
CONF_MAC: gateway.serial,
"title_placeholders": {
CONF_HOST: str(gateway.host or ""),
CONF_NAME: str(gateway.model_name or ""),
CONF_MAC: str(gateway.serial or ""),
},
}
)
self._update_flow_context(gateway)

async def _run_test_connection() -> dict[str, typing.Any]:
try:
Expand Down Expand Up @@ -368,6 +507,30 @@ async def _run_test_connection() -> dict[str, typing.Any]:
reason="reauth_successful",
)

model_source: str | None = None
if getattr(self, "_identify_after_auth", False):
model_source = await self._async_apply_gateway_identity(gateway)
if model_source is None and not getattr(self, "_command_password_asked", False):
# Split authentication: the monitor session is open, the command
# session is password-protected. Ask once (the command workers need
# it); a second refusal, e.g. after a blank password, is not asked again.
self._command_password_asked = True
LOGGER.debug(
"Gateway %s (%s:%s) accepts the monitor session without a password "
"but protects the command session; asking for it",
gateway.model_name,
gateway.address,
gateway.port,
)
return await self.async_step_password() # type: ignore
self._identify_after_auth = False
# The MAC and model may have changed: the flow title must say so
self._update_flow_context(gateway)
# The manual step left the unique_id to this point: the MAC the gateway
# reports, or the typed one, decides whether this gateway already exists.
await self.async_set_unique_id(dr.format_mac(gateway.serial), raise_on_progress=False) # type: ignore[attr-defined]
self._abort_if_unique_id_configured() # type: ignore[attr-defined]

_new_entry_data = {
Comment on lines +511 to 534

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Refresh self.context (CONF_NAME, CONF_MAC, title_placeholders) after _async_apply_gateway_identity

self.context is populated at lines 391–402 before _async_apply_gateway_identity(gateway) updates gateway.serial and gateway.model_name. When self._bus_primaries() is truthy and the flow proceeds to async_step_bus_topology(), self.context and title_placeholders retain the stale placeholder MAC and uncorrected model.

CONF_ID: dr.format_mac(gateway.serial),
CONF_HOST: gateway.address,
Expand All @@ -384,6 +547,8 @@ async def _run_test_connection() -> dict[str, typing.Any]:
CONF_MAC: dr.format_mac(gateway.serial),
CONF_UDN: gateway.udn,
}
if model_source:
_new_entry_data["model_source"] = model_source
_new_entry_options = {
CONF_WORKER_COUNT: command_session_default(gateway.model_name),
}
Expand All @@ -403,7 +568,8 @@ async def _run_test_connection() -> dict[str, typing.Any]:
)
else:
msg = test_result.get("Message")
LOGGER.warning(
# password_required is the normal first answer of a password-protected gateway.
(LOGGER.debug if msg == "password_required" else LOGGER.warning)(
"Gateway %s (%s:%s) test connection failed: %s",
gateway.model_name,
gateway.address,
Expand All @@ -423,6 +589,75 @@ async def _run_test_connection() -> dict[str, typing.Any]:
)
return self.async_abort(reason=abort_reason) # type: ignore[attr-defined]

def _update_flow_context(self, gateway: typing.Any) -> None:
"""Keep the flow context and its title placeholders in step with the gateway's MAC and model."""
self.context.update( # type: ignore[attr-defined]
{
CONF_HOST: gateway.host,
CONF_NAME: gateway.model_name,
CONF_MAC: gateway.serial,
"title_placeholders": {
CONF_HOST: str(gateway.host or ""),
CONF_NAME: str(gateway.model_name or ""),
CONF_MAC: str(gateway.serial or ""),
},
}
)

async def _async_apply_gateway_identity(self, gateway: typing.Any) -> str | None:
"""Correct the MAC and model typed in the manual step from what the gateway reports.

The caller sets the unique_id from the final MAC afterwards. The model follows
``resolve_gateway_identity``, the same verdict the runtime reaches: a brand
variant of the reported model is kept, an uncertain WHO=13 code may label a
``Generic`` entry but never overrides a chosen model, a certain code corrects
it. Returns the source the model rests on, for the entry's ``model_source``, or
``None`` when the command session wants a password the monitor session did not.
"""
from . import config_flow as _cf

mac, code13, code, password_required = GatewayIdentity(*await _cf.identify_gateway(gateway))
if password_required and not gateway.password:
return None
# A refusal after a password was given is a refusal, not a question: nothing reported
reported_mac = validated_reported_mac(mac)
if mac and not reported_mac:
LOGGER.debug(
"Gateway at %s reports the MAC %r, which names no interface; keeping the entered %s",
gateway.address,
mac,
gateway.serial,
)
if reported_mac and reported_mac != dr.format_mac(str(gateway.serial)):
LOGGER.info(
"Gateway at %s reports MAC %s; using it instead of the entered %s",
gateway.address,
reported_mac,
gateway.serial,
)
gateway.serial = reported_mac
configured = str(gateway.model_name or "").strip()
resolution = resolve_gateway_identity(
GatewayIdentityEvidence(
manual=None if configured.lower() in UNTRUSTED_MODEL_LABELS else configured,
who13_code=code13,
who1013_code=code,
)
)
model = resolution.model
if model and model != configured:
reading = resolution.corrected_reading or _inband_reading(code13, code)
LOGGER.info(
"Gateway at %s reports %s; using it instead of the selected model %s",
gateway.address,
reading.describe(),
configured,
)
gateway.model_name = gateway.model = model
gateway.profile = get_gateway_profile(model, gateway.model_number)
gateway._log_id = f"[{model} gateway - {gateway.host}]" # pylint: disable=protected-access
return resolution.source

async def async_step_port(self, user_input=None, errors=None): # type: ignore
"""Port information for the gateway is missing."""
if errors is None:
Expand Down
5 changes: 4 additions & 1 deletion custom_components/myhome/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
gateway_model_family,
)

# Labels that name no model: an entry carrying one is labelled from the in-band evidence.
UNTRUSTED_MODEL_LABELS: frozenset[str] = frozenset({"", "generic", "gateway", "unknown"})


def _normalized(model: str | None) -> str:
"""Compare model names without case, spaces, dashes or underscores."""
Expand Down Expand Up @@ -327,7 +330,7 @@ def identification_source(self) -> str:
if model_source == IDENTIFICATION_MANUAL:
return IDENTIFICATION_MANUAL
model = data.get(CONF_NAME)
if model and str(model).strip().lower() not in ("", "generic", "gateway", "unknown"):
if model and str(model).strip().lower() not in UNTRUSTED_MODEL_LABELS:
return IDENTIFICATION_MANUAL
return IDENTIFICATION_UNKNOWN

Expand Down
Loading
Loading