vtherm-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vtherm_api/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Public package for the Versatile Thermostat API."""
2
+
3
+ from .plugin_climate import PluginClimate
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["PluginClimate", "__version__"]
@@ -0,0 +1,4 @@
1
+ from types import MappingProxyType
2
+ from typing import Any
3
+
4
+ ConfigData = MappingProxyType[str, Any]
vtherm_api/const.py ADDED
@@ -0,0 +1,46 @@
1
+ """ Some constants for the VThermAPI library """
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from homeassistant.util import dt as dt_util
6
+ from homeassistant.core import HomeAssistant
7
+ DEVICE_MANUFACTURER = "JMCOLLIN"
8
+ DEVICE_MODEL = "Versatile Thermostat"
9
+
10
+ DOMAIN = "versatile_thermostat"
11
+
12
+ VTHERM_API_NAME = "vtherm_api"
13
+ CONF_NAME = "name"
14
+
15
+
16
+ class EventType(Enum):
17
+ """The event type that can be sent"""
18
+
19
+ SAFETY_EVENT = "versatile_thermostat_safety_event"
20
+ POWER_EVENT = "versatile_thermostat_power_event"
21
+ TEMPERATURE_EVENT = "versatile_thermostat_temperature_event"
22
+ HVAC_MODE_EVENT = "versatile_thermostat_hvac_mode_event"
23
+ CENTRAL_BOILER_EVENT = "versatile_thermostat_central_boiler_event"
24
+ PRESET_EVENT = "versatile_thermostat_preset_event"
25
+ WINDOW_AUTO_EVENT = "versatile_thermostat_window_auto_event"
26
+ AUTO_START_STOP_EVENT = "versatile_thermostat_auto_start_stop_event"
27
+ TIMED_PRESET_EVENT = "versatile_thermostat_timed_preset_event"
28
+ HEATING_FAILURE_EVENT = "versatile_thermostat_heating_failure_event"
29
+
30
+
31
+ def get_tz(hass: HomeAssistant):
32
+ """Get the current timezone"""
33
+
34
+ return dt_util.get_time_zone(hass.config.time_zone)
35
+
36
+
37
+ class NowClass:
38
+ """For testing purpose only"""
39
+
40
+ @staticmethod
41
+ def get_now(hass: HomeAssistant) -> datetime:
42
+ """A test function to get the now.
43
+ For testing purpose this method can be overriden to get a specific
44
+ timestamp.
45
+ """
46
+ return datetime.now(get_tz(hass))
@@ -0,0 +1,83 @@
1
+ # custom_components/versatile_thermostat/thermostat_interface.py
2
+ from __future__ import annotations
3
+ from typing import Any
4
+ from datetime import datetime
5
+
6
+ from typing import Protocol, runtime_checkable
7
+ from homeassistant.helpers.device_registry import DeviceInfo
8
+ from homeassistant.core import HomeAssistant, CALLBACK_TYPE
9
+
10
+ from .commons_type import ConfigData
11
+
12
+
13
+ @runtime_checkable
14
+ class InterfaceThermostat(Protocol):
15
+ """Contrat minimal commun pour manipuler un thermostat VTherm."""
16
+
17
+ @property
18
+ def name(self) -> str:
19
+ """Get the name of the thermostat."""
20
+ ...
21
+
22
+ def register_manager(self, manager: InterfaceFeatureManager):
23
+ """Register a feature manager to the thermostat."""
24
+ ...
25
+
26
+ @property
27
+ def unique_id(self) -> str:
28
+ """Get the unique ID of the thermostat."""
29
+ ...
30
+
31
+ @property
32
+ def device_info(self) -> DeviceInfo | None:
33
+ """Get the device information."""
34
+ ...
35
+
36
+
37
+ @runtime_checkable
38
+ class InterfaceFeatureManager(Protocol):
39
+ """Contrat commun pour tous les FeatureManagers."""
40
+
41
+ def post_init(self, entry_infos: ConfigData | dict[str, Any]) -> None:
42
+ """Initialise le manager avec la configuration fusionnée."""
43
+ ...
44
+
45
+ async def start_listening(self, force: bool = False) -> None:
46
+ """Abonne le manager aux événements nécessaires."""
47
+ ...
48
+
49
+ def stop_listening(self) -> bool | None:
50
+ """Désabonne les listeners du manager."""
51
+ ...
52
+
53
+ async def refresh_state(self) -> bool:
54
+ """Rafraîchit l'état interne et retourne True si changement."""
55
+ ...
56
+
57
+ def restore_state(self, old_state: Any) -> None:
58
+ """Restaure l'état du manager depuis un ancien state HA."""
59
+ ...
60
+
61
+ def add_listener(self, func: CALLBACK_TYPE) -> None:
62
+ """Ajoute un callback à désinscrire au stop."""
63
+ ...
64
+
65
+ @property
66
+ def is_configured(self) -> bool:
67
+ """Le manager est-il correctement configuré ?"""
68
+ ...
69
+
70
+ @property
71
+ def is_detected(self) -> bool:
72
+ """Le manager détecte-t-il actuellement sa condition ?"""
73
+ ...
74
+
75
+ @property
76
+ def name(self) -> str:
77
+ """Nom logique du manager."""
78
+ ...
79
+
80
+ @property
81
+ def hass(self) -> HomeAssistant:
82
+ """Instance Home Assistant."""
83
+ ...
@@ -0,0 +1,533 @@
1
+ """In-memory log collector for Versatile Thermostat.
2
+
3
+ Captures all log records from the VTherm logger hierarchy into a ring buffer.
4
+ Provides filtering by thermostat name, log level and time window, and export
5
+ to a downloadable text file served from config/www/.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ import threading
13
+ from collections import deque
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timedelta, timezone
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, ClassVar
18
+
19
+ from aiohttp import web
20
+ from homeassistant.components.http.auth import async_sign_path
21
+ from homeassistant.helpers.network import get_url
22
+
23
+ if TYPE_CHECKING:
24
+ from homeassistant.core import HomeAssistant
25
+ from homeassistant.components.http import HomeAssistantView
26
+
27
+
28
+ def _parse_to_utc(value: datetime | str | None) -> datetime | None:
29
+ """Convert a datetime or ISO string (assumed local) to UTC aware datetime.
30
+
31
+ Uses homeassistant.util.dt when available (runtime), falls back to
32
+ fromisoformat for unit tests.
33
+ """
34
+ if value is None:
35
+ return None
36
+ if isinstance(value, datetime):
37
+ if value.tzinfo is None:
38
+ try:
39
+ from homeassistant.util import dt as dt_util # pylint: disable=import-outside-toplevel
40
+ return dt_util.as_utc(value.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE))
41
+ except ImportError:
42
+ return value.replace(tzinfo=timezone.utc)
43
+ return value
44
+ # str path
45
+ try:
46
+ from homeassistant.util import dt as dt_util # pylint: disable=import-outside-toplevel
47
+ parsed = dt_util.parse_datetime(value)
48
+ if parsed is not None:
49
+ return dt_util.as_utc(parsed)
50
+ except ImportError:
51
+ pass
52
+ return datetime.fromisoformat(value).replace(tzinfo=timezone.utc)
53
+
54
+
55
+ _LOGGER = logging.getLogger(__name__)
56
+
57
+ THERMOSTAT_PATTERN = re.compile(r"^(.+?) - ")
58
+
59
+ DEFAULT_MAX_AGE_HOURS = 4
60
+ DEFAULT_MAX_ENTRIES = 100_000
61
+ PURGE_EVERY_N = 1000
62
+ LOG_OUTPUT_DIR = "www/versatile_thermostat"
63
+ OLD_FILE_MAX_AGE_HOURS = 24
64
+
65
+ LEVEL_MAP = {
66
+ "DEBUG": logging.DEBUG,
67
+ "INFO": logging.INFO,
68
+ "WARNING": logging.WARNING,
69
+ "ERROR": logging.ERROR,
70
+ }
71
+
72
+
73
+ def _extract_thermostat_hint(message: str) -> str | None:
74
+ """Extract the thermostat name from a log message following the '%s - ...' pattern."""
75
+ match = THERMOSTAT_PATTERN.match(message)
76
+ return match.group(1) if match else None
77
+
78
+
79
+ def _hint_matches_thermostat(hint_lower: str, name_lower: str) -> bool:
80
+ """Check if a log hint belongs to the given thermostat name.
81
+
82
+ Matches generically all formats where the thermostat name appears
83
+ after a class prefix separated by '-':
84
+ - '{name}' (exact match)
85
+ - '{Prefix}-{name}' (e.g. EMA-Salon)
86
+ - '{Prefix}-{name}-{entity}' (e.g. VersatileThermostat-Salon-input_boolean.heater1)
87
+ """
88
+ if hint_lower == name_lower:
89
+ return True
90
+ suffix = "-" + name_lower
91
+ # '{Prefix}-{name}'
92
+ if hint_lower.endswith(suffix):
93
+ return True
94
+ # '{Prefix}-{name}-{entity}'
95
+ if suffix + "-" in hint_lower:
96
+ return True
97
+ return False
98
+
99
+
100
+ def _short_logger_name(name: str) -> str:
101
+ """Return only the last part of a dotted logger name."""
102
+ return name.rsplit(".", 1)[-1]
103
+
104
+
105
+ class VThermLogger(logging.Logger):
106
+ """Logger subclass that always feeds the collector regardless of the effective level.
107
+
108
+ isEnabledFor() always returns True so that LogRecords are created for every
109
+ call (debug, info, …). In callHandlers(), the record is forwarded to the
110
+ ring-buffer collector unconditionally, while the normal HA handler chain is
111
+ only reached when the record level satisfies the configured effective level.
112
+ This preserves the user's logger.yaml configuration for home-assistant.log
113
+ while still capturing everything in the in-memory buffer.
114
+ """
115
+
116
+ # set by VThermLogHandler.__init__
117
+ _collector: ClassVar[VThermLogHandler | None] = None
118
+
119
+ def isEnabledFor(self, level: int) -> bool: # type: ignore[override]
120
+ """Always return True so that LogRecords are always created."""
121
+ return True
122
+
123
+ def callHandlers(self, record: logging.LogRecord) -> None:
124
+ """Send record to collector unconditionally; to other handlers only if level allows."""
125
+ if VThermLogger._collector is not None:
126
+ try:
127
+ VThermLogger._collector.emit(record)
128
+ except Exception: # noqa: BLE001
129
+ pass
130
+ if record.levelno >= self.getEffectiveLevel():
131
+ super().callHandlers(record)
132
+
133
+
134
+ def get_vtherm_logger(name: str) -> VThermLogger:
135
+ """Return a VThermLogger for *name*, replacing the standard Logger in the Manager.
136
+
137
+ Calls logging.getLogger(name) first so that the Manager sets up the parent
138
+ chain correctly, then swaps the entry for a VThermLogger while preserving
139
+ all attributes (level, handlers, parent, propagate, disabled).
140
+ """
141
+ manager = logging.Logger.manager
142
+ existing = manager.loggerDict.get(name)
143
+ if isinstance(existing, VThermLogger):
144
+ return existing
145
+ # Let the Manager create/retrieve the standard Logger (parent chain already set up)
146
+ std = logging.getLogger(name)
147
+ if isinstance(std, VThermLogger):
148
+ return std
149
+ vl = VThermLogger(name, std.level)
150
+ vl.parent = std.parent # type: ignore[assignment]
151
+ vl.propagate = std.propagate
152
+ vl.handlers = std.handlers
153
+ vl.disabled = std.disabled
154
+ manager.loggerDict[name] = vl
155
+ return vl
156
+
157
+
158
+ @dataclass(slots=True)
159
+ class VThermLogEntry:
160
+ """A single log entry stored in the ring buffer."""
161
+
162
+ timestamp: datetime
163
+ level: int
164
+ logger_name: str
165
+ message: str
166
+ thermostat_hint: str | None
167
+
168
+
169
+ class VThermLogHandler(logging.Handler):
170
+ """Custom logging handler that stores records in an in-memory ring buffer."""
171
+
172
+ def __init__(
173
+ self,
174
+ max_age_hours: int = DEFAULT_MAX_AGE_HOURS,
175
+ max_entries: int = DEFAULT_MAX_ENTRIES,
176
+ ):
177
+ super().__init__(level=logging.DEBUG)
178
+ self._max_age = timedelta(hours=max_age_hours)
179
+ self._buffer: deque[VThermLogEntry] = deque(maxlen=max_entries)
180
+ self._lock = threading.Lock()
181
+ self._insert_count = 0
182
+ # Register this handler as the collector for all VThermLogger instances
183
+ VThermLogger._collector = self
184
+
185
+ # --- Handler interface ---------------------------------------------------
186
+
187
+ def emit(self, record: logging.LogRecord) -> None:
188
+ """Store a log record in the buffer."""
189
+ try:
190
+ msg = self.format(
191
+ record) if self.formatter else record.getMessage()
192
+ entry = VThermLogEntry(
193
+ timestamp=datetime.fromtimestamp(
194
+ record.created, tz=timezone.utc),
195
+ level=record.levelno,
196
+ logger_name=record.name,
197
+ message=msg,
198
+ thermostat_hint=_extract_thermostat_hint(msg),
199
+ )
200
+ with self._lock:
201
+ self._buffer.append(entry)
202
+ self._insert_count += 1
203
+ if self._insert_count >= PURGE_EVERY_N:
204
+ self._purge_old_unlocked()
205
+ self._insert_count = 0
206
+ except Exception: # noqa: BLE001 – handler must never raise
207
+ self.handleError(record)
208
+
209
+ # --- Query interface -----------------------------------------------------
210
+
211
+ def get_entries(
212
+ self,
213
+ thermostat_name: str | None = None,
214
+ min_level: int = logging.DEBUG,
215
+ start: datetime | None = None,
216
+ end: datetime | None = None,
217
+ ) -> list[VThermLogEntry]:
218
+ """Return filtered entries from the buffer.
219
+
220
+ Args:
221
+ thermostat_name: If set, only entries whose thermostat_hint matches
222
+ (case-insensitive) OR entries without a hint are returned.
223
+ min_level: Minimum log level (inclusive).
224
+ start: Start of the time window (UTC aware datetime). Defaults to now - 60 min.
225
+ end: End of the time window (UTC aware datetime). Defaults to now.
226
+ """
227
+ now = datetime.now(tz=timezone.utc)
228
+ if end is None:
229
+ end = now
230
+ if start is None:
231
+ start = end - timedelta(minutes=60)
232
+
233
+ hint_lower = thermostat_name.lower() if thermostat_name else None
234
+
235
+ with self._lock:
236
+ entries = list(self._buffer)
237
+
238
+ result: list[VThermLogEntry] = []
239
+ for e in entries:
240
+ if e.timestamp < start or e.timestamp > end:
241
+ continue
242
+ if e.level < min_level:
243
+ continue
244
+ if hint_lower is not None:
245
+ if e.thermostat_hint is not None and not _hint_matches_thermostat(e.thermostat_hint.lower(), hint_lower):
246
+ continue
247
+ result.append(e)
248
+ return result
249
+
250
+ # --- Purge ---------------------------------------------------------------
251
+
252
+ def _purge_old_unlocked(self) -> None:
253
+ """Remove entries older than max_age. Must be called with lock held."""
254
+ cutoff = datetime.now(tz=timezone.utc) - self._max_age
255
+ while self._buffer and self._buffer[0].timestamp < cutoff:
256
+ self._buffer.popleft()
257
+
258
+ def purge(self) -> None:
259
+ """Public purge method."""
260
+ with self._lock:
261
+ self._purge_old_unlocked()
262
+
263
+ # --- Buffer info ---------------------------------------------------------
264
+
265
+ @property
266
+ def size(self) -> int:
267
+ """Return current number of entries."""
268
+ with self._lock:
269
+ return len(self._buffer)
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # HTTP Endpoint for log downloads
274
+ # ---------------------------------------------------------------------------
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # HTTP Endpoint for log downloads
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ async def async_register_log_download_endpoint(hass: HomeAssistant) -> None:
282
+ """Register the HTTP endpoint for log downloads.
283
+
284
+ This creates a custom view to serve log files via /api/versatile_thermostat/logs/<filename>
285
+ instead of using static /local/ paths, which ensures compatibility with reverse proxies
286
+ and external domain configurations.
287
+ """
288
+ from homeassistant.components.http import HomeAssistantView # pylint: disable=import-outside-toplevel
289
+
290
+ # Define the view class here to have proper access to hass
291
+ class LogDownloadView(HomeAssistantView):
292
+ """HTTP view for downloading log files."""
293
+
294
+ url = "/api/versatile_thermostat/logs/{filename}"
295
+ name = "api:versatile_thermostat:logs"
296
+ requires_auth = True
297
+
298
+ async def get(self, request: web.Request, filename: str) -> web.Response: # pylint: disable=unused-argument
299
+ """Serve a log file by name.
300
+
301
+ Args:
302
+ request: The HTTP request
303
+ filename: The log filename from the URL pattern
304
+
305
+ Returns:
306
+ HTTP response with file content or 404 if not found.
307
+ """
308
+ # Get hass from the request app context
309
+ hass = request.app["hass"]
310
+
311
+ # Validate filename to prevent path traversal attacks
312
+ # Only allow valid log filenames: vtherm_logs_<name>_<timestamp>.log
313
+ if not re.match(r"^vtherm_logs_[a-z0-9_]+_\d{8}_\d{6}\.log$", filename):
314
+ _LOGGER.warning(
315
+ "Attempted to download invalid log filename: %s", filename)
316
+ return web.Response(status=400, text="Invalid filename")
317
+
318
+ log_dir = Path(hass.config.path(LOG_OUTPUT_DIR))
319
+ filepath = log_dir / filename
320
+
321
+ _LOGGER.debug("Download request for log file: %s", filename)
322
+ _LOGGER.debug("Log directory: %s", log_dir)
323
+ _LOGGER.debug("Full filepath: %s", filepath)
324
+
325
+ # Double-check that file is within the log directory (security)
326
+ try:
327
+ filepath.resolve().relative_to(log_dir.resolve())
328
+ except ValueError:
329
+ _LOGGER.warning(
330
+ "Attempted to download file outside log directory: %s", filepath)
331
+ return web.Response(status=400, text="Invalid path")
332
+
333
+ if not filepath.exists():
334
+ _LOGGER.warning("Log file does not exist: %s", filepath)
335
+ _LOGGER.debug("Directory contents: %s", list(log_dir.glob(
336
+ "*.log")) if log_dir.exists() else "Directory doesn't exist")
337
+ return web.Response(status=404, text="Log file not found")
338
+
339
+ if not filepath.is_file():
340
+ _LOGGER.warning("Path exists but is not a file: %s", filepath)
341
+ return web.Response(status=404, text="Not a file")
342
+
343
+ try:
344
+ _LOGGER.debug("Reading log file: %s", filepath)
345
+ # Use FileResponse which handles streaming and async automatically
346
+ return web.FileResponse(
347
+ filepath,
348
+ headers={
349
+ "Content-Type": "text/plain; charset=utf-8",
350
+ "Content-Disposition": f'attachment; filename="{filename}"',
351
+ },
352
+ )
353
+ except FileNotFoundError:
354
+ _LOGGER.warning(
355
+ "Log file was deleted since existence check: %s", filepath)
356
+ return web.Response(status=404, text="Log file not found")
357
+ except OSError as err:
358
+ _LOGGER.error(
359
+ "OS error serving log file %s: %s", filepath, err)
360
+ return web.Response(status=500, text=f"OS error: {err}")
361
+ except Exception as err: # pylint: disable=broad-except
362
+ _LOGGER.error(
363
+ "Unexpected error serving log file %s: %s", filepath, err)
364
+ return web.Response(status=500, text=f"Error: {type(err).__name__}: {err}")
365
+
366
+ if hass.http is not None:
367
+ hass.http.register_view(LogDownloadView)
368
+ else:
369
+ _LOGGER.warning(
370
+ "HTTP server not available, log download endpoint not registered")
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # Export helpers
375
+ # ---------------------------------------------------------------------------
376
+
377
+ def _format_entry(entry: VThermLogEntry) -> str:
378
+ """Format a log entry as a single line."""
379
+ ts = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
380
+ level = logging.getLevelName(entry.level).ljust(7)
381
+ short_name = _short_logger_name(entry.logger_name).ljust(18)
382
+ return f"{ts} {level} [{short_name}] {entry.message}"
383
+
384
+
385
+ def _format_header(
386
+ thermostat_label: str,
387
+ start: datetime,
388
+ end: datetime,
389
+ level_name: str,
390
+ count: int,
391
+ config_entry: dict | None = None,
392
+ ) -> str:
393
+ """Build the header block of the export file."""
394
+ import json
395
+ from homeassistant.util import dt as dt_util # pylint: disable=import-outside-toplevel
396
+
397
+ sep = "=" * 80
398
+ now_str = dt_util.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
399
+ header = (
400
+ f"{sep}\n"
401
+ f"Versatile Thermostat - Log Export\n"
402
+ f"Thermostat : {thermostat_label}\n"
403
+ f"Period : {start.strftime('%Y-%m-%d %H:%M:%S')} → {end.strftime('%Y-%m-%d %H:%M:%S')} UTC\n"
404
+ f"Level : {level_name} and above\n"
405
+ f"Entries : {count}\n"
406
+ f"Generated : {now_str}\n"
407
+ )
408
+
409
+ if config_entry:
410
+ header += f"\nConfiguration:\n" f"{json.dumps(config_entry, indent=2, default=str)}\n"
411
+
412
+ header += f"{sep}\n"
413
+ return header
414
+
415
+
416
+ def _cleanup_old_files(directory: Path, max_age_hours: int = OLD_FILE_MAX_AGE_HOURS) -> None:
417
+ """Remove .log files older than *max_age_hours* in *directory*."""
418
+ if not directory.is_dir():
419
+ return
420
+ cutoff = datetime.now(tz=timezone.utc) - timedelta(hours=max_age_hours)
421
+ for f in directory.glob("vtherm_logs_*.log"):
422
+ try:
423
+ mtime = datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc)
424
+ if mtime < cutoff:
425
+ f.unlink()
426
+ except OSError:
427
+ pass
428
+
429
+
430
+ async def async_export_logs(
431
+ hass: HomeAssistant,
432
+ handler: VThermLogHandler,
433
+ thermostat_name: str | None = None,
434
+ entity_id: str | None = None,
435
+ log_level: str = "DEBUG",
436
+ period_start: datetime | str | None = None,
437
+ period_end: datetime | str | None = None,
438
+ config_entry: dict | None = None,
439
+ ) -> None:
440
+ """Filter logs, write export file, send persistent notification."""
441
+ from homeassistant.util import dt as dt_util # pylint: disable=import-outside-toplevel
442
+
443
+ min_level = LEVEL_MAP.get(log_level.upper(), logging.DEBUG)
444
+
445
+ # Convert to UTC-aware datetime
446
+ period_start = _parse_to_utc(period_start)
447
+ period_end = _parse_to_utc(period_end)
448
+
449
+ # Use dt_util for consistent time reference
450
+ now_utc = dt_util.utcnow()
451
+
452
+ # Apply defaults
453
+ eff_end = period_end or now_utc
454
+ eff_start = period_start or (eff_end - timedelta(minutes=60))
455
+
456
+ entries = handler.get_entries(
457
+ thermostat_name=thermostat_name,
458
+ min_level=min_level,
459
+ start=eff_start,
460
+ end=eff_end,
461
+ )
462
+
463
+ # Label for the header
464
+ if thermostat_name:
465
+ label = f"{thermostat_name} ({entity_id})" if entity_id else thermostat_name
466
+ else:
467
+ label = "All thermostats"
468
+
469
+ header = _format_header(label, eff_start, eff_end,
470
+ log_level.upper(), len(entries), config_entry)
471
+ body = "\n".join(_format_entry(e) for e in entries)
472
+ content = header + "\n" + body + "\n"
473
+
474
+ # Build output path
475
+ safe_name = re.sub(r"[^a-z0-9_]", "_", (thermostat_name or "all").lower())
476
+ ts_tag = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
477
+ filename = f"vtherm_logs_{safe_name}_{ts_tag}.log"
478
+
479
+ output_dir = Path(hass.config.path(LOG_OUTPUT_DIR))
480
+
481
+ def _write() -> Path:
482
+ output_dir.mkdir(parents=True, exist_ok=True)
483
+ filepath = output_dir / filename
484
+ filepath.write_text(content, encoding="utf-8")
485
+ _cleanup_old_files(output_dir)
486
+ return filepath
487
+
488
+ await hass.async_add_executor_job(_write)
489
+
490
+ # Send persistent notification with a signed download link.
491
+ # requires_auth=True on the view means a normal browser GET would get 401.
492
+ # async_sign_path generates a time-limited token in the query string so the
493
+ # link works without an Authorization header. We also build an absolute URL
494
+ # so the HA frontend SPA router does not intercept the click.
495
+ # We use hass.config.external_url / internal_url (user-configured) instead
496
+ # of get_url() which auto-detects and returns the Docker container IP.
497
+ raw_path = f"/api/versatile_thermostat/logs/{filename}"
498
+ try:
499
+ signed_path = async_sign_path(
500
+ hass,
501
+ raw_path,
502
+ timedelta(hours=OLD_FILE_MAX_AGE_HOURS),
503
+ )
504
+ except Exception: # noqa: BLE001
505
+ signed_path = raw_path
506
+ base_url = hass.config.external_url or hass.config.internal_url
507
+ if not base_url:
508
+ try:
509
+ base_url = get_url(hass)
510
+ except Exception: # noqa: BLE001
511
+ base_url = ""
512
+ # Strip trailing slash if present
513
+ base_url = base_url.rstrip("/") if base_url else ""
514
+ download_url = f"{base_url}{signed_path}"
515
+ notif_msg = (
516
+ f"**VTherm Log Export**\n\n"
517
+ f"Thermostat: {label}\n"
518
+ f"Period: {eff_start.strftime('%Y-%m-%d %H:%M')} → {eff_end.strftime('%Y-%m-%d %H:%M')} UTC\n"
519
+ f"Level: {log_level.upper()}\n"
520
+ f"Entries: {len(entries)}\n\n"
521
+ f"Copy/paste the link below into your browser to download the log file:\n\n"
522
+ f"{download_url}"
523
+ )
524
+
525
+ await hass.services.async_call(
526
+ "persistent_notification",
527
+ "create",
528
+ {
529
+ "title": "Versatile Thermostat - Logs Ready",
530
+ "message": notif_msg,
531
+ "notification_id": f"vtherm_logs_{safe_name}",
532
+ },
533
+ )