-
-
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 3 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,38 @@ | ||
| # Guntamatic Sensor | ||
|
|
||
| ## High-Level Description | ||
| The Guntamatic Sensor integration allows Home Assistant to monitor sensors from Guntamatic heaters. Guntamatic is a brand of modern wood/pellet gas boilers. This integration exposes temperature, operational state, and other relevant sensors. | ||
|
|
||
|
|
||
| ## Sensors | ||
| The integration currently exposes the following sensors (dynamic values): | ||
|
|
||
| - Boiler state: Running, STANDBY | ||
| - Boiler temperature | ||
| - Outside temperature | ||
| - Buffer load and buffer top/mid/bottom temperatures | ||
| - Boiler shunt pump, suction fan, primary and secondary air | ||
| - CO₂ content | ||
| - Domestic hot water (DHW) temperatures and pumps | ||
| - Heating circulation pumps and flow temperatures for multiple zones | ||
| - Program states (HEAT/HC) | ||
| - Interruptions | ||
| - Serial number, version, operation time, service hours | ||
| - Auxiliary pumps | ||
| - Additional WW/Buffer sensors | ||
|
|
||
| ## Installation Instructions | ||
| 1. Copy the `guntamatic_sensor` folder into `config/custom_components/`. | ||
| 2. Restart Home Assistant. | ||
| 3. Go to **Settings → Devices & Services → Add Integration**. | ||
| 4. Search for "Guntamatic Sensor" and enter the host address of your heater. | ||
|
|
||
| ## Removal Instructions | ||
| 1. Go to **Settings → Devices & Services**. | ||
| 2. Select the Guntamatic Sensor integration. | ||
| 3. Click **Delete** to remove the integration and its entities. | ||
| 4. Optional: Delete the `guntamatic_sensor` folder from `custom_components/`. | ||
|
JensTimmerman marked this conversation as resolved.
Outdated
|
||
|
|
||
| ## Services | ||
| This integration does **not** provide any Home Assistant service calls. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| """The guntamatic integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| import logging | ||
|
|
||
| from guntamatic.heater import Heater | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import CONF_HOST, Platform | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import ConfigEntryNotReady | ||
| from homeassistant.helpers import device_registry as dr | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
|
||
| from .const import DOMAIN, SCAN_INTERVAL | ||
|
|
||
| # For your initial PR, limit it to 1 platform. | ||
|
JensTimmerman marked this conversation as resolved.
Outdated
|
||
| _LOGGER = logging.getLogger(__name__) | ||
| _PLATFORMS: list[Platform] = [Platform.SENSOR] | ||
|
|
||
| type GuntamaticConfigEntry = ConfigEntry[Heater] | ||
|
JensTimmerman marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| @dataclass | ||
| class GuntamaticData: | ||
| """Data for the Guntamatic integration.""" | ||
|
|
||
| heater: Heater | ||
| coordinator: DataUpdateCoordinator | ||
|
JensTimmerman marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: GuntamaticConfigEntry) -> bool: | ||
| """Set up guntamatic from a config entry.""" | ||
|
|
||
| host = entry.data[CONF_HOST] | ||
| heater = Heater(host) | ||
|
|
||
| # initial connectivity check | ||
| initial_data = None | ||
| try: | ||
| initial_data = await hass.async_add_executor_job(heater.get_data) | ||
| except Exception as err: | ||
| raise ConfigEntryNotReady( | ||
| f"Cannot connect to Guntamatic heater: {err}" | ||
| ) from err | ||
|
|
||
| if not initial_data: | ||
| raise ConfigEntryNotReady("Cannot connect to Guntamatic heater") | ||
|
|
||
| async def async_update_data() -> dict[str, list[str]]: | ||
| """Fetch all sensor data from the heater. | ||
|
|
||
| Expected return format: | ||
| { | ||
| "Boiler Temperature": [68.5, "°C"], | ||
| "Flue Temperature": [115.2, "°C"], | ||
| "Power Output": [12.4, "kW"], | ||
| } | ||
| """ | ||
|
|
||
| data: dict[str, list[str]] = await hass.async_add_executor_job(heater.get_data) | ||
| if not data: | ||
| raise UpdateFailed("No data received from heater") | ||
| return data | ||
|
|
||
| coordinator = DataUpdateCoordinator( | ||
|
JensTimmerman marked this conversation as resolved.
Outdated
|
||
| hass, | ||
| logger=_LOGGER, | ||
| name="guntamatic_sensor", | ||
| update_method=async_update_data, | ||
| update_interval=SCAN_INTERVAL, | ||
| config_entry=entry, | ||
| ) | ||
|
|
||
| coordinator.data = initial_data | ||
| entry.runtime_data = GuntamaticData(heater=heater, coordinator=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) | ||
|
|
||
|
|
||
| async def async_remove_config_entry_device( | ||
|
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. Let's omit this from this PR
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 |
||
| hass: HomeAssistant, | ||
| config_entry: GuntamaticConfigEntry, | ||
| device_entry: dr.DeviceEntry, | ||
| ) -> bool: | ||
| """Remove a config entry from a device.""" | ||
| return not any( | ||
| identifier | ||
| for identifier in device_entry.identifiers | ||
| if identifier[0] == DOMAIN | ||
| and identifier[1] | ||
| == config_entry.runtime_data.coordinator.data.get("Serial", [None])[0] | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| """Config flow for the guntamatic integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| from guntamatic.heater import Heater | ||
| import requests | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||
| from homeassistant.const import CONF_HOST | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import HomeAssistantError | ||
|
|
||
| from .const import DOMAIN | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| STEP_USER_DATA_SCHEMA = vol.Schema( | ||
| { | ||
| vol.Required(CONF_HOST): str, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: | ||
|
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 can be inlined IMO
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. When I try ruff always complains and wants me to create an inner function:
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. fixed in a diferent way |
||
| """Validate the user input allows us to connect. | ||
|
|
||
| Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. | ||
| """ | ||
| heater = Heater(data[CONF_HOST]) | ||
|
|
||
| if not await hass.async_add_executor_job(heater.get_data): | ||
| raise CannotConnect | ||
|
|
||
| # Return info that you want to store in the config entry. | ||
| return {"host": data[CONF_HOST]} | ||
|
JensTimmerman marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| class GuntamaticConfigFlow(ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for guntamatic.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| 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: | ||
| self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) | ||
| try: | ||
| info = await validate_input(self.hass, user_input) | ||
| except CannotConnect, requests.exceptions.ConnectionError: | ||
|
JensTimmerman marked this conversation as resolved.
Outdated
|
||
| errors["base"] = "cannot_connect" | ||
| except Exception: | ||
| _LOGGER.exception("Unexpected exception") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| return self.async_create_entry( | ||
| title="Guntamatic Heater", | ||
| data=info, | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors | ||
| ) | ||
|
|
||
|
|
||
| class CannotConnect(HomeAssistantError): | ||
| """Error to indicate we cannot connect.""" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| """Constants for the guntamatic integration.""" | ||
|
|
||
| from datetime import timedelta | ||
|
|
||
| from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass | ||
|
|
||
| DOMAIN = "guntamatic_sensor" | ||
| SCAN_INTERVAL = timedelta(seconds=30) | ||
| DIAGNOSTIC_SENSORS = {"Serial", "Version", "Operat. time", "Service Hrs"} | ||
|
|
||
| SENSOR_DEVICE_CLASSES: dict[str, SensorDeviceClass | None] = { | ||
| "Boiler temperature": SensorDeviceClass.TEMPERATURE, | ||
| "Outside Temp.": SensorDeviceClass.TEMPERATURE, | ||
| "Buffer Top": SensorDeviceClass.TEMPERATURE, | ||
| "Buffer Mid": SensorDeviceClass.TEMPERATURE, | ||
| "Buffer Btm": SensorDeviceClass.TEMPERATURE, | ||
| "DHW 0": SensorDeviceClass.TEMPERATURE, | ||
| "DHW 1": SensorDeviceClass.TEMPERATURE, | ||
| "DHW 2": SensorDeviceClass.TEMPERATURE, | ||
| # co2 content is explicitly not a co2 device class, this is flue gas, which will be 900.000ppm it doesn't make sense | ||
| # to put it in the category of indoor air quality measurement devices | ||
| # "CO2 Content": SensorDeviceClass.CO2, | ||
| "Operat. time": SensorDeviceClass.DURATION, | ||
| } | ||
|
|
||
| SENSOR_STATE_CLASSES: dict[str, SensorStateClass | None] = { | ||
| "Boiler temperature": SensorStateClass.MEASUREMENT, | ||
| "Outside Temp.": SensorStateClass.MEASUREMENT, | ||
| "Buffer load.": SensorStateClass.MEASUREMENT, | ||
| "CO2 Content": SensorStateClass.MEASUREMENT, | ||
| } |
|
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. Keep diagnostics for a later PR |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| """Diagnostics support for Guntamatic.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any | ||
|
|
||
| from homeassistant.core import HomeAssistant | ||
|
|
||
| from . import GuntamaticConfigEntry | ||
|
|
||
|
|
||
| async def async_get_config_entry_diagnostics( | ||
| hass: HomeAssistant, entry: GuntamaticConfigEntry | ||
| ) -> dict[str, Any]: | ||
| """Return diagnostics for a config entry.""" | ||
| return { | ||
| "entry_data": dict(entry.data), | ||
| "data": entry.runtime_data.coordinator.data, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "domain": "guntamatic_sensor", | ||
| "name": "guntamatic", | ||
|
JensTimmerman marked this conversation as resolved.
Outdated
|
||
| "codeowners": ["@JensTimmerman"], | ||
| "config_flow": true, | ||
| "dependencies": [], | ||
| "documentation": "https://www.home-assistant.io/integrations/guntamatic_sensor", | ||
| "homekit": {}, | ||
|
JensTimmerman marked this conversation as resolved.
Outdated
|
||
| "integration_type": "device", | ||
| "iot_class": "local_polling", | ||
| "quality_scale": "silver", | ||
| "requirements": ["guntamatic==1.0.3"], | ||
| "ssdp": [], | ||
| "zeroconf": [] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| 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: done | ||
| discovery-update-info: | ||
| status: exempt | ||
| comment: Guntamatic heaters do not support network discovery protocols. | ||
| discovery: | ||
| status: exempt | ||
| comment: Guntamatic heaters do not support network discovery protocols. | ||
|
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. Why not? What's the Mac address? What's the hostname? Have you checked mDNS?
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'm intressted in having auto discovery, but the only feasible approach I found was to check every ip in a big network range and see if they had a daqdata.cgi url that returns expected data. I ran arp -n 192.168.1.41 I'm not sure how unique this is: Hainzl Industriesysteme GmbH , manages 1 unique MAC address prefix. These prefixes are part of a massive allocation with a total block size of 16.8 million addresses. However I now noticed it does advertise a hostname of kessel0001 (german for boiler/heater) I found http://ha:8123/config/dhcp I couldn't find a lot of more information, but could it work in some environments if I add this to manifest.json? edit:
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. Hmm, Kessel might be very generic. It doesn't sound specific for this brand. I think I'll raise the question in the core team if a combination of the hostname with the Mac would be good as that'd make it a bit more specific. What does hainzl do? Is it maybe the company behind this brand? Maybe that's specific enough
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 primary looked at https://developers.home-assistant.io/docs/network_discovery/ and could not find any info about this dhcp option in manifest.json, is this something I should add there?
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. https://developers.home-assistant.io/docs/creating_integration_manifest#dhcp Good one, didn't know it wasn't documented there, will look into that
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.
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. From what I can see hainzl makes the technology (motherboards) for the guntamatic boilers, so guntamatic is one of their clients: https://www.hainzl.at/en/embedded-systems-en/use-case-guntamatic/ Given they are active in the area's of Hydraulics, Elektro mechanics,,Elektronic systems & telematics,Machine & plant automation,Optimization of machine & plant energy,Handling & robotic systems,Process & spray technology,Building technology their mac adress ranges might show up in more products with api's.
There is an easy extra test: check if again, does HA have analytics on mac adress ranges and hostname patterns? That could tell us a lot if these are shared somewhere to be analysed (anonymised) |
||
| 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 | ||
| 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 | ||
Uh oh!
There was an error while loading. Please reload this page.