Compare commits

..

3 Commits

Author SHA1 Message Date
Mike Degatano
9d8a08bfc1 Fixes from feedback 2026-03-18 20:13:56 +00:00
Mike Degatano
7024649ab3 Fix stringified None and coverage gaps 2026-03-18 20:13:53 +00:00
Mike Degatano
8cb336b9e4 Replace calls to set options in Supervisor with aiohasupervisor 2026-03-18 20:13:53 +00:00
19 changed files with 152 additions and 158 deletions

View File

@@ -24,8 +24,6 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
T = TypeVar(
@@ -99,13 +97,7 @@ class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
self.subentry.title,
err,
)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"error": str(err),
},
) from err
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
class GoogleWeatherCurrentConditionsCoordinator(

View File

@@ -66,7 +66,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:

View File

@@ -98,10 +98,5 @@
"name": "Wind gust speed"
}
}
},
"exceptions": {
"update_error": {
"message": "Error fetching weather data: {error}"
}
}
}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from contextlib import suppress
from dataclasses import replace
from datetime import datetime
import logging
import os
@@ -15,6 +16,7 @@ from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
GreenOptions,
HomeAssistantInfo,
HomeAssistantOptions,
HostInfo,
InstalledAddon,
NetworkInfo,
@@ -22,20 +24,28 @@ from aiohasupervisor.models import (
RootInfo,
StoreInfo,
SupervisorInfo,
SupervisorOptions,
YellowOptions,
)
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import RefreshToken
from homeassistant.components import frontend, panel_custom
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import StaticPathConfig
from homeassistant.components.http import (
CONF_SERVER_HOST,
CONF_SERVER_PORT,
CONF_SSL_CERTIFICATE,
StaticPathConfig,
)
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_NAME,
EVENT_CORE_CONFIG_UPDATE,
HASSIO_USER_NAME,
SERVER_PORT,
Platform,
)
from homeassistant.core import (
@@ -447,8 +457,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
require_admin=True,
)
async def update_hass_api(http_config: dict[str, Any], refresh_token: RefreshToken):
"""Update Home Assistant API data on Hass.io."""
options = HomeAssistantOptions(
ssl=CONF_SSL_CERTIFICATE in http_config,
port=http_config.get(CONF_SERVER_PORT) or SERVER_PORT,
refresh_token=refresh_token.token,
)
if http_config.get(CONF_SERVER_HOST) is not None:
options = replace(options, watchdog=False)
_LOGGER.warning(
"Found incompatible HTTP option 'server_host'. Watchdog feature"
" disabled"
)
try:
await supervisor_client.homeassistant.set_options(options)
except SupervisorError as err:
_LOGGER.warning(
"Failed to update Home Assistant options in Supervisor: %s", err
)
update_hass_api_task = hass.async_create_task(
hassio.update_hass_api(config.get("http", {}), refresh_token), eager_start=True
update_hass_api(config.get("http", {}), refresh_token), eager_start=True
)
last_timezone = None
@@ -459,19 +491,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
nonlocal last_timezone
nonlocal last_country
new_timezone = str(hass.config.time_zone)
new_country = str(hass.config.country)
new_timezone = hass.config.time_zone
new_country = hass.config.country
if new_timezone != last_timezone or new_country != last_country:
last_timezone = new_timezone
last_country = new_country
await hassio.update_hass_config(new_timezone, new_country)
try:
await supervisor_client.supervisor.set_options(
SupervisorOptions(timezone=new_timezone, country=new_country)
)
except SupervisorError as err:
_LOGGER.warning("Failed to update Supervisor options: %s", err)
hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config)
push_config_task = hass.async_create_task(push_config(None), eager_start=True)
# Start listening for problems with supervisor and making issues
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio)
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
async def async_service_handler(service: ServiceCall) -> None:
@@ -619,7 +657,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
async_set_stop_handler(hass, _async_stop)
# Init discovery Hass.io feature
async_setup_discovery_view(hass, hassio)
async_setup_discovery_view(hass)
# Init auth Hass.io feature
assert user is not None

View File

