Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
073e72e
Add Hanna integration
bestycame Jun 18, 2025
524aa9a
Merge branch 'dev' into add_hanna_integration
bestycame Jun 18, 2025
06ef095
Update homeassistant/components/hanna/strings.json
bestycame Jun 18, 2025
ff89533
Update homeassistant/components/hanna/strings.json
bestycame Jun 18, 2025
45df6dc
Update homeassistant/components/hanna/strings.json
bestycame Jun 18, 2025
dd9530e
Merge branch 'dev' into add_hanna_integration
bestycame Jun 19, 2025
5bffc3a
Merge branch 'dev' into add_hanna_integration
bestycame Jun 19, 2025
623607a
Merge branch 'home-assistant:dev' into add_hanna_integration
bestycame Jun 19, 2025
95ecc53
refactor following PR review comment
odotreppe-abbove Jul 18, 2025
ef61866
Correct case of sensor names
odotreppe-abbove Jul 22, 2025
665839d
Refactor Hanna integration to streamline authentication and device ma…
odotreppe-abbove Jul 25, 2025
fae064d
WIP
odotreppe-abbove Sep 16, 2025
bcec574
Various updates following PR review
odotreppe-abbove Oct 1, 2025
1746628
Remove some logging
odotreppe-abbove Oct 14, 2025
719b2c9
update the type annotation
odotreppe-abbove Oct 14, 2025
9150cbd
Update following PR review
odotreppe-abbove Oct 14, 2025
f095cce
update looping on Sensor Description
odotreppe-abbove Oct 14, 2025
6b30df6
Removed dead code
odotreppe-abbove Oct 14, 2025
4512afd
self.add_suggested_values_to_schema(self.data_schema, user_input)
odotreppe-abbove Oct 14, 2025
2bc1b9b
Update quality scale
odotreppe-abbove Oct 15, 2025
74b85d9
Update quality scale and define async_shutdown function in coordinato…
odotreppe-abbove Oct 15, 2025
caf9b9d
removed dead code and async_shutdown function in coordinator as it i…
odotreppe-abbove Oct 15, 2025
2401e14
Merge branch 'dev' into add_hanna_integration
bestycame Oct 15, 2025
8d882cf
Extend test coverage
odotreppe-abbove Oct 16, 2025
e458b79
Improve test_error_scenarios following review
odotreppe-abbove Oct 16, 2025
b384d1d
Bump to hanna-cloud 0.0.6
odotreppe-abbove Oct 16, 2025
d3bcb74
Following review comments
odotreppe-abbove Nov 12, 2025
cbb7888
Added device classes and remove unnecessery icons
odotreppe-abbove Nov 12, 2025
eda78ca
Merge branch 'dev' into add_hanna_integration
joostlek Nov 20, 2025
6eee8bd
Fix
joostlek Nov 20, 2025
04320d8
Fix
joostlek Nov 20, 2025
71e8519
Fix
joostlek Nov 20, 2025
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.

73 changes: 73 additions & 0 deletions homeassistant/components/hanna/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""The Hanna Instruments integration."""

from __future__ import annotations

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_validation import config_entry_only_config_schema
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration

from .const import DOMAIN
from .coordinator import HannaDataCoordinator, HannaMainCoordinator

PLATFORMS = [Platform.SENSOR]

CONFIG_SCHEMA = config_entry_only_config_schema(DOMAIN)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Hanna Instruments component."""
_ = await async_get_integration(hass, DOMAIN)
hass.data.setdefault(DOMAIN, {})
return True
Comment thread
bestycame marked this conversation as resolved.
Outdated


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Comment thread
bestycame marked this conversation as resolved.
Outdated
"""Set up Hanna Instruments from a config entry."""

# Create main coordinator
main_coordinator = HannaMainCoordinator(hass, entry)
await main_coordinator.async_authenticate(
entry.data["email"], entry.data["password"], entry.data["code"]
)
Comment thread
bestycame marked this conversation as resolved.
Outdated
await main_coordinator.async_config_entry_first_refresh()

# Create device coordinators
devices = await main_coordinator.async_get_devices()
device_coordinators = {}
for device in devices:
coordinator = HannaDataCoordinator(hass, main_coordinator, device, entry)
await coordinator.async_config_entry_first_refresh()
device_coordinators[coordinator.device_identifier] = coordinator

# Set runtime data
entry.runtime_data = {
"main_coordinator": main_coordinator,
"device_coordinators": device_coordinators,
}

# Forward the setup to the platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# Unload platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

# Clean up coordinators
if unload_ok and entry.runtime_data:
# Clean up device coordinators
for coordinator in entry.runtime_data["device_coordinators"].values():
await coordinator.async_shutdown()

# Clean up main coordinator
await entry.runtime_data["main_coordinator"].async_shutdown()

