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 +7 -0
- vtherm_api/commons_type.py +4 -0
- vtherm_api/const.py +46 -0
- vtherm_api/interfaces.py +83 -0
- vtherm_api/log_collector.py +533 -0
- vtherm_api/plugin_climate.py +154 -0
- vtherm_api/vtherm_api.py +115 -0
- vtherm_api-0.1.0.dist-info/METADATA +575 -0
- vtherm_api-0.1.0.dist-info/RECORD +12 -0
- vtherm_api-0.1.0.dist-info/WHEEL +5 -0
- vtherm_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- vtherm_api-0.1.0.dist-info/top_level.txt +1 -0
vtherm_api/__init__.py
ADDED
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))
|
vtherm_api/interfaces.py
ADDED
|
@@ -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
|
+
)
|