Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.

109 changes: 109 additions & 0 deletions homeassistant/components/easywave/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""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.helpers import 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 | None
country: str | None


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)
Comment thread
eldateas marked this conversation as resolved.

# _async_setup + first data refresh; raises ConfigEntryNotReady on failure
await coordinator.async_config_entry_first_refresh()

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

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

await entry.runtime_data.coordinator.async_shutdown()

return True
172 changes: 172 additions & 0 deletions homeassistant/components/easywave/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""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.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,
USB_DEVICE_NAMES,
)

_LOGGER = logging.getLogger(__name__)


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

VERSION = 1

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

# ------------------------------------------------------------------
# Manual setup: list all serial ports
# ------------------------------------------------------------------

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show available serial ports and let the user pick one."""
errors: dict[str, str] = {}
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
port_list = {
p.device: (
f"{p.device}"
f"{f', s/n: {p.serial_number}' if p.serial_number else ''}"
f"{f' - {p.manufacturer}' if p.manufacturer else ''}"
)
for p in ports
}

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

if user_input is not None:
selected_path = user_input[CONF_DEVICE_PATH]
# Find the matching port to extract USB metadata
port = next(
(p for p in ports if p.device == selected_path),
None,
)
if port is None:
errors["base"] = "device_no_longer_available"
else:
self._device = {
"device": port.device,
"vid": port.vid,
"pid": port.pid,
"serial_number": port.serial_number or "unknown",
"manufacturer": port.manufacturer or "unknown",
"product": (
USB_DEVICE_NAMES[(port.vid, port.pid)]["product"]
if (port.vid, port.pid) in USB_DEVICE_NAMES
else port.product or "Easywave Device"
),
}
return await self.async_step_confirm()

return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_DEVICE_PATH): vol.In(port_list)}),
errors=errors,
)

# ------------------------------------------------------------------
# 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()
Comment thread
eldateas marked this conversation as resolved.

Comment thread
eldateas marked this conversation as resolved.
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()

Comment thread
eldateas marked this conversation as resolved.
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