# Clear runtime data
entry.runtime_data = None

return unload_ok
101 changes: 101 additions & 0 deletions homeassistant/components/hanna/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Config flow for Hanna Instruments integration."""

from __future__ import annotations

import logging
from typing import Any

from hanna_cloud import HannaCloudClient
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD, CONF_SCAN_INTERVAL

from .const import DEFAULT_ENCRYPTION_KEY, DOMAIN

_LOGGER = logging.getLogger(__name__)


class HannaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Hanna Instruments."""

VERSION = 1
data_schema = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_CODE, default=DEFAULT_ENCRYPTION_KEY): str,
Comment thread
bestycame marked this conversation as resolved.
Outdated
vol.Required(CONF_SCAN_INTERVAL, default=5): int,
Comment thread
bestycame marked this conversation as resolved.
Outdated
}
)

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 None:
return self.async_show_form(
step_id="user",
data_schema=self.data_schema,
errors=errors,
)

try:
client = HannaCloudClient()
await self.hass.async_add_executor_job(
client.authenticate,
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
user_input[CONF_CODE],
)
except Exception:
Comment thread
bestycame marked this conversation as resolved.
Outdated
_LOGGER.exception("Unexpected exception")
errors["base"] = "invalid_auth"
return self.async_show_form(
step_id="user",
data_schema=self.data_schema,
errors=errors,
)
Comment thread
bestycame marked this conversation as resolved.
Outdated
return self.async_create_entry(
title=user_input[CONF_EMAIL],
data=user_input,
)

