From 2fb480a54823fba7b5fb001c47f0af8ca6e7af36 Mon Sep 17 00:00:00 2001 From: Hebezo Date: Thu, 5 Mar 2026 15:48:44 +0100 Subject: [PATCH 1/2] feat: implement cleanup for persistent files on integration removal and enhance FCM connection handling --- custom_components/siedle/__init__.py | 48 ++++++++++++++++++++++--- custom_components/siedle/fcm_handler.py | 10 +++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/custom_components/siedle/__init__.py b/custom_components/siedle/__init__.py index a303701..81c9d5c 100644 --- a/custom_components/siedle/__init__.py +++ b/custom_components/siedle/__init__.py @@ -462,6 +462,49 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Clean up all persistent files when the integration is removed.""" + entry_id = entry.entry_id + files_to_remove = [] + + # FCM credentials (old location in config root) + files_to_remove.append(hass.config.path(f".siedle_fcm_{entry_id}.json")) + + # Token cache (old location in config root) + files_to_remove.append(hass.config.path(f".siedle_token_{entry_id}.json")) + + # Token cache (new location in siedle/ subfolder) + siedle_data_dir = hass.config.path("siedle") + files_to_remove.append(os.path.join(siedle_data_dir, f"token_{entry_id}.json")) + + def _cleanup_files(): + removed = [] + for path in files_to_remove: + if os.path.exists(path): + try: + os.remove(path) + removed.append(path) + except OSError as err: + _LOGGER.warning("Could not remove %s: %s", path, err) + + # Remove siedle/ directory if empty + if os.path.isdir(siedle_data_dir): + try: + if not os.listdir(siedle_data_dir): + os.rmdir(siedle_data_dir) + removed.append(siedle_data_dir) + except OSError: + pass + + return removed + + removed = await hass.async_add_executor_job(_cleanup_files) + if removed: + _LOGGER.info("Cleaned up %d file(s) for entry %s: %s", len(removed), entry_id, removed) + else: + _LOGGER.debug("No files to clean up for entry %s", entry_id) + + def _mqtt_callback(hass: HomeAssistant, entry: ConfigEntry, topic: str, payload: dict): """Handle MQTT messages (called from MQTT thread).""" _LOGGER.debug("MQTT message: %s - %s", topic, payload) @@ -572,10 +615,7 @@ def _sip_state_callback(hass: HomeAssistant, entry: ConfigEntry, state, data: di if state_val == "answered": call_history_sensor.call_answered() elif state_val in ("idle", "ended"): - call_history_sensor.call_ended( - recording_file=data.get("recording_file"), - dtmf_door_opened=data.get("dtmf_door_opened", False), - ) + call_history_sensor.call_ended(data=data) # Update recording sensor if recording file is available if "recording_file" in data: diff --git a/custom_components/siedle/fcm_handler.py b/custom_components/siedle/fcm_handler.py index 3348e95..a3e0d21 100644 --- a/custom_components/siedle/fcm_handler.py +++ b/custom_components/siedle/fcm_handler.py @@ -12,9 +12,10 @@ from typing import Callable, Optional, Dict, Any from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_SIEDLE_CONNECTION_UPDATE _LOGGER = logging.getLogger(__name__) @@ -449,9 +450,16 @@ def _on_connection_status(self, status: str, client_id: str): if self._connected and not was_connected: _LOGGER.info("FCM connection established - doorbell detection active") self._fire_event("fcm_connected", {"status": "connected"}) + # Dispatch signal so binary sensor updates immediately + self._hass.loop.call_soon_threadsafe( + async_dispatcher_send, self._hass, SIGNAL_SIEDLE_CONNECTION_UPDATE + ) elif not self._connected and was_connected: _LOGGER.warning("FCM connection lost") self._fire_event("fcm_disconnected", {"status": "disconnected"}) + self._hass.loop.call_soon_threadsafe( + async_dispatcher_send, self._hass, SIGNAL_SIEDLE_CONNECTION_UPDATE + ) def _on_notification(self, message: dict, client_id: str): """Handle FCM notification message.""" From d75b064c4d94cbbdb3ce2038c1a968022bf4bffe Mon Sep 17 00:00:00 2001 From: Hebezo Date: Sun, 8 Mar 2026 09:10:03 +0100 Subject: [PATCH 2/2] feat: add option reload listener and improve transport string handling in SIP manager --- custom_components/siedle/__init__.py | 9 +++++ custom_components/siedle/sip_manager.py | 44 +++++++++++++++++-------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/custom_components/siedle/__init__.py b/custom_components/siedle/__init__.py index 81c9d5c..7680e6e 100644 --- a/custom_components/siedle/__init__.py +++ b/custom_components/siedle/__init__.py @@ -417,9 +417,18 @@ async def _start_fcm_handler(): # Register services await async_setup_services(hass, siedle) + # Reload integration when options change (e.g. external SIP enabled/disabled) + entry.async_on_unload(entry.add_update_listener(_async_reload_on_options_change)) + return True +async def _async_reload_on_options_change(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the config entry when options are updated via the UI.""" + _LOGGER.info("Options changed, reloading Siedle integration...") + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/siedle/sip_manager.py b/custom_components/siedle/sip_manager.py index 929ca70..1dd1352 100644 --- a/custom_components/siedle/sip_manager.py +++ b/custom_components/siedle/sip_manager.py @@ -105,6 +105,15 @@ def from_dict(cls, data: dict) -> "SipConfig": display_name=data.get("display_name"), ) + @property + def transport_str(self) -> str: + """SIP transport string for Via/Contact headers (TLS, TCP, or UDP).""" + if self.transport == SipTransport.TLS: + return "TLS" + if self.transport == SipTransport.TCP: + return "TCP" + return "UDP" + @dataclass class SipCall: @@ -382,7 +391,7 @@ def _create_register(self, with_auth: bool = False) -> bytes: branch = uuid.uuid4().hex[:12] uri = f"sip:{self.config.host}" - transport_str = "TLS" if self.config.transport == SipTransport.TLS else "UDP" + transport_str = self.config.transport_str lines = [ f"REGISTER {uri} SIP/2.0", @@ -428,7 +437,7 @@ def create_invite(self, to_uri: str, local_rtp_port: int, call_id: Optional[str] branch = uuid.uuid4().hex[:12] cseq = 1 - transport_str = "TLS" if self.config.transport == SipTransport.TLS else "UDP" + transport_str = self.config.transport_str display_name = self.config.display_name or "Siedle Türstation" # Use custom SDP or generate default @@ -486,7 +495,7 @@ def create_response(self, request: SipMessage, status_code: int, status_text: st """ local_ip = self._get_local_ip() to_tag = uuid.uuid4().hex[:8] - transport_str = "TLS" if self.config.transport == SipTransport.TLS else "UDP" + transport_str = self.config.transport_str # Build To header with tag for 200 OK to_header = request.to_header @@ -540,7 +549,7 @@ def create_bye(self, call: SipCall) -> bytes: local_ip = self._get_local_ip() cseq = call.next_cseq() branch = uuid.uuid4().hex[:12] - transport_str = "TLS" if self.config.transport == SipTransport.TLS else "UDP" + transport_str = self.config.transport_str to_header = f"<{call.to_uri}>" if call.to_tag: @@ -565,7 +574,7 @@ def create_ack(self, call: SipCall, for_response: SipMessage) -> bytes: """Create SIP ACK message.""" local_ip = self._get_local_ip() branch = uuid.uuid4().hex[:12] - transport_str = "TLS" if self.config.transport == SipTransport.TLS else "UDP" + transport_str = self.config.transport_str # Extract to_tag from response to_header = for_response.to_header @@ -598,20 +607,28 @@ def connect(self) -> bool: self._ssl_socket = context.wrap_socket(sock, server_hostname=self.config.host) self._ssl_socket.connect((self.config.host, self.config.port)) self._socket = self._ssl_socket - _LOGGER.info(f"{self.name}: TLS connection established") + self._local_ip = self._socket.getsockname()[0] + self._local_port = self._socket.getsockname()[1] + _LOGGER.info(f"{self.name}: TLS connection established (local {self._local_ip}:{self._local_port})") elif self.config.transport == SipTransport.TCP: # TCP connection self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.settimeout(30) self._socket.connect((self.config.host, self.config.port)) - _LOGGER.info(f"{self.name}: TCP connection established") + self._local_ip = self._socket.getsockname()[0] + self._local_port = self._socket.getsockname()[1] + _LOGGER.info(f"{self.name}: TCP connection established (local {self._local_ip}:{self._local_port})") else: # UDP connection self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._socket.settimeout(5) # For UDP, we don't "connect" but we set default destination self._socket.connect((self.config.host, self.config.port)) - _LOGGER.info(f"{self.name}: UDP socket ready") + # Update local port to actual OS-assigned port (important for Via/Contact headers!) + actual_addr = self._socket.getsockname() + self._local_ip = actual_addr[0] + self._local_port = actual_addr[1] + _LOGGER.info(f"{self.name}: UDP socket ready (local {self._local_ip}:{self._local_port})") self._connected = True return True @@ -629,9 +646,10 @@ def register(self) -> bool: try: # Send initial REGISTER - _LOGGER.info(f"{self.name}: Sending initial REGISTER to {self.config.host}:{self.config.port}...") + _LOGGER.info(f"{self.name}: Sending initial REGISTER to {self.config.host}:{self.config.port} " + f"(local {self._local_ip}:{self._local_port}, user={self.config.username})...") register_msg = self._create_register(with_auth=False) - _LOGGER.debug(f"{self.name}: REGISTER message:\n{register_msg.decode()[:500]}...") + _LOGGER.info(f"{self.name}: REGISTER message ({len(register_msg)} bytes):\n{register_msg.decode()[:800]}") self._socket.send(register_msg) response = self._socket.recv(4096) @@ -883,7 +901,7 @@ def _send_options_keepalive(self): local_ip = self._get_local_ip() cseq = self._next_cseq() branch = uuid.uuid4().hex[:12] - transport_str = "TLS" if self.config.transport == SipTransport.TLS else "UDP" + transport_str = self.config.transport_str options_msg = ( f"OPTIONS sip:{self.config.host} SIP/2.0\r\n" @@ -1178,7 +1196,7 @@ def _build_cancel_for_invite(self) -> Optional[bytes]: local_ip = self._external_conn._get_local_ip() local_port = self._external_conn.config.port - transport_str = "TLS" if self._external_conn.config.transport == SipTransport.TLS else "UDP" + transport_str = self._external_conn.config.transport_str branch = uuid.uuid4().hex[:12] targets = self._get_forward_targets() @@ -1521,7 +1539,7 @@ def _handle_external_message(self, msg: SipMessage): # Reuse the same SDP from the original INVITE local_ip = self._external_conn._get_local_ip() branch = uuid.uuid4().hex[:12] - transport_str = "TLS" if self._external_conn.config.transport == SipTransport.TLS else "UDP" + transport_str = self._external_conn.config.transport_str # We need the SDP — reconstruct from rtp_bridge sdp_ip_b = local_ip