Skip to content

Commit

Permalink
Merge pull request #72 from Alexwijn/develop
Browse files Browse the repository at this point in the history
New Years Eve Release
  • Loading branch information
Alexwijn authored Dec 31, 2024
2 parents 9cd9a24 + 4d6bca4 commit 9ce3926
Show file tree
Hide file tree
Showing 53 changed files with 3,986 additions and 1,785 deletions.
17 changes: 17 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Release

on:
release:
types: [ published ]

jobs:
release:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: vimtor/[email protected]
with:
files: custom_components/
dest: sat.zip
19 changes: 19 additions & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[MASTER]
# Specify a configuration file.
rcfile=

[REPORTS]
# Set the output format. Available formats: text, parseable, colorized, msvs (visual studio).
output-format=text

[FORMAT]
# Maximum number of characters on a single line.
max-line-length=200

[DESIGN]
# Maximum number of arguments for function / method.
max-args=5

[TYPECHECK]
# Tells whether missing members accessed in mixin class should be ignored.
ignore-mixin-members=yes
225 changes: 199 additions & 26 deletions README.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions configuration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ logger:
default: info
logs:
custom_components.sat: debug
custom_components.interpolated_sensor: debug

homeassistant:
customize:
Expand Down Expand Up @@ -39,18 +40,22 @@ template:
- unit_of_measurement: °C
name: Heater Temperature
device_class: 'temperature'
unique_id: heater_temperature
state: "{{ states('input_number.heater_temperature_raw') }}"
- unit_of_measurement: °C
name: Current Temperature
device_class: 'temperature'
unique_id: current_temperature
state: "{{ states('input_number.current_temperature_raw') }}"
- unit_of_measurement: °C
name: Outside Temperature
device_class: 'temperature'
unique_id: outside_temperature
state: "{{ states('input_number.outside_temperature_raw') }}"
- unit_of_measurement: "%"
name: Current Humidity
device_class: 'humidity'
unique_id: current_humidity
state: "{{ states('input_number.humidity_raw') }}"

input_number:
Expand Down
204 changes: 140 additions & 64 deletions custom_components/sat/__init__.py
Original file line number Diff line number Diff line change
@@ -1,151 +1,227 @@
import asyncio
import logging
import traceback

from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry
from homeassistant.helpers.storage import Store

from . import mqtt, serial, switch
from .const import *
from sentry_sdk import Client, Hub

from .const import (
DOMAIN,
CLIMATE,
SENTRY,
COORDINATOR,
CONF_MODE,
CONF_DEVICE,
CONF_ERROR_MONITORING, OPTIONS_DEFAULTS,
)
from .coordinator import SatDataUpdateCoordinatorFactory

_LOGGER: logging.Logger = logging.getLogger(__name__)
PLATFORMS = [CLIMATE_DOMAIN, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN]


async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""
Set up this integration using the UI.
This function is called by Home Assistant when the integration is set up with the UI.
"""
# Make sure we have our default domain property
_hass.data.setdefault(DOMAIN, {})
hass.data.setdefault(DOMAIN, {})

# Create a new dictionary for this entry
_hass.data[DOMAIN][_entry.entry_id] = {}
hass.data[DOMAIN][entry.entry_id] = {}

# Setup error monitoring (if enabled)
if entry.options.get(CONF_ERROR_MONITORING, OPTIONS_DEFAULTS[CONF_ERROR_MONITORING]):
await hass.async_add_executor_job(initialize_sentry, hass)

# Resolve the coordinator by using the factory according to the mode
_hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = await SatDataUpdateCoordinatorFactory().resolve(
hass=_hass, data=_entry.data, options=_entry.options, mode=_entry.data.get(CONF_MODE), device=_entry.data.get(CONF_DEVICE)
hass.data[DOMAIN][entry.entry_id][COORDINATOR] = SatDataUpdateCoordinatorFactory().resolve(
hass=hass, data=entry.data, options=entry.options, mode=entry.data.get(CONF_MODE), device=entry.data.get(CONF_DEVICE)
)

# Forward entry setup for climate and other platforms
await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE_DOMAIN))
await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN]))
# Making sure everything is loaded
await hass.data[DOMAIN][entry.entry_id][COORDINATOR].async_setup()

# Forward entry setup for used platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

# Add an update listener for this entry
_entry.async_on_unload(_entry.add_update_listener(async_reload_entry))
entry.async_on_unload(entry.add_update_listener(async_reload_entry))

return True


async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""
Handle removal of an entry.
This function is called by Home Assistant when the integration is being removed.
"""

climate = _hass.data[DOMAIN][_entry.entry_id][CLIMATE]
await _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].async_will_remove_from_hass(climate)
_climate = hass.data[DOMAIN][entry.entry_id][CLIMATE]
_coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]

await _coordinator.async_will_remove_from_hass()

unloaded = all(
await asyncio.gather(
_hass.config_entries.async_unload_platforms(_entry, [CLIMATE_DOMAIN, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN]),
)
# Forward entry unload for used platforms
await asyncio.gather(hass.config_entries.async_unload_platforms(entry, PLATFORMS))
)

try:
if SENTRY in hass.data[DOMAIN]:
hass.data[DOMAIN][SENTRY].flush()
hass.data[DOMAIN][SENTRY].close()
hass.data[DOMAIN].pop(SENTRY, None)
except Exception as ex:
_LOGGER.error("Error during Sentry cleanup: %s", str(ex))

# Remove the entry from the data dictionary if all components are unloaded successfully
if unloaded:
_hass.data[DOMAIN].pop(_entry.entry_id)
hass.data[DOMAIN].pop(entry.entry_id)

return unloaded


async def async_reload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> None:
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""
Reload config entry.
This function is called by Home Assistant when the integration configuration is updated.
"""
# Unload the entry and its dependent components
await async_unload_entry(_hass, _entry)
await async_unload_entry(hass, entry)

# Set up the entry again
await async_setup_entry(_hass, _entry)
await async_setup_entry(hass, entry)


async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
from custom_components.sat.config_flow import SatFlowHandler
_LOGGER.debug("Migrating from version %s", _entry.version)
from .config_flow import SatFlowHandler
_LOGGER.debug("Migrating from version %s", entry.version)

if _entry.version < SatFlowHandler.VERSION:
new_data = {**_entry.data}
new_options = {**_entry.options}
if entry.version < SatFlowHandler.VERSION:
new_data = {**entry.data}
new_options = {**entry.options}

if _entry.version < 2:
if not _entry.data.get(CONF_MINIMUM_SETPOINT):
if entry.version < 2:
if not entry.data.get("minimum_setpoint"):
# Legacy Store
store = Store(_hass, 1, DOMAIN)
new_data[CONF_MINIMUM_SETPOINT] = MINIMUM_SETPOINT
store = Store(hass, 1, DOMAIN)
new_data["minimum_setpoint"] = 10

if (data := await store.async_load()) and (overshoot_protection_value := data.get("overshoot_protection_value")):
new_data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value
new_data["minimum_setpoint"] = overshoot_protection_value

if _entry.options.get("heating_system") == "underfloor":
new_data[CONF_HEATING_SYSTEM] = HEATING_SYSTEM_UNDERFLOOR
if entry.options.get("heating_system") == "underfloor":
new_data["heating_system"] = "underfloor"
else:
new_data[CONF_HEATING_SYSTEM] = HEATING_SYSTEM_RADIATORS
new_data["heating_system"] = "radiators"

if not _entry.data.get(CONF_MAXIMUM_SETPOINT):
new_data[CONF_MAXIMUM_SETPOINT] = 55
if not entry.data.get("maximum_setpoint"):
new_data["maximum_setpoint"] = 55

if _entry.options.get("heating_system") == "underfloor":
new_data[CONF_MAXIMUM_SETPOINT] = 50
if entry.options.get("heating_system") == "underfloor":
new_data["maximum_setpoint"] = 50

if _entry.options.get("heating_system") == "radiator_low_temperatures":
new_data[CONF_MAXIMUM_SETPOINT] = 55
if entry.options.get("heating_system") == "radiator_low_temperatures":
new_data["maximum_setpoint"] = 55

if _entry.options.get("heating_system") == "radiator_medium_temperatures":
new_data[CONF_MAXIMUM_SETPOINT] = 65
if entry.options.get("heating_system") == "radiator_medium_temperatures":
new_data["maximum_setpoint"] = 65

if _entry.options.get("heating_system") == "radiator_high_temperatures":
new_data[CONF_MAXIMUM_SETPOINT] = 75
if entry.options.get("heating_system") == "radiator_high_temperatures":
new_data["maximum_setpoint"] = 75

if _entry.version < 3:
if main_climates := _entry.options.get("main_climates"):
new_data[CONF_MAIN_CLIMATES] = main_climates
if entry.version < 3:
if main_climates := entry.options.get("main_climates"):
new_data["main_climates"] = main_climates
new_options.pop("main_climates")

if secondary_climates := _entry.options.get("climates"):
new_data[CONF_SECONDARY_CLIMATES] = secondary_climates
if secondary_climates := entry.options.get("climates"):
new_data["secondary_climates"] = secondary_climates
new_options.pop("climates")

if sync_with_thermostat := _entry.options.get("sync_with_thermostat"):
new_data[CONF_SYNC_WITH_THERMOSTAT] = sync_with_thermostat
if sync_with_thermostat := entry.options.get("sync_with_thermostat"):
new_data["sync_with_thermostat"] = sync_with_thermostat
new_options.pop("sync_with_thermostat")

if _entry.version < 4:
if _entry.data.get("window_sensor") is not None:
new_data[CONF_WINDOW_SENSORS] = [_entry.data.get("window_sensor")]
if entry.version < 4:
if entry.data.get("window_sensor") is not None:
new_data["window_sensors"] = [entry.data.get("window_sensor")]
del new_options["window_sensor"]

if _entry.version < 5:
if _entry.options.get("overshoot_protection") is not None:
new_data[CONF_OVERSHOOT_PROTECTION] = _entry.options.get("overshoot_protection")
if entry.version < 5:
if entry.options.get("overshoot_protection") is not None:
new_data["overshoot_protection"] = entry.options.get("overshoot_protection")
del new_options["overshoot_protection"]

if _entry.version < 6:
new_options[CONF_HEATING_CURVE_VERSION] = 1
if entry.version < 7:
new_options["pid_controller_version"] = 1

if entry.version < 8:
if entry.options.get("heating_curve_version") is not None and int(entry.options.get("heating_curve_version")) < 2:
new_options["heating_curve_version"] = 3

if entry.version < 9:
if entry.data.get("heating_system") == "heat_pump":
new_options["cycles_per_hour"] = 2

if entry.data.get("heating_system") == "radiators":
new_options["cycles_per_hour"] = 3

if entry.version < 10:
if entry.data.get("mode") == "mqtt":
device = device_registry.async_get(hass).async_get(entry.data.get("device"))

new_data["mode"] = "mqtt_opentherm"
new_data["device"] = list(device.identifiers)[0][1]

_entry.version = SatFlowHandler.VERSION
_hass.config_entries.async_update_entry(_entry, data=new_data, options=new_options)
hass.config_entries.async_update_entry(entry, version=SatFlowHandler.VERSION, data=new_data, options=new_options)

_LOGGER.info("Migration to version %s successful", _entry.version)
_LOGGER.info("Migration to version %s successful", entry.version)

return True


def initialize_sentry(hass: HomeAssistant):
"""Initialize Sentry synchronously in an offloaded executor job."""

def exception_filter(event, hint):
"""Filter events to send only SAT-related exceptions to Sentry."""
exc_info = hint.get("exc_info")

if exc_info:
_, _, exc_traceback = exc_info
stack = traceback.extract_tb(exc_traceback)

# Check if the exception originates from the SAT custom component
if any("custom_components/sat/" in frame.filename for frame in stack):
return event

# Ignore exceptions not related to SAT
return None

# Configure the Sentry client
client = Client(
traces_sample_rate=1.0,
before_send=exception_filter,
dsn="https://216fc0a74c488abdb79f9839fb7da33e@o4508432869621760.ingest.de.sentry.io/4508432872898640",
)

# Bind the Sentry client to the Sentry hub
hub = Hub(client)
hub.bind_client(client)

# Store the hub in Home Assistant's data for later use
hass.data[DOMAIN][SENTRY] = client
Loading

0 comments on commit 9ce3926

Please sign in to comment.