Skip to content
Merged
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
57 changes: 53 additions & 4 deletions custom_components/siedle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -462,6 +471,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)
Expand Down Expand Up @@ -572,10 +624,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:
Expand Down
10 changes: 9 additions & 1 deletion custom_components/siedle/fcm_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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."""
Expand Down
44 changes: 31 additions & 13 deletions custom_components/siedle/sip_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
Kannix2005 marked this conversation as resolved.


@dataclass
class SipCall:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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]
Comment thread
Kannix2005 marked this conversation as resolved.
_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]
Comment thread
Kannix2005 marked this conversation as resolved.
_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
Expand All @@ -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]}")
Comment thread
Kannix2005 marked this conversation as resolved.
self._socket.send(register_msg)

response = self._socket.recv(4096)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down