Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
59d5ee7
upload base structure for the easywave integration
eldateas Mar 18, 2026
94deb39
Fix manifest.json according to hassfest + Add rx_module implementatio…
eldateas Mar 18, 2026
1edcf30
Address Copilot review: device_path passthrough, single_instance_allo…
eldateas Mar 18, 2026
5674b20
Merge branch 'dev' into easywave-integration
eldateas Mar 18, 2026
f1028e4
Address second Copilot review feedback
eldateas Mar 18, 2026
8313015
Address third Copilot review + proactive improvements
eldateas Mar 18, 2026
a7a9ab8
Address fourth Copilot review (7 comments)
eldateas Mar 18, 2026
4aca3fb
Address fifth Copilot review (4 comments)
eldateas Mar 18, 2026
ba84d66
Merge branch 'dev' into easywave-integration
eldateas Mar 18, 2026
20e219d
Address sixth Copilot review (4 comments)
eldateas Mar 18, 2026
8996c7c
Merge branch 'dev' into easywave-integration
eldateas Mar 18, 2026
e620204
Address seventh Copilot review (3 comments)
eldateas Mar 18, 2026
636e4ac
Address eighth Copilot review (3 medium-risk comments)
eldateas Mar 19, 2026
245a378
Merge branch 'dev' into easywave-integration
eldateas Mar 19, 2026
ef2edd1
Address 9th Copilot review
eldateas Mar 19, 2026
f46e2c3
Address Copilot suggestion regarding SOP handling
eldateas Mar 19, 2026
d3a8443
Address 10th Copilot review (2 high-risk comments)
eldateas Mar 19, 2026
d529264
Address Copilot suggestion for an initial refresh
eldateas Mar 19, 2026
ffd5bd8
Address 11th Copilot review (2 comments)
eldateas Mar 19, 2026
61f04e3
Merge branch 'dev' into easywave-integration
eldateas Mar 19, 2026
2403c89
Address 12th Copilot review (set gateway_sensor.hass in tests)
eldateas Mar 19, 2026
85b55b8
Achieve 100% config_flow coverage for Codecov
eldateas Mar 19, 2026
ca6b124
Move _is_serial_port_valid() inside health_check interval
eldateas Mar 19, 2026
efe5c7e
Merge branch 'dev' into easywave-integration
eldateas Mar 19, 2026
81c55c8
Merge branch 'dev' into easywave-integration
eldateas Mar 19, 2026
d6e9a0b
Replace custom rx_module.py with easywave-home-control library
eldateas Mar 24, 2026
376930d
Address Copilot review: thread-safety, race-condition, error handling
eldateas Mar 24, 2026
aa4c34e
Merge branch 'dev' into easywave-integration
eldateas Mar 24, 2026
ef6dd3b
Fix OSError not caught in _refresh_usb_identity
eldateas Mar 24, 2026
c897957
Merge branch 'dev' into easywave-integration
eldateas Mar 25, 2026
3b909ec
Address review feedback for easywave integration
eldateas Apr 10, 2026
59eb883
Merge branch 'dev' into easywave-integration
eldateas Apr 10, 2026
8673035
Merge branch 'dev' into easywave-integration
eldateas Apr 10, 2026
33e908f
Merge branch 'dev' into easywave-integration
eldateas Apr 10, 2026
7caaef4
Update generated requirements files after merge
eldateas Apr 10, 2026
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
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

