From 5e7e299876c2ae360ac5f342376dd7b3c23e7cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 25 Dec 2025 23:37:35 +0200 Subject: [PATCH] Add Tasmota firmware update availability support --- homeassistant/components/tasmota/const.py | 1 + .../components/tasmota/coordinator.py | 38 +++++++++ .../components/tasmota/manifest.json | 2 +- homeassistant/components/tasmota/update.py | 79 +++++++++++++++++++ requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/tasmota/test_update.py | 65 +++++++++++++++ 7 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tasmota/coordinator.py create mode 100644 homeassistant/components/tasmota/update.py create mode 100644 tests/components/tasmota/test_update.py diff --git a/homeassistant/components/tasmota/const.py b/homeassistant/components/tasmota/const.py index fe1f325e94c..f92b5ebe807 100644 --- a/homeassistant/components/tasmota/const.py +++ b/homeassistant/components/tasmota/const.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] TASMOTA_EVENT = "tasmota_event" diff --git a/homeassistant/components/tasmota/coordinator.py b/homeassistant/components/tasmota/coordinator.py new file mode 100644 index 00000000000..61d90a01ec7 --- /dev/null +++ b/homeassistant/components/tasmota/coordinator.py @@ -0,0 +1,38 @@ +"""Data update coordinators for Tasmota.""" + +from datetime import timedelta +import logging + +from aiogithubapi import GitHubAPI, GitHubRatelimitException, GitHubReleaseModel +from aiogithubapi.client import GitHubConnectionException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + + +class TasmotaLatestReleaseUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]): + """Data update coordinator for Tasmota latest release info.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + self.client = GitHubAPI(session=async_get_clientsession(hass)) + super().__init__( + hass, + logger=logging.getLogger(__name__), + config_entry=config_entry, + name="Tasmota latest release", + update_interval=timedelta(days=1), + ) + + async def _async_update_data(self) -> GitHubReleaseModel: + """Get new data.""" + try: + response = await self.client.repos.releases.latest("arendst/Tasmota") + if response.data is None: + raise UpdateFailed("No data received") + except (GitHubConnectionException, GitHubRatelimitException) as ex: + raise UpdateFailed(ex) from ex + else: + return response.data diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 6c2d7ee271b..cb068c07c44 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.10.1"] + "requirements": ["HATasmota==0.10.1", "aiogithubapi==26.0.0"] } diff --git a/homeassistant/components/tasmota/update.py b/homeassistant/components/tasmota/update.py new file mode 100644 index 00000000000..6e7284c1f0e --- /dev/null +++ b/homeassistant/components/tasmota/update.py @@ -0,0 +1,79 @@ +"""Update entity for Tasmota.""" + +import re + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import TasmotaLatestReleaseUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Tasmota update entities.""" + coordinator = TasmotaLatestReleaseUpdateCoordinator(hass, config_entry) + await coordinator.async_config_entry_first_refresh() + + device_registry = dr.async_get(hass) + devices = device_registry.devices.get_devices_for_config_entry_id( + config_entry.entry_id + ) + async_add_entities(TasmotaUpdateEntity(coordinator, device) for device in devices) + + +class TasmotaUpdateEntity(UpdateEntity): + """Representation of a Tasmota update entity.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_name = "Firmware" + _attr_title = "Tasmota firmware" + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + + def __init__( + self, + coordinator: TasmotaLatestReleaseUpdateCoordinator, + device_entry: DeviceEntry, + ) -> None: + """Initialize the Tasmota update entity.""" + self.coordinator = coordinator + self.device_entry = device_entry + self._attr_unique_id = f"{device_entry.id}_update" + + @property + def installed_version(self) -> str | None: + """Return the installed version.""" + return self.device_entry.sw_version # type:ignore[union-attr] + + @property + def latest_version(self) -> str: + """Return the latest version.""" + return self.coordinator.data.tag_name.removeprefix("v") + + @property + def release_url(self) -> str: + """Return the release URL.""" + return self.coordinator.data.html_url + + @property + def release_summary(self) -> str: + """Return the release summary.""" + return self.coordinator.data.name + + def release_notes(self) -> str | None: + """Return the release notes.""" + if not self.coordinator.data.body: + return None + return re.sub( + r"^.*?", "", self.coordinator.data.body, flags=re.DOTALL + ) diff --git a/requirements_all.txt b/requirements_all.txt index ee89a230198..16d2b50505d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -267,6 +267,7 @@ aioftp==0.21.3 aioghost==0.4.0 # homeassistant.components.github +# homeassistant.components.tasmota aiogithubapi==26.0.0 # homeassistant.components.guardian diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96bf949f780..36afdaac6b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -255,6 +255,7 @@ aioflo==2021.11.0 aioghost==0.4.0 # homeassistant.components.github +# homeassistant.components.tasmota aiogithubapi==26.0.0 # homeassistant.components.guardian diff --git a/tests/components/tasmota/test_update.py b/tests/components/tasmota/test_update.py new file mode 100644 index 00000000000..af85a1a0dc1 --- /dev/null +++ b/tests/components/tasmota/test_update.py @@ -0,0 +1,65 @@ +"""Tests for the Tasmota update platform.""" + +import copy +import json + +from aiogithubapi import GitHubReleaseModel +import pytest + +from homeassistant.components.tasmota.const import DEFAULT_PREFIX +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .test_common import DEFAULT_CONFIG + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +@pytest.mark.parametrize( + ("candidate_version", "update_available"), + [ + ("0.0.0", False), + (".".join(str(int(x) + 1) for x in DEFAULT_CONFIG["sw"].split(".")), True), + ], +) +async def test_update_state( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + device_registry: dr.DeviceRegistry, + setup_tasmota, + candidate_version: str, + update_available: bool, +) -> None: + """Test setting up a device.""" + config = copy.deepcopy(DEFAULT_CONFIG) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + + # TODO mock coordinator.client.repos.releases.latest("arendst/Tasmota") to return this + data = GitHubReleaseModel( + tag_name=f"v{candidate_version}", + name=f"Tasmota v{candidate_version} Foo", + html_url=f"https://github.com/arendst/Tasmota/releases/tag/v{candidate_version}", + body="""\ + + + Logo + + +# RELEASE NOTES + +... """, + ) + + # TODO update_available test, device_entry.sw_version has the current version