async def async_step_reconfigure(
Comment thread
bestycame marked this conversation as resolved.
Outdated
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
errors: dict[str, str] = {}

if user_input is None:
return self.async_show_form(
step_id="reconfigure",
data_schema=self.data_schema,
errors=errors,
)

try:
client = HannaCloudClient()
await self.hass.async_add_executor_job(
client.authenticate,
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
user_input[CONF_CODE],
)
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "invalid_auth"
return self.async_show_form(
step_id="reconfigure",
data_schema=self.data_schema,
errors=errors,
)

# Update the existing entry
reconfigure_entry = self._get_reconfigure_entry()
return self.async_update_reload_and_abort(
reconfigure_entry,
data=user_input,
)
6 changes: 6 additions & 0 deletions homeassistant/components/hanna/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants for the Hanna integration."""

DOMAIN = "hanna"

# This key is NOT private. It is found in the JavaScript code of the Hanna Cloud webapp at https://www.hannacloud.com
DEFAULT_ENCRYPTION_KEY = "MzJmODBmMDU0ZTAyNDFjYWM0YTVhOGQxY2ZlZTkwMDM="
174 changes: 174 additions & 0 deletions homeassistant/components/hanna/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""Hanna Instruments data coordinator for Home Assistant.

This module provides the data coordinator for fetching and managing Hanna Instruments
sensor data.
"""

from datetime import datetime, timedelta
import logging
from typing import Any

from hanna_cloud import HannaCloudClient
from requests.exceptions import RequestException

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

_LOGGER = logging.getLogger(__name__)


class HannaMainCoordinator(DataUpdateCoordinator):
"""Main coordinator for Hanna Instruments authentication and device management."""

def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> None:
"""Initialize the main Hanna coordinator."""
self.api_client = HannaCloudClient()
self._devices: dict[str, dict] = {}
update_interval = timedelta(minutes=config_entry.data.get("update_interval", 1))

Copilot AI Jun 26, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key used to obtain the update interval is 'update_interval', but the config flow schema defines it as 'scan_interval'. Update this to use config_entry.data.get('scan_interval', 1) to reflect the intended configuration.

Suggested change
update_interval = timedelta(minutes=config_entry.data.get("update_interval", 1))
update_interval = timedelta(minutes=config_entry.data.get("scan_interval", 1))

Copilot uses AI. Check for mistakes.
Comment thread
bestycame marked this conversation as resolved.
Outdated
super().__init__(
hass,
_LOGGER,
name="hanna_main",
update_interval=update_interval,
)

async def async_authenticate(self, email: str, password: str, code: str) -> None:
"""Authenticate with the Hanna API."""
await self.hass.async_add_executor_job(
self.api_client.authenticate, email, password, code
)

async def async_get_devices(self) -> list[dict]:
"""Get all devices associated with the account."""
devices = await self.hass.async_add_executor_job(self.api_client.get_devices)
self._devices = {device.get("DID"): device for device in devices}
return devices

def get_device(self, device_id: str) -> dict:
"""Get a specific device by ID."""
return self._devices[device_id]

async def _async_update_data(self):
"""Update the list of devices."""
try:
await self.async_get_devices()
except Exception as e:
_LOGGER.error("Error updating devices: %s", e)
raise UpdateFailed(f"Error updating devices: {e}") from e
else:
return self._devices


class HannaDataCoordinator(DataUpdateCoordinator):
"""Coordinator for fetching Hanna sensor data."""

def __init__(
self,
hass: HomeAssistant,
main_coordinator: HannaMainCoordinator,
device: dict,
Comment thread
bestycame marked this conversation as resolved.
Outdated
config_entry: ConfigEntry,
Comment thread
bestycame marked this conversation as resolved.
Outdated
) -> None:
"""Initialize the Hanna data coordinator."""
self.main_coordinator = main_coordinator
self._device_data = device
self._readings = None
update_interval = timedelta(minutes=config_entry.data.get("update_interval", 1))

Copilot AI Jun 26, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the HannaDataCoordinator, the update interval is also obtained from the key 'update_interval'. To ensure consistency with the config flow (which uses 'scan_interval'), update this key accordingly.

Suggested change
update_interval = timedelta(minutes=config_entry.data.get("update_interval", 1))
update_interval = timedelta(minutes=config_entry.data.get("scan_interval", 1))

Copilot uses AI. Check for mistakes.
Comment thread
bestycame marked this conversation as resolved.
Outdated
super().__init__(
hass,
_LOGGER,
name=f"hanna_{self.device_identifier}",
update_interval=update_interval,
)

@property
def api_client(self) -> HannaCloudClient:
"""Return the API client from the main coordinator."""
return self.main_coordinator.api_client

@property
def device_identifier(self) -> str:
"""Return the device identifier."""
return self._device_data["DID"]

@property
def device_data(self) -> dict:
"""Return the device data."""
return self._device_data

@property
def readings(self) -> dict:
"""Return the readings."""
return self._readings or {}

@property
def device_info(self) -> DeviceInfo:
"""Return device information for Home Assistant."""
sy = self.device_data.get("reportedSettings", {}).get("SY")
return DeviceInfo(
identifiers={("hanna", self.device_identifier)},
manufacturer="Hanna Instruments",
model=self.device_data.get("DM"),
name=f"{self.device_identifier} {self.device_data.get('DINFO', {}).get('deviceName')}",
serial_number=sy.split(",")[4],
sw_version="".join(sy.split(",")[2:4]).replace("/", "/"),
)

def get_last_update_time(self) -> str:
"""Get the formatted last update time from sensor data."""
format_string = "%Y-%m-%d %H:%M:%SZ"
last_update_ts = int(self.get_messages_value("receivedAtUTCs"))
last_update_dt = datetime.fromtimestamp(last_update_ts)
Comment thread
bestycame marked this conversation as resolved.
Outdated
return last_update_dt.strftime(format_string)

def get_messages(self) -> dict[str, Any]:
"""Get the messages from the sensor data."""
return self.get_readings().get("messages", {})

def get_messages_value(self, key: str) -> Any:
"""Get the value for a specific key in the messages."""
return self.get_messages().get(key)

def get_glp(self) -> dict[str, Any]:
"""Get the glp from the sensor data."""
return self.get_messages_value("glp")

def get_glp_value(self, key: str) -> Any:
"""Get the value for a specific key in the glp."""
return self.get_glp().get(key)

def get_parameters(self) -> list[dict[str, Any]]:
"""Get all parameters from the sensor data."""
return self.get_messages_value("parameters") or []

def get_parameter_value(self, key: str) -> Any:
"""Get the value for a specific parameter."""
for parameter in self.get_parameters():
if parameter["name"] == key:
return parameter["value"]
return None
Comment thread
bestycame marked this conversation as resolved.

def get_readings(self) -> dict[str, Any]:
"""Get the raw readings from the device."""
return self._readings or {}

async def _async_update_data(self):
Comment thread
bestycame marked this conversation as resolved.
Outdated
"""Fetch latest sensor data from the Hanna API."""
try:
readings = await self.hass.async_add_executor_job(
self.api_client.get_last_device_reading, self.device_identifier
)
self._readings = readings[0]
Comment thread
bestycame marked this conversation as resolved.
Outdated
except RequestException as e:
raise UpdateFailed(f"Error communicating with Hanna API: {e}") from e
except (KeyError, IndexError) as e:
raise UpdateFailed(f"Error parsing Hanna API response: {e}") from e
except Exception as e:
_LOGGER.error("Unexpected error while fetching Hanna data: %s", e)
raise UpdateFailed(f"Unexpected error: {e}") from e
Comment thread
bestycame marked this conversation as resolved.
Outdated
11 changes: 11 additions & 0 deletions homeassistant/components/hanna/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "hanna",
"name": "Hanna",
"codeowners": ["@bestycame"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hanna",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["hanna-cloud==0.0.4"],
"single_config_entry": true
Comment thread
bestycame marked this conversation as resolved.
Outdated
}
Loading