129 changes: 129 additions & 0 deletions homeassistant/components/easywave/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""The Easywave integration."""

from __future__ import annotations

from dataclasses import dataclass
import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import device_registry as dr, issue_registry as ir

from .const import (
CONF_DEVICE_PATH,
CONF_USB_PID,
DOMAIN,
get_frequency_for_pid,
is_country_allowed_for_frequency,
)
from .coordinator import EasywaveCoordinator
from .transceiver import RX11Transceiver

_LOGGER = logging.getLogger(__name__)


@dataclass
class EasywaveRuntimeData:
"""Runtime data for the Easywave integration."""

coordinator: EasywaveCoordinator
frequency: str
country: str


type EasywaveConfigEntry = ConfigEntry[EasywaveRuntimeData]

PLATFORMS: list[Platform] = [Platform.SENSOR]


async def async_setup_entry(hass: HomeAssistant, entry: EasywaveConfigEntry) -> bool:
"""Set up Easywave from a config entry."""

# ── Regulatory compliance check (868 MHz) ──────────────────────────
# The operating frequency is derived from the USB device's PID.
# If the configured HA country is outside the allowed region for that
# frequency, the integration must not start.
usb_pid = entry.data.get(CONF_USB_PID)
frequency = get_frequency_for_pid(usb_pid)
country_code = hass.config.country # ISO 3166-1 alpha-2 or None

if frequency and not is_country_allowed_for_frequency(frequency, country_code):
_LOGGER.warning(
"This hardware operates on %s, which is not permitted in "
"your configured region (%s). Integration disabled for "
"regulatory compliance",
frequency,
country_code or "unknown",
)
# Create a persistent repair issue visible in the HA dashboard
ir.async_create_issue(
hass,
DOMAIN,
f"frequency_not_permitted_{entry.entry_id}",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="frequency_not_permitted",
translation_placeholders={
"frequency": frequency,
"country": country_code or "unknown",
},
)
# Return False for regulatory compliance violation (not a setup error)
return False

# If the check passed, make sure any stale repair issue is removed
# (e.g. user changed their country setting).
ir.async_delete_issue(hass, DOMAIN, f"frequency_not_permitted_{entry.entry_id}")

# ── Initialize transceiver and coordinator ──────────────────────────
# Create transceiver instance, prefer user-selected serial device_path
transceiver = RX11Transceiver(hass, entry.data.get(CONF_DEVICE_PATH))

# Create coordinator for managing connection lifecycle & offline mode
coordinator = EasywaveCoordinator(hass, transceiver, entry)

# Attempt initial setup (may succeed in offline mode)
if not await coordinator.async_setup():
raise ConfigEntryNotReady("Failed to initialize coordinator")

# Perform initial refresh so coordinator.data is populated and
# reconnect polling starts before platforms are set up.
await coordinator.async_config_entry_first_refresh()

# Set runtime data for the integration
entry.runtime_data = EasywaveRuntimeData(
coordinator=coordinator,
frequency=frequency or "unknown",
country=country_code or "unknown",
)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: EasywaveConfigEntry) -> bool:
"""Unload a config entry."""
# Unload platforms first; only shut down the coordinator if this succeeds
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not unload_ok:
return False

if hasattr(entry, "runtime_data") and entry.runtime_data:
await entry.runtime_data.coordinator.async_shutdown()

return True


async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Allow removing devices except the RX11 gateway."""
gateway_identifier = (DOMAIN, f"{config_entry.entry_id}_gateway")
if gateway_identifier in device_entry.identifiers:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_delete_gateway",
)
return True
235 changes: 235 additions & 0 deletions homeassistant/components/easywave/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
"""Config flow for the Easywave integration."""

from __future__ import annotations

import logging
from typing import Any

import serial.tools.list_ports
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.service_info.usb import UsbServiceInfo

from .const import (
CONF_DEVICE_PATH,
CONF_USB_MANUFACTURER,
CONF_USB_PID,
CONF_USB_PRODUCT,
CONF_USB_SERIAL_NUMBER,
CONF_USB_VID,
DOMAIN,
SUPPORTED_USB_IDS,
USB_DEVICE_NAMES,
)

_LOGGER = logging.getLogger(__name__)


def _find_easywave_devices() -> list[dict[str, Any]]:
"""Scan serial ports and return info dicts for all supported Easywave sticks.

Runs in an executor (blocking I/O).
"""
devices: list[dict[str, Any]] = []
try:
for port in serial.tools.list_ports.comports():
if (port.vid, port.pid) in SUPPORTED_USB_IDS:
device_entry = USB_DEVICE_NAMES.get((port.vid, port.pid))
mfr = device_entry["manufacturer"] if device_entry else "ELDAT EaS GmbH"
prod = (
device_entry["product"]
if device_entry
else "Unknown Easywave Device"
)
devices.append(
{
"device": port.device,
"vid": port.vid,
"pid": port.pid,
"serial_number": port.serial_number or "unknown",
"manufacturer": port.manufacturer or mfr,
"product": prod,
}
)
except serial.SerialException, OSError:
_LOGGER.exception("Error scanning for Easywave USB devices")
return devices


class EasywaveConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the config flow for Easywave."""

VERSION = 1

def __init__(self) -> None:
"""Initialize."""
self._device: dict[str, Any] = {}

# ------------------------------------------------------------------
# Entry point: start auto-detection immediately
# ------------------------------------------------------------------

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Start auto-detection."""
return await self.async_step_detect()

# ------------------------------------------------------------------
# Auto-detection step
# ------------------------------------------------------------------

async def async_step_detect(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Scan for connected Easywave sticks and proceed to confirmation."""
devices = await self.hass.async_add_executor_job(_find_easywave_devices)

if not devices:
return self.async_abort(reason="no_devices_found")

# Auto-select when exactly one device is present.
if len(devices) == 1:
self._device = devices[0]
return await self.async_step_confirm()

# Multiple devices: let the user pick one.
options = [
SelectOptionDict(
value=d["device"],
label=f"{d['product']} — {d['device']} ({d['serial_number']})",
)
for d in devices
]

if user_input is not None:
selected_path = user_input[CONF_DEVICE_PATH]
selected_device = next(
(d for d in devices if d["device"] == selected_path),
None,
)
if selected_device is None:
return self.async_show_form(
step_id="detect",
data_schema=vol.Schema(
{
vol.Required(CONF_DEVICE_PATH): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.LIST,
)
)
}
),
description_placeholders={"count": str(len(devices))},
errors={"base": "device_no_longer_available"},
)
self._device = selected_device
return await self.async_step_confirm()

return self.async_show_form(
step_id="detect",
data_schema=vol.Schema(
{
vol.Required(CONF_DEVICE_PATH): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.LIST,
)
)
}
),
description_placeholders={"count": str(len(devices))},
)

# ------------------------------------------------------------------
# USB auto-discovery (triggered by manifest `usb` matcher)
# ------------------------------------------------------------------

async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle USB discovery."""
vid = int(discovery_info.vid, 16)
pid = int(discovery_info.pid, 16)
serial_number = discovery_info.serial_number or "unknown"

unique_id = (
f"easywave_{serial_number}"
if serial_number != "unknown"
else f"easywave_{vid:04X}_{pid:04X}"
)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()

device_entry = USB_DEVICE_NAMES.get((vid, pid))
mfr = device_entry["manufacturer"] if device_entry else "ELDAT EaS GmbH"
prod = device_entry["product"] if device_entry else "Unknown Easywave Device"

self._device = {
"device": discovery_info.device,
"vid": vid,
"pid": pid,
"serial_number": serial_number,
"manufacturer": discovery_info.manufacturer or mfr,
"product": prod,
}
self.context["title_placeholders"] = {"name": prod}
return await self.async_step_confirm()

# ------------------------------------------------------------------
# Confirmation step
# ------------------------------------------------------------------

async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show confirmation dialog and create the entry on submit."""
serial_number = self._device["serial_number"]
vid = self._device.get("vid")
pid = self._device.get("pid")

if serial_number != "unknown":
unique_id = f"easywave_{serial_number}"
elif vid is not None and pid is not None:
unique_id = f"easywave_{vid:04X}_{pid:04X}"
else:
unique_id = f"easywave_{self._device['device'].replace('/', '_')}"

await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()

if user_input is not None:
return self._create_entry()

return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders={
"name": self._device["product"],
"serial_number": serial_number,
"device": self._device["device"],
},
)

# ------------------------------------------------------------------

def _create_entry(self) -> ConfigFlowResult:
"""Create the config entry."""
d = self._device
return self.async_create_entry(
title="Easywave Gateway",
data={
CONF_DEVICE_PATH: d["device"],
CONF_USB_VID: d["vid"],
CONF_USB_PID: d["pid"],
CONF_USB_SERIAL_NUMBER: d["serial_number"],
CONF_USB_MANUFACTURER: d["manufacturer"],
CONF_USB_PRODUCT: d["product"],
},
)
Loading
Loading