-
-
Notifications
You must be signed in to change notification settings - Fork 37.4k
Added guntamatic heater integration #167419
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 37 commits
e372a09
3d7c7b7
67150e6
d77f1ad
d73e6b0
1c3e485
8499ddd
d6ff64c
f08bddb
1e06298
c3ebb1a
a422c0b
8b9235b
0c12805
604a845
3c4c97e
3892ada
f7b606c
6c4ddfd
29fd735
4a313f8
3c27e88
943e7c5
f8c0130
5b724e7
7c7f3bc
9950e6e
c82bf5e
ca98c9d
7dfde24
ec42fec
eecd590
8e5cbc9
75eea53
a05ff73
32006fe
f08775f
80ddaf8
cf4787e
a776434
1d47520
7b0360e
d68f468
b364784
5e47527
2f7b88f
aa1c8c8
39fe372
940b44e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| """The guntamatic integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
|
|
||
| from guntamatic.heater import Heater | ||
|
|
||
| from homeassistant.const import CONF_HOST, Platform | ||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from .coordinator import GuntamaticConfigEntry, GuntamaticCoordinator | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
| _PLATFORMS: list[Platform] = [Platform.SENSOR] | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: | ||
| """Set up guntamatic from a config entry.""" | ||
|
|
||
| heater = Heater(entry.data[CONF_HOST]) | ||
|
|
||
| coordinator = GuntamaticCoordinator(hass, heater, entry) | ||
| await coordinator.async_config_entry_first_refresh() | ||
| entry.runtime_data = coordinator | ||
|
|
||
| await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) | ||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,96 @@ | ||||||||
| """Config flow for the guntamatic integration.""" | ||||||||
|
|
||||||||
| from __future__ import annotations | ||||||||
|
|
||||||||
| import logging | ||||||||
| from typing import Any | ||||||||
|
|
||||||||
| from guntamatic.heater import Heater, NoSerialException | ||||||||
| import requests | ||||||||
| import voluptuous as vol | ||||||||
|
|
||||||||
| from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||||||||
| from homeassistant.const import CONF_HOST | ||||||||
| from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo | ||||||||
|
|
||||||||
| from .const import DOMAIN | ||||||||
|
|
||||||||
| _LOGGER = logging.getLogger(__name__) | ||||||||
|
|
||||||||
| STEP_USER_DATA_SCHEMA = vol.Schema( | ||||||||
| { | ||||||||
| vol.Required(CONF_HOST): str, | ||||||||
| } | ||||||||
| ) | ||||||||
|
|
||||||||
|
|
||||||||
| class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): | ||||||||
| """Handle a config flow for guntamatic.""" | ||||||||
|
|
||||||||
| VERSION = 1 | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Since it's new, it will default to 1. :)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||||||||
| _discovered_ip: str | ||||||||
|
|
||||||||
| async def async_step_dhcp( | ||||||||
| self, discovery_info: DhcpServiceInfo | ||||||||
| ) -> ConfigFlowResult: | ||||||||
| """Handle DHCP discovery.""" | ||||||||
| try: | ||||||||
| heater = Heater(discovery_info.ip) | ||||||||
| data = await self.hass.async_add_executor_job(heater.parse_data) | ||||||||
| except requests.exceptions.RequestException: | ||||||||
| return self.async_abort(reason="cannot_connect") | ||||||||
| except NoSerialException: | ||||||||
| return self.async_abort(reason="bad_data") | ||||||||
|
|
||||||||
| # set serial as unique id for deduplication, ip isn't a good match | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removed |
||||||||
| serial = data["serial"][0] | ||||||||
| await self.async_set_unique_id(serial) | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||||||||
| self._abort_if_unique_id_configured() | ||||||||
| self._discovered_ip = discovery_info.ip | ||||||||
|
|
||||||||
| return await self.async_step_discovery_confirm() | ||||||||
|
|
||||||||
| async def async_step_discovery_confirm( | ||||||||
| self, user_input: dict[str, Any] | None = None | ||||||||
| ) -> ConfigFlowResult: | ||||||||
| """Confirm discovery.""" | ||||||||
| if user_input is not None: | ||||||||
| return self.async_create_entry( | ||||||||
| title="Guntamatic Heater", | ||||||||
| data={CONF_HOST: self._discovered_ip}, | ||||||||
| ) | ||||||||
|
|
||||||||
| self._set_confirm_only() | ||||||||
| return self.async_show_form(step_id="discovery_confirm") | ||||||||
|
JensTimmerman marked this conversation as resolved.
|
||||||||
|
|
||||||||
| async def async_step_user( | ||||||||
| self, user_input: dict[str, Any] | None = None | ||||||||
| ) -> ConfigFlowResult: | ||||||||
| """Handle the initial step.""" | ||||||||
| errors: dict[str, str] = {} | ||||||||
|
|
||||||||
| if user_input is not None: | ||||||||
| try: | ||||||||
| heater = Heater(user_input[CONF_HOST]) | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this can be placed outside the try except, right before it. Only place parts that we're trying and might crash.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or I could inline it in the next line?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that could work, but I think keeping it out is a bit cleaner |
||||||||
| data = await self.hass.async_add_executor_job(heater.parse_data) | ||||||||
| except requests.exceptions.RequestException: | ||||||||
| errors["base"] = "cannot_connect" | ||||||||
| except NoSerialException: | ||||||||
| errors["base"] = "bad_data" | ||||||||
|
|
||||||||
| except Exception: | ||||||||
| _LOGGER.exception("Unexpected exception") | ||||||||
| errors["base"] = "unknown" | ||||||||
|
JensTimmerman marked this conversation as resolved.
|
||||||||
| else: | ||||||||
| # set serial as unique id for deduplication, ip isn't a good match | ||||||||
| serial = data["serial"][0] | ||||||||
| await self.async_set_unique_id(serial) | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||||||||
| self._abort_if_unique_id_configured() | ||||||||
|
|
||||||||
| return self.async_create_entry( | ||||||||
| title="Guntamatic Heater", data=user_input | ||||||||
| ) | ||||||||
|
|
||||||||
| return self.async_show_form( | ||||||||
| step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors | ||||||||
| ) | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| """Constants for the guntamatic integration.""" | ||
|
|
||
| from datetime import timedelta | ||
|
|
||
| DOMAIN = "guntamatic" | ||
| SCAN_INTERVAL = timedelta(seconds=30) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,55 @@ | ||||||
| """Coordinator for Guntamatic integration.""" | ||||||
|
|
||||||
| from __future__ import annotations | ||||||
|
|
||||||
| import logging | ||||||
|
|
||||||
| from guntamatic.heater import Heater, NoSerialException | ||||||
| import requests | ||||||
|
|
||||||
| from homeassistant.config_entries import ConfigEntry | ||||||
| from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady | ||||||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||||||
|
|
||||||
| from .const import DOMAIN, SCAN_INTERVAL | ||||||
|
|
||||||
| _LOGGER = logging.getLogger(__name__) | ||||||
|
|
||||||
| type GuntamaticConfigEntry = ConfigEntry[GuntamaticCoordinator] | ||||||
|
|
||||||
|
|
||||||
| class GuntamaticCoordinator(DataUpdateCoordinator[dict[str, list[str]]]): | ||||||
|
JensTimmerman marked this conversation as resolved.
|
||||||
| """Guntamatic data coordinator.""" | ||||||
|
|
||||||
| def __init__( | ||||||
| self, hass: HomeAssistant, heater: Heater, entry: GuntamaticConfigEntry | ||||||
| ) -> None: | ||||||
| """Initialize coordinator.""" | ||||||
| super().__init__( | ||||||
| hass, | ||||||
| logger=_LOGGER, | ||||||
| name=DOMAIN, | ||||||
| update_interval=SCAN_INTERVAL, | ||||||
| config_entry=entry, | ||||||
| ) | ||||||
| self.heater = heater | ||||||
|
|
||||||
| async def _async_setup(self) -> None: | ||||||
| """Do initialization logic.""" | ||||||
| try: | ||||||
| await self.hass.async_add_executor_job(self.heater.parse_data) | ||||||
| except NoSerialException as err: | ||||||
| raise ConfigEntryError(f"Unexpected data from heater: {err}") from err | ||||||
| except requests.exceptions.ConnectionError as err: | ||||||
| raise ConfigEntryNotReady(f"Cannot connect to heater: {err}") from err | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This already happens in async update data, no need to have it double
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't understand? I was told to add an _async_setup: #167419 (comment)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
|
|
||||||
| async def _async_update_data(self) -> dict[str, list[str]]: | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's a good practice to add an
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is there any documentation about this?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh, must have mist that, yeah that cleans things up a bit
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not sure what the reason was you redirected for this
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @joostlek is this remark for me or for @erwindouna ?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| """Fetch data from heater.""" | ||||||
| try: | ||||||
| data: dict[str, list[str]] = await self.hass.async_add_executor_job( | ||||||
| self.heater.parse_data | ||||||
| ) | ||||||
| except requests.exceptions.ConnectionError as err: | ||||||
|
JensTimmerman marked this conversation as resolved.
|
||||||
| except requests.exceptions.ConnectionError as err: | |
| except requests.exceptions.RequestException as err: |
Copilot
AI
Apr 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle all request/parse errors in the coordinator by raising UpdateFailed.
_async_update_data currently only wraps requests.exceptions.ConnectionError, but the same parse_data call is treated as potentially raising any requests.exceptions.RequestException (and NoSerialException) in the config flow. If those occur during polling, they will be logged as unexpected exceptions (with tracebacks) on every update. Catching requests.exceptions.RequestException (and any expected library exceptions) and re-raising as UpdateFailed will keep failures cleanly classified as communication/update problems.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| { | ||
| "domain": "guntamatic", | ||
| "name": "Guntamatic", | ||
| "codeowners": ["@JensTimmerman"], | ||
| "config_flow": true, | ||
| "dependencies": [], | ||
| "dhcp": [ | ||
| { | ||
| "hostname": "kessel*", | ||
| "macaddress": "0024BD*" | ||
| } | ||
| ], | ||
|
|
||
| "documentation": "https://www.home-assistant.io/integrations/guntamatic", | ||
| "integration_type": "device", | ||
| "iot_class": "local_polling", | ||
| "quality_scale": "silver", | ||
| "requirements": ["guntamatic==1.5.0"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| rules: | ||
| # Bronze | ||
| action-setup: done | ||
| appropriate-polling: done | ||
| brands: done | ||
| common-modules: done | ||
| config-flow-test-coverage: done | ||
| config-flow: done | ||
| dependency-transparency: done | ||
| docs-actions: | ||
| status: exempt | ||
| comment: | | ||
| No custom actions are defined. | ||
| docs-high-level-description: done | ||
| docs-installation-instructions: done | ||
| docs-removal-instructions: done | ||
| entity-event-setup: done | ||
| entity-unique-id: done | ||
| has-entity-name: done | ||
| runtime-data: done | ||
| test-before-configure: done | ||
| test-before-setup: done | ||
| unique-config-entry: done | ||
|
|
||
| # Silver | ||
| action-exceptions: | ||
| status: exempt | ||
| comment: | | ||
| No custom actions are defined. | ||
| config-entry-unloading: done | ||
| docs-configuration-parameters: | ||
| status: exempt | ||
| comment: No options flow | ||
| docs-installation-parameters: done | ||
| entity-unavailable: done | ||
| integration-owner: done | ||
| log-when-unavailable: done | ||
| parallel-updates: done | ||
| reauthentication-flow: | ||
| status: exempt | ||
| comment: No authentication required. | ||
| test-coverage: done | ||
|
|
||
| # Gold | ||
| devices: done | ||
| diagnostics: todo | ||
| discovery-update-info: todo | ||
| discovery: todo | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
| docs-data-update: todo | ||
| docs-examples: todo | ||
| docs-known-limitations: todo | ||
| docs-supported-devices: todo | ||
| docs-supported-functions: todo | ||
| docs-troubleshooting: todo | ||
| docs-use-cases: todo | ||
| dynamic-devices: done | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is exempt, you connect to a single one |
||
| entity-category: done | ||
| entity-device-class: todo | ||
| entity-disabled-by-default: todo | ||
| entity-translations: todo | ||
| exception-translations: todo | ||
| icon-translations: todo | ||
| reconfiguration-flow: todo | ||
| repair-issues: todo | ||
| stale-devices: todo | ||
|
|
||
| # Platinum | ||
| async-dependency: todo | ||
| inject-websession: todo | ||
| strict-typing: todo | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done