Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 36 additions & 7 deletions custom_components/hydroqc/config_flow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
from __future__ import annotations

import logging
import socket
from typing import Any, cast

import aiohttp

import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
Expand Down Expand Up @@ -35,6 +38,7 @@
CONF_CONTRACT_NAME,
CONF_CUSTOMER_ID,
CONF_ENABLE_CONSUMPTION_SYNC,
CONF_FORCE_IPV4,
CONF_HISTORY_DAYS,
CONF_PREHEAT_DURATION,
CONF_RATE,
Expand Down Expand Up @@ -65,6 +69,7 @@ def __init__(self) -> None:
self._available_sectors: list[str] = []
self._selected_sector: str | None = None
self._available_rates: list[dict[str, str]] = []
self._force_ipv4: bool = False

async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the initial step - choose auth mode."""
Expand All @@ -90,12 +95,14 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con
),
mode=SelectSelectorMode.LIST,
)
)
),
vol.Optional(CONF_FORCE_IPV4, default=False): bool,
}
),
)

self._auth_mode = user_input[CONF_AUTH_MODE]
self._force_ipv4 = user_input.get(CONF_FORCE_IPV4, False)

if self._auth_mode == AUTH_MODE_PORTAL:
return await self.async_step_account()
Expand All @@ -114,12 +121,26 @@ async def async_step_account(

try:
# Check portal status first
_LOGGER.debug(
"Creating temp WebUser for login (force_ipv4=%s)", self._force_ipv4
)
_temp_session: aiohttp.ClientSession | None = None
if self._force_ipv4:
_connector = aiohttp.TCPConnector(family=socket.AF_INET)
_cookie_jar = aiohttp.CookieJar(quote_cookie=False, unsafe=True)
_temp_session = aiohttp.ClientSession(
connector=_connector,
cookie_jar=_cookie_jar,
requote_redirect_url=False,
)
_LOGGER.debug("Force IPv4: created IPv4-only session for login")
temp_webuser = WebUser(
self._username,
self._password,
verify_ssl=True,
log_level="INFO",
http_log_level="WARNING",
session=_temp_session,
)

portal_available = await temp_webuser.check_hq_portal_status()
Expand Down Expand Up @@ -157,13 +178,18 @@ async def async_step_account(
return await self.async_step_select_contract()

except hydroqc.error.HydroQcHTTPError as err:
# Check if it's a 500 error (portal maintenance)
_LOGGER.error(
"HTTP error during login for %s: %s (status=%s)",
self._username,
err,
getattr(err, "status_code", "unknown"),
)
if hasattr(err, "status_code") and err.status_code == 500:
errors["base"] = "portal_maintenance"
else:
errors["base"] = "invalid_auth"
except RuntimeError:
# Portal unavailable - error already set above
except RuntimeError as err:
_LOGGER.warning("Portal unavailable during login: %s", err)
pass
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception during login")
Expand Down Expand Up @@ -294,6 +320,7 @@ async def async_step_import_history(
CONF_PREHEAT_DURATION: preheat_duration,
CONF_ENABLE_CONSUMPTION_SYNC: enable_consumption_sync,
CONF_HISTORY_DAYS: history_days if enable_consumption_sync else 0,
CONF_FORCE_IPV4: self._force_ipv4,
}

# Add calendar configuration if provided
Expand Down Expand Up @@ -338,7 +365,7 @@ async def async_step_opendata(
if user_input is not None:
# Store selected sector and move to offer selection
self._selected_sector = user_input["sector"]
return await self.async_step_opendata_rate()
return await self.async_step_opendata_offer()

# Build sector selection dropdown
sector_options = [
Expand All @@ -361,7 +388,7 @@ async def async_step_opendata(
errors=errors,
)

async def async_step_opendata_rate(
async def async_step_opendata_offer(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle opendata mode setup - select offer for chosen sector."""
Expand Down Expand Up @@ -415,6 +442,7 @@ async def async_step_opendata_rate(
CONF_RATE: rate,
CONF_RATE_OPTION: rate_option,
CONF_PREHEAT_DURATION: preheat_duration,
CONF_FORCE_IPV4: self._force_ipv4,
},
)

Expand All @@ -427,7 +455,7 @@ async def async_step_opendata_rate(
else "Unknown"
)
return self.async_show_form(
step_id="opendata_rate",
step_id="opendata_offer",
data_schema=vol.Schema(
{
vol.Required(CONF_CONTRACT_NAME, default="Home"): TextSelector(),
Expand Down Expand Up @@ -473,6 +501,7 @@ async def async_step_calendar_opendata(
CONF_RATE_OPTION: self._selected_contract["rate_option"],
CONF_PREHEAT_DURATION: DEFAULT_PREHEAT_DURATION,
CONF_CALENDAR_ENTITY_ID: calendar_entity_id,
CONF_FORCE_IPV4: self._force_ipv4,
}

sector_label = (
Expand Down
12 changes: 12 additions & 0 deletions custom_components/hydroqc/config_flow/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ..const import (
CONF_CALENDAR_ENTITY_ID,
CONF_ENABLE_CONSUMPTION_SYNC,
CONF_FORCE_IPV4,
CONF_PREHEAT_DURATION,
CONF_RATE,
CONF_RATE_OPTION,
Expand Down Expand Up @@ -87,6 +88,17 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Con
)
] = bool

# Add Force IPv4 option (applies to both portal and opendata modes)
schema_dict[
vol.Optional(
CONF_FORCE_IPV4,
default=self.config_entry.options.get(
CONF_FORCE_IPV4,
self.config_entry.data.get(CONF_FORCE_IPV4, False),
),
)
] = bool

# Add calendar options for DPC/DCPC rates (required)
if supports_calendar:
current_calendar = self.config_entry.options.get(
Expand Down
1 change: 1 addition & 0 deletions custom_components/hydroqc/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
CONF_HISTORY_DAYS: Final = "history_days"
CONF_CALENDAR_ENTITY_ID: Final = "calendar_entity_id"
CONF_ENABLE_CONSUMPTION_SYNC: Final = "enable_consumption_sync"
CONF_FORCE_IPV4: Final = "force_ipv4"

# Auth modes
AUTH_MODE_PORTAL: Final = "portal"
Expand Down
19 changes: 18 additions & 1 deletion custom_components/hydroqc/coordinator/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
import asyncio
import datetime
import logging
import socket
from typing import Any
from zoneinfo import ZoneInfo

import aiohttp

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
Expand Down Expand Up @@ -39,6 +42,7 @@
CONF_CONTRACT_ID,
CONF_CONTRACT_NAME,
CONF_CUSTOMER_ID,
CONF_FORCE_IPV4,
CONF_PREHEAT_DURATION,
CONF_RATE,
CONF_RATE_OPTION,
Expand Down Expand Up @@ -77,8 +81,11 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:

# Public data client for peak data (always used)
rate_for_client = f"{self._rate}{self._rate_option}"
force_ipv4 = entry.data.get(CONF_FORCE_IPV4, False)
self.public_client = PublicDataClient(
rate_code=rate_for_client, preheat_duration=self._preheat_duration
rate_code=rate_for_client,
preheat_duration=self._preheat_duration,
force_ipv4=force_ipv4,
)

# Track last successful update time
Expand Down Expand Up @@ -113,12 +120,22 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:

# Initialize webuser if in portal mode
if self._auth_mode == AUTH_MODE_PORTAL:
portal_session: aiohttp.ClientSession | None = None
if force_ipv4:
_connector = aiohttp.TCPConnector(family=socket.AF_INET)
_cookie_jar = aiohttp.CookieJar(quote_cookie=False, unsafe=True)
portal_session = aiohttp.ClientSession(
connector=_connector,
cookie_jar=_cookie_jar,
requote_redirect_url=False,
)
self._webuser = WebUser(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
verify_ssl=True,
log_level="INFO",
http_log_level="WARNING",
session=portal_session,
)
self._customer_id = entry.data[CONF_CUSTOMER_ID]
self._account_id = entry.data[CONF_ACCOUNT_ID]
Expand Down
13 changes: 11 additions & 2 deletions custom_components/hydroqc/public_data/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import datetime
import logging
import socket
import zoneinfo

import aiohttp
Expand All @@ -21,22 +22,30 @@
class PublicDataClient:
"""Client for Hydro-Québec public open data API."""

def __init__(self, rate_code: str, preheat_duration: int = 120) -> None:
def __init__(self, rate_code: str, preheat_duration: int = 120, force_ipv4: bool = False) -> None:
"""Initialize public data client.

Args:
rate_code: Rate code (DCPC, DPC, M-GDP, etc.)
preheat_duration: Pre-heat duration in minutes (default 120)
force_ipv4: When True, restricts the aiohttp session to IPv4
only (family=socket.AF_INET). Useful on dual-stack hosts
where Hydro-Québec servers are not reachable over IPv6.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Oops ... comment needs to mention traffic not from Quebec/Canada.

"""
self.rate_code = rate_code
self.peak_handler = PeakHandler(rate_code, preheat_duration)
self._force_ipv4 = force_ipv4
self._session: aiohttp.ClientSession | None = None
self._last_fetch: datetime.datetime | None = None

async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
if self._force_ipv4:
connector = aiohttp.TCPConnector(family=socket.AF_INET)
self._session = aiohttp.ClientSession(connector=connector)
else:
self._session = aiohttp.ClientSession()
return self._session

async def fetch_peak_data(self) -> None:
Expand Down
12 changes: 8 additions & 4 deletions custom_components/hydroqc/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
"title": "Choose Connection Mode",
"description": "Select how you want to connect to Hydro-Québec",
"data": {
"auth_mode": "Connection Mode"
"auth_mode": "Connection Mode",
"force_ipv4": "Force IPv4"
},
"data_description": {
"auth_mode": "Portal Mode: Sign in for full account access. OpenData Mode: Use public data only (no login required)"
"auth_mode": "Portal Mode: Sign in for full account access. OpenData Mode: Use public data only (no login required)",
"force_ipv4": "All IP addresses must originate from Canada/Quebec. On dual-stack hosts, IPv6 traffic may route through a non-Canadian path and be geo-blocked by Hydro-Québec servers, causing connection timeouts. Forcing IPv4 ensures traffic uses your Canadian IPv4 address."
}
},
"account": {
Expand Down Expand Up @@ -111,13 +113,15 @@
"update_interval": "Update Interval (seconds)",
"preheat_duration_minutes": "Pre-heat Duration (minutes)",
"enable_consumption_sync": "Enable consumption synchronization",
"calendar_entity_id": "Calendar Entity (optional)"
"calendar_entity_id": "Calendar Entity (optional)",
"force_ipv4": "Force IPv4"
},
"data_description": {
"update_interval": "How often to fetch data from Hydro-Québec (30-600 seconds)",
"preheat_duration_minutes": "Duration before critical peak events to trigger pre-heating (0-240 minutes)",
"enable_consumption_sync": "Enable to sync hourly consumption data for the energy dashboard. Requires integration reload to take effect.",
"calendar_entity_id": "Calendar entity for peak events (DPC/DCPC rates only). Leave empty to disable."
"calendar_entity_id": "Calendar entity for peak events (DPC/DCPC rates only). Leave empty to disable.",
"force_ipv4": "All IP addresses must originate from Canada/Quebec. On dual-stack hosts, IPv6 traffic may route through a non-Canadian path and be geo-blocked by Hydro-Québec servers, causing connection timeouts. Forcing IPv4 ensures traffic uses your Canadian IPv4 address."
}
}
}
Expand Down
12 changes: 8 additions & 4 deletions custom_components/hydroqc/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
"title": "Choose Connection Mode",
"description": "Select how you want to connect to Hydro-Québec",
"data": {
"auth_mode": "Connection Mode"
"auth_mode": "Connection Mode",
"force_ipv4": "Force IPv4"
},
"data_description": {
"auth_mode": "Portal Mode: Sign in for full account access. OpenData Mode: Use public data only (no login required)"
"auth_mode": "Portal Mode: Sign in for full account access. OpenData Mode: Use public data only (no login required)",
"force_ipv4": "All IP addresses must originate from Canada/Quebec. On dual-stack hosts, IPv6 traffic may route through a non-Canadian path and be geo-blocked by Hydro-Québec servers, causing connection timeouts. Forcing IPv4 ensures traffic uses your Canadian IPv4 address."
}
},
"account": {
Expand Down Expand Up @@ -123,13 +125,15 @@
"update_interval": "Update Interval (seconds)",
"preheat_duration_minutes": "Pre-heat Duration (minutes)",
"enable_consumption_sync": "Enable consumption synchronization",
"calendar_entity_id": "Calendar Entity"
"calendar_entity_id": "Calendar Entity",
"force_ipv4": "Force IPv4"
},
"data_description": {
"update_interval": "How often to fetch data from Hydro-Québec (30-600 seconds)",
"preheat_duration_minutes": "Duration before critical peak events to trigger pre-heating (0-240 minutes)",
"enable_consumption_sync": "Enable to sync hourly consumption data for the energy dashboard. Requires integration reload to take effect.",
"calendar_entity_id": "Calendar entity for peak events (DPC/DCPC rates only). Required for peak sensors to work."
"calendar_entity_id": "Calendar entity for peak events (DPC/DCPC rates only). Required for peak sensors to work.",
"force_ipv4": "All IP addresses must originate from Canada/Quebec. On dual-stack hosts, IPv6 traffic may route through a non-Canadian path and be geo-blocked by Hydro-Québec servers, causing connection timeouts. Forcing IPv4 ensures traffic uses your Canadian IPv4 address."
}
}
},
Expand Down
20 changes: 13 additions & 7 deletions custom_components/hydroqc/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
"title": "Elegir modo de conexión",
"description": "Seleccione cómo desea conectarse a Hydro-Québec",
"data": {
"auth_mode": "Modo de conexión"
"auth_mode": "Modo de conexión",
"force_ipv4": "Forzar IPv4"
},
"data_description": {
"auth_mode": "Modo Portal: Inicie sesión para acceso completo a la cuenta. Modo Datos Abiertos: Use solo datos públicos (no requiere inicio de sesión)"
"auth_mode": "Modo Portal: Inicie sesión para acceso completo a la cuenta. Modo Datos Abiertos: Use solo datos públicos (no requiere inicio de sesión)",
"force_ipv4": "Todas las direcciones IP deben originarse en Canadá/Quebec. En hosts de doble pila, el tráfico IPv6 puede enrutarse a través de una ruta no canadiense y ser bloqueado geográficamente por los servidores de Hydro-Québec, causando tiempos de espera. Forzar IPv4 garantiza que el tráfico use su dirección IPv4 canadiense."
}
},
"account": {
Expand Down Expand Up @@ -123,13 +125,15 @@
"update_interval": "Intervalo de actualización (segundos)",
"preheat_duration_minutes": "Duración de precalentamiento (minutos)",
"enable_consumption_sync": "Habilitar sincronización de consumo",
"calendar_entity_id": "Entidad de calendario"
"calendar_entity_id": "Entidad de calendario",
"force_ipv4": "Forzar IPv4"
},
"data_description": {
"update_interval": "Con qué frecuencia obtener datos de Hydro-Québec (30-600 segundos)",
"preheat_duration_minutes": "Duración antes de eventos de pico crítico para activar precalentamiento (0-240 minutos)",
"enable_consumption_sync": "Habilitar para sincronizar datos de consumo por hora para el panel de energía. Requiere recargar la integración para tener efecto.",
"calendar_entity_id": "Entidad de calendario para eventos de pico (solo tarifas DPC/DCPC). Requerido para el funcionamiento de los sensores de pico."
"calendar_entity_id": "Entidad de calendario para eventos de pico (solo tarifas DPC/DCPC). Requerido para el funcionamiento de los sensores de pico.",
"force_ipv4": "Todas las direcciones IP deben originarse en Canadá/Quebec. En hosts de doble pila, el tráfico IPv6 puede enrutarse a través de una ruta no canadiense y ser bloqueado geográficamente por los servidores de Hydro-Québec, causando tiempos de espera. Forzar IPv4 garantiza que el tráfico use su dirección IPv4 canadiense."
}
}
},
Expand Down Expand Up @@ -314,9 +318,11 @@
"name": "Pico mañana mañana"
},
"dpc_critical_evening_peak_tomorrow": {
"name": "Pico tarde mañana" },
"name": "Pico tarde mañana"
},
"portal_status": {
"name": "Estado del portal" }
"name": "Estado del portal"
}
}
}
}
}
Loading