@@ -21,15 +21,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from .const import ATTR_ADDON, ATTR_UUID, DOMAIN
from .handler import HassIO, get_supervisor_client
from .handler import get_supervisor_client
_LOGGER = logging.getLogger(__name__)
@callback
def async_setup_discovery_view(hass: HomeAssistant, hassio: HassIO) -> None:
def async_setup_discovery_view(hass: HomeAssistant) -> None:
"""Discovery setup."""
hassio_discovery = HassIODiscovery(hass, hassio)
hassio_discovery = HassIODiscovery(hass)
supervisor_client = get_supervisor_client(hass)
hass.http.register_view(hassio_discovery)
@@ -77,10 +77,9 @@ class HassIODiscovery(HomeAssistantView):
name = "api:hassio_push:discovery"
url = "/api/hassio_push/discovery/{uuid}"
def __init__(self, hass: HomeAssistant, hassio: HassIO) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize WebView."""
self.hass = hass
self.hassio = hassio
self._supervisor_client = get_supervisor_client(hass)
async def post(self, request: web.Request, uuid: str) -> web.Response:

View File

@@ -14,13 +14,6 @@ from aiohasupervisor.models import SupervisorOptions
import aiohttp
from yarl import URL
from homeassistant.auth.models import RefreshToken
from homeassistant.components.http import (
CONF_SERVER_HOST,
CONF_SERVER_PORT,
CONF_SSL_CERTIFICATE,
)
from homeassistant.const import SERVER_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.singleton import singleton
@@ -35,22 +28,6 @@ class HassioAPIError(RuntimeError):
"""Return if a API trow a error."""
def _api_bool[**_P](
funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]],
) -> Callable[_P, Coroutine[Any, Any, bool]]:
"""Return a boolean."""
async def _wrapper(*argv: _P.args, **kwargs: _P.kwargs) -> bool:
"""Wrap function."""
try:
data = await funct(*argv, **kwargs)
return data["result"] == "ok"
except HassioAPIError:
return False
return _wrapper
def api_data[**_P](
funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]],
) -> Callable[_P, Coroutine[Any, Any, Any]]:
@@ -95,37 +72,6 @@ class HassIO:
"""
return self.send_command("/ingress/panels", method="get")
@_api_bool
async def update_hass_api(
self, http_config: dict[str, Any], refresh_token: RefreshToken
):
"""Update Home Assistant API data on Hass.io."""
port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT
options = {
"ssl": CONF_SSL_CERTIFICATE in http_config,
"port": port,
"refresh_token": refresh_token.token,
}
if http_config.get(CONF_SERVER_HOST) is not None:
options["watchdog"] = False
_LOGGER.warning(
"Found incompatible HTTP option 'server_host'. Watchdog feature"
" disabled"
)
return await self.send_command("/homeassistant/options", payload=options)
@_api_bool
def update_hass_config(self, timezone: str, country: str | None) -> Coroutine:
"""Update Home-Assistant timezone data on Hass.io.
This method returns a coroutine.
"""
return self.send_command(
"/supervisor/options", payload={"timezone": timezone, "country": country}
)
async def send_command(
self,
command: str,

View File

@@ -63,7 +63,7 @@ from .const import (
UPDATE_KEY_SUPERVISOR,
)
from .coordinator import HassioDataUpdateCoordinator, get_addons_list, get_host_info
from .handler import HassIO, get_supervisor_client
from .handler import get_supervisor_client
ISSUE_KEY_UNHEALTHY = "unhealthy"
ISSUE_KEY_UNSUPPORTED = "unsupported"
@@ -175,10 +175,9 @@ class Issue:
class SupervisorIssues:
"""Create issues from supervisor events."""
def __init__(self, hass: HomeAssistant, client: HassIO) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize supervisor issues."""
self._hass = hass
self._client = client
self._unsupported_reasons: set[str] = set()
self._unhealthy_reasons: set[str] = set()
self._issues: dict[UUID, Issue] = {}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["ohme==1.7.1"]
"requirements": ["ohme==1.7.0"]
}

View File

@@ -68,7 +68,7 @@ SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
ulid-transform==2.0.2
urllib3>=2.0
uv==0.10.6
voluptuous-openapi==0.2.0

View File

@@ -72,7 +72,7 @@ dependencies = [
"standard-aifc==3.13.0",
"standard-telnetlib==3.13.0",
"typing-extensions>=4.15.0,<5.0",
"ulid-transform==2.2.0",
"ulid-transform==2.0.2",
"urllib3>=2.0",
"uv==0.10.6",
"voluptuous==0.15.2",

2
requirements.txt generated
View File

@@ -52,7 +52,7 @@ SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
ulid-transform==2.0.2
urllib3>=2.0
uv==0.10.6
voluptuous-openapi==0.2.0

2
requirements_all.txt generated
View File

@@ -1672,7 +1672,7 @@ odp-amsterdam==6.1.2
oemthermostat==1.1.1
# homeassistant.components.ohme
ohme==1.7.1
ohme==1.7.0
# homeassistant.components.ollama
ollama==0.5.1

View File

@@ -1458,7 +1458,7 @@ objgraph==3.5.0
odp-amsterdam==6.1.2
# homeassistant.components.ohme
ohme==1.7.1
ohme==1.7.0
# homeassistant.components.ollama
ollama==0.5.1

View File

@@ -42,19 +42,16 @@ async def test_config_not_ready(
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
failing_api_method: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test for setup failure if an API call fails."""
getattr(
mock_google_weather_api, failing_api_method
).side_effect = GoogleWeatherApiError("API error")
).side_effect = GoogleWeatherApiError()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert "Error fetching weather data: API error" in caplog.text
async def test_unload_entry(
hass: HomeAssistant,

View File

@@ -10,7 +10,7 @@ from aiohasupervisor.models import AddonsStats, AddonState, InstalledAddonComple
from aiohttp.test_utils import TestClient
import pytest
from homeassistant.auth.models import RefreshToken
from homeassistant.components.hassio.const import DATA_CONFIG_STORE
from homeassistant.components.hassio.handler import HassIO
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -33,7 +33,7 @@ def disable_security_filter() -> Generator[None]:
@pytest.fixture
async def hassio_client(
hassio_stubs: RefreshToken, hass: HomeAssistant, hass_client: ClientSessionGenerator
hassio_stubs, hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> TestClient:
"""Return a Hass.io HTTP client."""
return await hass_client()
@@ -41,9 +41,7 @@ async def hassio_client(
@pytest.fixture
async def hassio_noauth_client(
hassio_stubs: RefreshToken,
hass: HomeAssistant,
aiohttp_client: ClientSessionGenerator,
hassio_stubs, hass: HomeAssistant, aiohttp_client: ClientSessionGenerator
) -> TestClient:
"""Return a Hass.io HTTP client without auth."""
return await aiohttp_client(hass.http.app)
@@ -51,12 +49,15 @@ async def hassio_noauth_client(
@pytest.fixture
async def hassio_client_supervisor(
hass: HomeAssistant,
aiohttp_client: ClientSessionGenerator,
hassio_stubs: RefreshToken,
hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, hassio_stubs
) -> TestClient:
"""Return an authenticated HTTP client."""
access_token = hass.auth.async_create_access_token(hassio_stubs)
hassio_user_id = hass.data[DATA_CONFIG_STORE].data.hassio_user
hassio_user = await hass.auth.async_get_user(hassio_user_id)
assert hassio_user
assert hassio_user.refresh_tokens
refresh_token = next(iter(hassio_user.refresh_tokens.values()))
access_token = hass.auth.async_create_access_token(refresh_token)
return await aiohttp_client(
hass.http.app,
headers={"Authorization": f"Bearer {access_token}"},

View File

@@ -61,7 +61,7 @@ async def test_hassio_addon_panel_startup(
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
assert aioclient_mock.call_count == 3
assert aioclient_mock.call_count == 1
assert mock_panel.called
mock_panel.assert_called_with(
hass,
@@ -108,7 +108,7 @@ async def test_hassio_addon_panel_api(
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
assert aioclient_mock.call_count == 3
assert aioclient_mock.call_count == 1
assert mock_panel.called
mock_panel.assert_called_with(
hass,

View File

@@ -127,33 +127,27 @@ async def test_hassio_discovery_startup_done(
addon_installed.return_value.name = "Mosquitto Test"
supervisor_root_info.side_effect = SupervisorError()
with (
patch(
"homeassistant.components.hassio.HassIO.update_hass_api",
return_value={"result": "ok"},
),
):
await hass.async_start()
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
await hass.async_start()
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
assert get_addon_discovery_info.call_count == 1
assert mock_mqtt.async_step_hassio.called
mock_mqtt.async_step_hassio.assert_called_with(
HassioServiceInfo(
config={
"broker": "mock-broker",
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"protocol": "3.1.1",
"addon": "Mosquitto Test",
},
name="Mosquitto Test",
slug="mosquitto",
uuid=uuid.hex,
)
assert get_addon_discovery_info.call_count == 1
assert mock_mqtt.async_step_hassio.called
mock_mqtt.async_step_hassio.assert_called_with(
HassioServiceInfo(
config={
"broker": "mock-broker",
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
"protocol": "3.1.1",
"addon": "Mosquitto Test",
},
name="Mosquitto Test",
slug="mosquitto",
uuid=uuid.hex,
)
)
async def test_hassio_discovery_webhook(

View File

@@ -5,7 +5,7 @@ from datetime import timedelta
import os
from pathlib import PurePath
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import ANY, AsyncMock, Mock, patch
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
@@ -13,12 +13,14 @@ from aiohasupervisor.models import (
AddonStage,
AddonState,
CIFSMountResponse,
HomeAssistantOptions,
InstalledAddon,
InstalledAddonComplete,
MountsInfo,
MountState,
MountType,
MountUsage,
SupervisorOptions,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -228,9 +230,26 @@ async def test_setup_api_push_api_data(
assert result
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 23
assert not aioclient_mock.mock_calls[0][2]["ssl"]
assert aioclient_mock.mock_calls[0][2]["port"] == 9999
assert "watchdog" not in aioclient_mock.mock_calls[0][2]
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY)
)
async def test_setup_api_push_api_data_error(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
supervisor_client: AsyncMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test setup with error while pushing core config data to API."""
supervisor_client.homeassistant.set_options.side_effect = SupervisorError("boom")
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}})
await hass.async_block_till_done()
assert result
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 23
assert "Failed to update Home Assistant options in Supervisor: boom" in caplog.text
async def test_setup_api_push_api_data_server_host(
@@ -249,9 +268,9 @@ async def test_setup_api_push_api_data_server_host(
assert result
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 23
assert not aioclient_mock.mock_calls[0][2]["ssl"]
assert aioclient_mock.mock_calls[0][2]["port"] == 9999
assert not aioclient_mock.mock_calls[0][2]["watchdog"]
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=9999, refresh_token=ANY, watchdog=False)
)
async def test_setup_api_push_api_data_default(
@@ -270,9 +289,12 @@ async def test_setup_api_push_api_data_default(
assert result
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 23
assert not aioclient_mock.mock_calls[0][2]["ssl"]
assert aioclient_mock.mock_calls[0][2]["port"] == 8123
refresh_token = aioclient_mock.mock_calls[0][2]["refresh_token"]
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=8123, refresh_token=ANY)
)
refresh_token = (
supervisor_client.homeassistant.set_options.mock_calls[0].args[0].refresh_token
)
hassio_user = await hass.auth.async_get_user(
hass_storage[STORAGE_KEY]["data"]["hassio_user"]
)
@@ -351,9 +373,9 @@ async def test_setup_api_existing_hassio_user(
assert result
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 23
assert not aioclient_mock.mock_calls[0][2]["ssl"]
assert aioclient_mock.mock_calls[0][2]["port"] == 8123
assert aioclient_mock.mock_calls[0][2]["refresh_token"] == token.token
supervisor_client.homeassistant.set_options.assert_called_once_with(
HomeAssistantOptions(ssl=False, port=8123, refresh_token=token.token)
)
async def test_setup_core_push_config(
@@ -370,13 +392,35 @@ async def test_setup_core_push_config(
assert result
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 23
assert aioclient_mock.mock_calls[1][2]["timezone"] == "testzone"
supervisor_client.supervisor.set_options.assert_called_once_with(
SupervisorOptions(timezone="testzone")
)
with patch("homeassistant.util.dt.set_default_time_zone"):
await hass.config.async_update(time_zone="America/New_York", country="US")
await hass.async_block_till_done()
assert aioclient_mock.mock_calls[-1][2]["timezone"] == "America/New_York"
assert aioclient_mock.mock_calls[-1][2]["country"] == "US"
supervisor_client.supervisor.set_options.assert_called_with(
SupervisorOptions(timezone="America/New_York", country="US")
)
async def test_setup_core_push_config_error(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
supervisor_client: AsyncMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test setup with error while pushing supervisor config data to API."""
hass.config.time_zone = "testzone"
supervisor_client.supervisor.set_options.side_effect = SupervisorError("boom")
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, "hassio", {"hassio": {}})
await hass.async_block_till_done()
assert result
assert aioclient_mock.call_count + len(supervisor_client.mock_calls) == 23
assert "Failed to update Supervisor options: boom" in caplog.text
async def test_setup_hassio_no_additional_data(

View File

@@ -123,7 +123,6 @@ from .typing import (
if TYPE_CHECKING:
# Local import to avoid processing recorder and SQLite modules when running a
# testcase which does not use the recorder.
from homeassistant.auth.models import RefreshToken
from homeassistant.components import recorder
@@ -2009,17 +2008,9 @@ async def hassio_stubs(
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
supervisor_client: AsyncMock,
) -> RefreshToken:
) -> None:
"""Create mock hassio http client."""
with (
patch(
"homeassistant.components.hassio.HassIO.update_hass_api",
return_value={"result": "ok"},
) as hass_api,
patch(
"homeassistant.components.hassio.HassIO.update_hass_config",
return_value={"result": "ok"},
),
patch(
"homeassistant.components.hassio.HassIO.get_ingress_panels",
return_value={"panels": []},
@@ -2030,8 +2021,6 @@ async def hassio_stubs(
):
await async_setup_component(hass, "hassio", {})
return hass_api.call_args[0][1]
@pytest.fixture
def integration_frame_path() -> str: