diff --git a/custom_components/hydroqc/config_flow/base.py b/custom_components/hydroqc/config_flow/base.py index defd45b..963b593 100644 --- a/custom_components/hydroqc/config_flow/base.py +++ b/custom_components/hydroqc/config_flow/base.py @@ -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 @@ -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, @@ -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.""" @@ -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() @@ -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() @@ -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") @@ -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 @@ -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 = [ @@ -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.""" @@ -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, }, ) @@ -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(), @@ -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 = ( diff --git a/custom_components/hydroqc/config_flow/options.py b/custom_components/hydroqc/config_flow/options.py index 8f965ab..f3cf0be 100644 --- a/custom_components/hydroqc/config_flow/options.py +++ b/custom_components/hydroqc/config_flow/options.py @@ -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, @@ -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( diff --git a/custom_components/hydroqc/const.py b/custom_components/hydroqc/const.py index dbf18f1..7acf83c 100644 --- a/custom_components/hydroqc/const.py +++ b/custom_components/hydroqc/const.py @@ -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" diff --git a/custom_components/hydroqc/coordinator/base.py b/custom_components/hydroqc/coordinator/base.py index 26a2bbb..701423a 100644 --- a/custom_components/hydroqc/coordinator/base.py +++ b/custom_components/hydroqc/coordinator/base.py @@ -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 @@ -39,6 +42,7 @@ CONF_CONTRACT_ID, CONF_CONTRACT_NAME, CONF_CUSTOMER_ID, + CONF_FORCE_IPV4, CONF_PREHEAT_DURATION, CONF_RATE, CONF_RATE_OPTION, @@ -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 @@ -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] diff --git a/custom_components/hydroqc/public_data/client.py b/custom_components/hydroqc/public_data/client.py index e698b75..129ad66 100644 --- a/custom_components/hydroqc/public_data/client.py +++ b/custom_components/hydroqc/public_data/client.py @@ -4,6 +4,7 @@ import datetime import logging +import socket import zoneinfo import aiohttp @@ -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. """ 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: diff --git a/custom_components/hydroqc/strings.json b/custom_components/hydroqc/strings.json index 2553d5b..85b3b79 100644 --- a/custom_components/hydroqc/strings.json +++ b/custom_components/hydroqc/strings.json @@ -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": { @@ -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." } } } diff --git a/custom_components/hydroqc/translations/en.json b/custom_components/hydroqc/translations/en.json index 73dd0a3..917a796 100644 --- a/custom_components/hydroqc/translations/en.json +++ b/custom_components/hydroqc/translations/en.json @@ -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": { @@ -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." } } }, diff --git a/custom_components/hydroqc/translations/es.json b/custom_components/hydroqc/translations/es.json index c83bd5f..c8feb0e 100644 --- a/custom_components/hydroqc/translations/es.json +++ b/custom_components/hydroqc/translations/es.json @@ -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": { @@ -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." } } }, @@ -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" + } } } -} \ No newline at end of file +} diff --git a/custom_components/hydroqc/translations/fr.json b/custom_components/hydroqc/translations/fr.json index e6d57c8..9470c56 100644 --- a/custom_components/hydroqc/translations/fr.json +++ b/custom_components/hydroqc/translations/fr.json @@ -5,10 +5,12 @@ "title": "Choisir le mode de connexion", "description": "Sélectionnez comment vous voulez vous connecter à Hydro-Québec", "data": { - "auth_mode": "Mode de connexion" + "auth_mode": "Mode de connexion", + "force_ipv4": "Forcer IPv4" }, "data_description": { - "auth_mode": "Mode Portail : Connectez-vous pour un accès complet au compte. Mode Données Ouvertes : Utilisez les données publiques uniquement (sans connexion requise)" + "auth_mode": "Mode Portail : Connectez-vous pour un accès complet au compte. Mode Données Ouvertes : Utilisez les données publiques uniquement (sans connexion requise)", + "force_ipv4": "Toutes les adresses IP doivent provenir du Canada/Québec. Sur les hôtes double-pile, le trafic IPv6 peut transiter par un chemin non canadien et être bloqué géographiquement par les serveurs d'Hydro-Québec, causant des délais d'expiration. Forcer IPv4 garantit que le trafic utilise votre adresse IPv4 canadienne." } }, "account": { @@ -113,13 +115,15 @@ "update_interval": "Intervalle de mise à jour (secondes)", "preheat_duration_minutes": "Durée de préchauffage (minutes)", "enable_consumption_sync": "Activer la synchronisation de consommation", - "calendar_entity_id": "Entité calendrier" + "calendar_entity_id": "Entité calendrier", + "force_ipv4": "Forcer IPv4" }, "data_description": { "update_interval": "Fréquence de récupération des données d'Hydro-Québec (30-600 secondes)", "preheat_duration_minutes": "Durée avant les événements de pointe critique pour déclencher le préchauffage (0-240 minutes)", "enable_consumption_sync": "Activer pour synchroniser les données de consommation horaire pour le tableau de bord énergétique. Nécessite un rechargement de l'intégration pour prendre effet.", - "calendar_entity_id": "Entité calendrier pour les événements de pointe (tarifs DPC/DCPC seulement). Requis pour le fonctionnement des capteurs de pointe." + "calendar_entity_id": "Entité calendrier pour les événements de pointe (tarifs DPC/DCPC seulement). Requis pour le fonctionnement des capteurs de pointe.", + "force_ipv4": "Toutes les adresses IP doivent provenir du Canada/Québec. Sur les hôtes double-pile, le trafic IPv6 peut transiter par un chemin non canadien et être bloqué géographiquement par les serveurs d'Hydro-Québec, causant des délais d'expiration. Forcer IPv4 garantit que le trafic utilise votre adresse IPv4 canadienne." } } }, @@ -304,9 +308,11 @@ "name": "Pointe matin demain" }, "dpc_critical_evening_peak_tomorrow": { - "name": "Pointe soir demain" }, + "name": "Pointe soir demain" + }, "portal_status": { - "name": "Statut du portail" } + "name": "Statut du portail" + } } } }