Coverage for custom_components/supernotify/media_grab.py: 95%

358 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 21:14 +0000

1from __future__ import annotations 

2 

3import asyncio 

4import datetime as dt 

5import hashlib 

6import io 

7import json 

8import logging 

9from enum import StrEnum, auto 

10from http import HTTPStatus 

11from io import BytesIO 

12from typing import TYPE_CHECKING, Any, cast 

13 

14import aiofiles 

15import aiofiles.os 

16import homeassistant.components.camera as ha_camera 

17import homeassistant.components.image as ha_image 

18import homeassistant.util.dt as dt_util 

19from aiohttp import ClientResponse, ClientSession, ClientTimeout 

20from anyio import Path 

21from homeassistant.const import STATE_HOME, STATE_UNAVAILABLE 

22from PIL import Image 

23 

24from custom_components.supernotify.const import ( 

25 ATTR_JPEG_OPTS, 

26 ATTR_MEDIA_CAMERA_DELAY, 

27 ATTR_MEDIA_CAMERA_ENTITY_ID, 

28 ATTR_MEDIA_CAMERA_PTZ_PRESET, 

29 ATTR_MEDIA_SNAPSHOT_PATH, 

30 ATTR_MEDIA_SNAPSHOT_URL, 

31 ATTR_PNG_OPTS, 

32 CONF_ALT_CAMERA, 

33 CONF_CAMERA, 

34 CONF_DEVICE_TRACKER, 

35 CONF_OPTIONS, 

36 CONF_PTZ_CAMERA, 

37 CONF_PTZ_DELAY, 

38 CONF_PTZ_METHOD, 

39 CONF_PTZ_PRESET_DEFAULT, 

40 CONF_SNAP_WAIT, 

41 PLATFORM_FRIGATE, 

42 PTZ_DELAY_DEFAULT, 

43 PTZ_METHOD_FRIGATE, 

44 PTZ_METHOD_ONVIF, 

45 SNAP_WAIT_DEFAULT, 

46) 

47from custom_components.supernotify.options import MEDIA_OPTION_REPROCESS, OPTION_JPEG, OPTION_PNG 

48 

49from .common import int_or_none 

50 

51if TYPE_CHECKING: 

52 from homeassistant.core import Context as HAContext 

53 from homeassistant.core import State 

54 

55 from .context import Context 

56 from .delivery import Delivery 

57 from .hass_api import HomeAssistantAPI 

58 from .notification import Notification 

59 

60_LOGGER = logging.getLogger(__name__) 

61 

62 

63class ReprocessOption(StrEnum): 

64 ALWAYS = auto() 

65 NEVER = auto() 

66 PRESERVE = auto() 

67 

68 

69async def snapshot_from_url( 

70 hass_api: HomeAssistantAPI, 

71 snapshot_url: str, 

72 notification_id: str, 

73 media_path: Path, 

74 hass_base_url: str | None, 

75 remote_timeout: int = 15, 

76) -> Path | None: 

77 """Download a snapshot URL and save raw bytes. No reprocessing.""" 

78 hass_base_url = hass_base_url or "" 

79 try: 

80 raw_dir: Path = Path(media_path) / "raw" 

81 await raw_dir.mkdir(parents=True, exist_ok=True) 

82 

83 image_url = snapshot_url if snapshot_url.startswith("http") else f"{hass_base_url}{snapshot_url}" 

84 websession: ClientSession = hass_api.http_session() 

85 r: ClientResponse = await websession.get(image_url, timeout=ClientTimeout(total=remote_timeout)) 

86 if r.status != HTTPStatus.OK: 

87 _LOGGER.warning("SUPERNOTIFY Unable to retrieve %s: %s", image_url, r.status) 

88 else: 

89 bitmap: bytes | None = await r.content.read() 

90 if bitmap: 

91 ext = await _detect_image_ext(hass_api, bitmap) 

92 raw_path: Path = raw_dir / f"{notification_id}.{ext}" 

93 async with aiofiles.open(raw_path, "wb") as f: 

94 await f.write(bitmap) 

95 _LOGGER.debug("SUPERNOTIFY Fetched raw image from %s to %s", image_url, raw_path) 

96 return raw_path 

97 

98 _LOGGER.warning("SUPERNOTIFY Failed to snap image from %s", snapshot_url) 

99 except Exception: 

100 _LOGGER.exception("SUPERNOTIFY Image snap fail") 

101 

102 return None 

103 

104 

105def infer_ptz_method(hass_api: HomeAssistantAPI, camera_entity_id: str) -> str: 

106 """Guess a PTZ method from the integration that owns the camera entity. 

107 

108 Used only as a fallback for cameras with no entry in the cameras: config, where 

109 there's no explicit ptz_method to consult. 

110 """ 

111 if hass_api.platform_for_entity(camera_entity_id) == PLATFORM_FRIGATE: 

112 return PTZ_METHOD_FRIGATE 

113 return PTZ_METHOD_ONVIF 

114 

115 

116async def move_camera_to_ptz_preset( 

117 hass_api: HomeAssistantAPI, 

118 camera_entity_id: str, 

119 preset: str | int, 

120 method: str = PTZ_METHOD_ONVIF, 

121 ha_context: HAContext | None = None, 

122) -> None: 

123 try: 

124 _LOGGER.info("SUPERNOTIFY Executing PTZ by %s to %s for %s", method, preset, camera_entity_id) 

125 if method == PTZ_METHOD_FRIGATE: 

126 await hass_api.call_service( 

127 "frigate", 

128 "ptz", 

129 service_data={"action": "preset", "argument": preset}, 

130 target={"entity_id": camera_entity_id}, 

131 return_response=False, 

132 blocking=True, 

133 context=ha_context, 

134 ) 

135 

136 elif method == PTZ_METHOD_ONVIF: 

137 await hass_api.call_service( 

138 "onvif", 

139 "ptz", 

140 service_data={"move_mode": "GotoPreset", "preset": preset}, 

141 target={"entity_id": camera_entity_id}, 

142 return_response=False, 

143 blocking=True, 

144 context=ha_context, 

145 ) 

146 else: 

147 _LOGGER.warning("SUPERNOTIFY Unknown PTZ method %s", method) 

148 except Exception as e: 

149 _LOGGER.warning("SUPERNOTIFY Unable to move %s to ptz preset %s: %s", camera_entity_id, preset, e) 

150 

151 

152async def snap_image_entity( 

153 hass_api: HomeAssistantAPI, entity_id: str, media_path: Path, notification_id: str, max_image_wait: int = 30 

154) -> Path | None: 

155 """Read an image entity and save raw bytes. No reprocessing.""" 

156 raw_path: Path | None = None 

157 try: 

158 image: ha_image.Image | None = await hass_api.async_get_image_entity_image(entity_id, timeout=max_image_wait) 

159 if image and image.content: 

160 raw_dir: Path = Path(media_path) / "raw" 

161 await raw_dir.mkdir(parents=True, exist_ok=True) 

162 ext: str = await _detect_image_ext(hass_api, image.content) 

163 raw_path = raw_dir / f"{notification_id}.{ext}" 

164 async with aiofiles.open(raw_path, "wb") as f: 

165 await f.write(image.content) 

166 except Exception as e: 

167 _LOGGER.warning("SUPERNOTIFY Unable to snap image %s: %s", entity_id, e) 

168 if raw_path is None: 

169 _LOGGER.warning("SUPERNOTIFY Unable to save from image entity %s", entity_id) 

170 return raw_path 

171 

172 

173async def snap_camera( 

174 hass_api: HomeAssistantAPI, 

175 camera_entity_id: str, 

176 notification_id: str, 

177 media_path: Path, 

178 max_camera_wait: int = 20, 

179) -> Path | None: 

180 """Snap a camera and save the raw image. No reprocessing. 

181 

182 Fetches the still directly from the camera entity via HA's own camera component API, 

183 rather than triggering the camera.snapshot service and polling the filesystem for the 

184 resulting file to appear. 

185 """ 

186 if not camera_entity_id: 

187 _LOGGER.warning("SUPERNOTIFY Empty camera entity id for snap") 

188 return None 

189 

190 raw_path: Path | None = None 

191 try: 

192 image: ha_camera.Image | None = await hass_api.async_get_camera_image(camera_entity_id, timeout=max_camera_wait) 

193 if image and image.content: 

194 raw_dir: Path = Path(media_path) / "raw" 

195 await raw_dir.mkdir(parents=True, exist_ok=True) 

196 ext: str = await _detect_image_ext(hass_api, image.content) 

197 raw_path = raw_dir / f"{notification_id}.{ext}" 

198 async with aiofiles.open(raw_path, "wb") as f: 

199 await f.write(image.content) 

200 except Exception as e: 

201 _LOGGER.warning("Failed to snap avail camera %s: %s", camera_entity_id, e) 

202 raw_path = None 

203 

204 return raw_path 

205 

206 

207def camera_available(hass_api: HomeAssistantAPI, camera_config: dict[str, Any], non_entity: bool = False) -> bool: 

208 state: State | None = None 

209 tracker_entity_id: str 

210 camera_entity_id: str = camera_config[CONF_CAMERA] 

211 try: 

212 if camera_config.get(CONF_DEVICE_TRACKER): 

213 tracker_entity_id = camera_config[CONF_DEVICE_TRACKER] 

214 state = hass_api.get_state(camera_config[CONF_DEVICE_TRACKER]) 

215 if state and state.state == STATE_HOME: 

216 return True 

217 _LOGGER.debug("SUPERNOTIFY Skipping camera %s tracker %s state %s", camera_entity_id, tracker_entity_id, state) 

218 else: 

219 tracker_entity_id = camera_entity_id 

220 state = hass_api.get_state(camera_entity_id) 

221 if state and state.state != STATE_UNAVAILABLE: 

222 return True 

223 if state is None and non_entity: 

224 return True 

225 _LOGGER.debug("SUPERNOTIFY Skipping camera %s with state %s", camera_entity_id, state) 

226 if state is None: 

227 if tracker_entity_id == camera_entity_id: 

228 _LOGGER.warning( 

229 "SUPERNOTIFY Camera %s tracker %s has no entity state", 

230 camera_entity_id, 

231 tracker_entity_id, 

232 ) 

233 else: 

234 _LOGGER.warning( 

235 "SUPERNOTIFY Camera %s device_tracker %s seems missing", 

236 camera_entity_id, 

237 camera_config[CONF_DEVICE_TRACKER], 

238 ) 

239 return False 

240 

241 except Exception: 

242 _LOGGER.exception("SUPERNOTIFY Unable to determine camera state: %s", camera_config) 

243 return False 

244 

245 

246def select_avail_camera( 

247 hass_api: HomeAssistantAPI, cameras: dict[str, Any], camera_entity_id: str, exclude_primary: bool = False 

248) -> str | None: 

249 """exclude_primary skips re-offering camera_entity_id itself, for a *configured* camera 

250 (one with a `cameras:` entry - an unconfigured entity has no alternative to weigh it 

251 against, so it's unaffected). A configured camera's own availability check is state-based 

252 (see camera_available()) and can't detect one that's live-disabled at the device without 

253 that showing up as entity state - so a caller that already knows, from an actual failed 

254 fetch rather than just this heuristic, that camera_entity_id isn't currently deliverable 

255 (e.g. mobile_push falling back after grab_image() found nothing) should set this, or it'll 

256 just be handed the same unusable camera again. 

257 """ 

258 avail_camera_entity_id: str | None = None 

259 

260 preferred_cam = cameras.get(camera_entity_id) 

261 # test support FIXME 

262 if preferred_cam and CONF_CAMERA not in preferred_cam: 

263 preferred_cam[CONF_CAMERA] = camera_entity_id 

264 if preferred_cam is None: 

265 # no alternatives for an unconfigured entity, whether camera or image, so it's just 

266 # a matter of whether it's known to be unavailable - assume it's fine if it has no state 

267 if camera_available(hass_api, {CONF_CAMERA: camera_entity_id}, non_entity=True): 

268 return camera_entity_id 

269 return None 

270 if not exclude_primary and camera_available(hass_api, preferred_cam): 

271 return camera_entity_id 

272 

273 alt_cams: list[dict[str, Any]] = [cameras[c] for c in preferred_cam.get(CONF_ALT_CAMERA, []) if c in cameras] 

274 alt_cams.extend( 

275 {CONF_CAMERA: entity_id} for entity_id in preferred_cam.get(CONF_ALT_CAMERA, []) if entity_id not in cameras 

276 ) 

277 for alt_cam in alt_cams: 

278 if camera_available(hass_api, alt_cam): 

279 _LOGGER.info("SUPERNOTIFY Selecting available camera %s rather than %s", alt_cam[CONF_CAMERA], camera_entity_id) 

280 return alt_cam[CONF_CAMERA] 

281 

282 if avail_camera_entity_id is None: 

283 _LOGGER.warning("SUPERNOTIFY %s not available, finding best alternative available", camera_entity_id) 

284 if not exclude_primary and camera_available(hass_api, preferred_cam, non_entity=True): 

285 _LOGGER.info("SUPERNOTIFY Selecting camera %s with no known entity", camera_entity_id) 

286 return camera_entity_id 

287 for alt_cam in alt_cams: 

288 if camera_available(hass_api, alt_cam, non_entity=True): 

289 _LOGGER.info( 

290 "SUPERNOTIFY Selecting alt camera %s with no known entity for %s", alt_cam[CONF_CAMERA], camera_entity_id 

291 ) 

292 return alt_cam[CONF_CAMERA] 

293 

294 return None 

295 

296 

297async def _detect_image_ext(hass_api: HomeAssistantAPI, bitmap: bytes) -> str: 

298 """Detect image format from raw bytes, returning a file extension.""" 

299 try: 

300 img = await hass_api.create_job(Image.open, io.BytesIO(bitmap)) 

301 fmt = (img.format or "").lower() 

302 return "jpg" if fmt in ("jpg", "jpeg") else fmt or "img" 

303 except Exception as e: 

304 _LOGGER.warning("SUPERNOTIFY unable to detect image, defaulting to 'img': %s", e) 

305 return "img" 

306 

307 

308async def snap_notification_image( 

309 notification: Notification, context: Context, ha_context: HAContext | None = None 

310) -> Path | None: 

311 """Delivery-neutral image acquisition: PTZ movement, camera snap, URL fetch, or image entity. 

312 

313 Caches the raw image path on notification._raw_image_path. Safe to call multiple times; 

314 subsequent calls return the cached path immediately. 

315 """ 

316 if getattr(notification, "_raw_image_path", None) is not None: 

317 return notification._raw_image_path # type: ignore[attr-defined] 

318 

319 if notification.media.get(ATTR_MEDIA_SNAPSHOT_PATH) is not None: 

320 return Path(notification.media[ATTR_MEDIA_SNAPSHOT_PATH]) 

321 

322 snapshot_url = notification.media.get(ATTR_MEDIA_SNAPSHOT_URL) 

323 camera_entity_id = cast("str", notification.media.get(ATTR_MEDIA_CAMERA_ENTITY_ID)) 

324 media_path: Path | None = context.media_storage.media_path 

325 

326 if not media_path or (not snapshot_url and not camera_entity_id): 

327 return None 

328 if not context.hass_api: 

329 return None 

330 

331 raw_path: Path | None = None 

332 if snapshot_url: 

333 raw_path = await snapshot_from_url( 

334 context.hass_api, snapshot_url, notification.id, media_path, context.hass_api.internal_url 

335 ) 

336 elif camera_entity_id.startswith("image."): 

337 raw_path = await snap_image_entity( 

338 context.hass_api, camera_entity_id, media_path, notification.id, max_image_wait=SNAP_WAIT_DEFAULT 

339 ) 

340 else: 

341 active_camera_entity_id = select_avail_camera(context.hass_api, context.cameras, camera_entity_id) 

342 if active_camera_entity_id: 

343 camera_config = context.cameras.get(active_camera_entity_id, {}) 

344 camera_ptz_entity_id: str = camera_config.get(CONF_PTZ_CAMERA, active_camera_entity_id) 

345 camera_delay: int | None = int_or_none(notification.media.get(ATTR_MEDIA_CAMERA_DELAY)) 

346 camera_ptz_preset_default = camera_config.get(CONF_PTZ_PRESET_DEFAULT) 

347 camera_ptz_preset = notification.media.get(ATTR_MEDIA_CAMERA_PTZ_PRESET) 

348 camera_ptz_method = camera_config.get(CONF_PTZ_METHOD) 

349 if camera_ptz_method is None: 

350 camera_ptz_method = infer_ptz_method(context.hass_api, camera_ptz_entity_id) 

351 

352 if camera_ptz_preset: 

353 camera_delay = ( 

354 camera_delay if camera_delay is not None else camera_config.get(CONF_PTZ_DELAY, PTZ_DELAY_DEFAULT) 

355 ) 

356 _LOGGER.debug( 

357 "SUPERNOTIFY Moving camera %s, ptz %s->%s (%s), delay %s secs", 

358 active_camera_entity_id, 

359 camera_ptz_preset, 

360 camera_ptz_preset_default, 

361 camera_ptz_entity_id, 

362 camera_delay, 

363 ) 

364 await move_camera_to_ptz_preset( 

365 context.hass_api, camera_ptz_entity_id, camera_ptz_preset, method=camera_ptz_method, ha_context=ha_context 

366 ) 

367 if camera_delay: 

368 # pause if there's a PTZ movement, or notification explicitly asked for `camera_delay` 

369 _LOGGER.debug("SUPERNOTIFY Waiting %s secs before snapping", camera_delay) 

370 await asyncio.sleep(camera_delay) 

371 

372 max_camera_wait: int = camera_config.get(CONF_SNAP_WAIT, SNAP_WAIT_DEFAULT) 

373 _LOGGER.debug( 

374 "SUPERNOTIFY Snapping camera %s, max_wait: %s, to: %s", active_camera_entity_id, max_camera_wait, media_path 

375 ) 

376 raw_path = await snap_camera( 

377 context.hass_api, 

378 active_camera_entity_id, 

379 notification.id, 

380 media_path=media_path, 

381 max_camera_wait=max_camera_wait, 

382 ) 

383 if camera_ptz_preset and camera_ptz_preset_default: 

384 await move_camera_to_ptz_preset( 

385 context.hass_api, 

386 camera_ptz_entity_id, 

387 camera_ptz_preset_default, 

388 method=camera_ptz_method, 

389 ha_context=ha_context, 

390 ) 

391 

392 if raw_path is None: 

393 _LOGGER.warning("SUPERNOTIFY No media available to attach (%s,%s)", snapshot_url, camera_entity_id) 

394 notification._raw_image_path = raw_path # type: ignore[attr-defined] 

395 return raw_path 

396 

397 

398async def grab_image( 

399 notification: Notification, delivery: Delivery, context: Context, ha_context: HAContext | None = None 

400) -> Path | None: 

401 """Get a delivery-ready image, reprocessing the raw snap with delivery-specific settings. 

402 

403 The raw snap is cached on the notification; reprocessed variants are cached by filename 

404 so multiple deliveries with the same settings share the processed file. 

405 

406 Filename convention: 

407 raw/{nid}.{ext} — delivery-neutral camera output 

408 image/{nid}.jpg — default reprocessing (ALWAYS, no extra opts) 

409 image/{nid}_{hashed_opts}}.jpg — delivery-specific opts or non-ALWAYS reprocess mode 

410 """ 

411 if notification.media.get(ATTR_MEDIA_SNAPSHOT_PATH) is not None: 

412 return Path(notification.media[ATTR_MEDIA_SNAPSHOT_PATH]) 

413 

414 raw_path = await snap_notification_image(notification, context, ha_context=ha_context) 

415 if raw_path is None: 

416 return None 

417 

418 media_path: Path | None = context.media_storage.media_path 

419 if not media_path or not context.hass_api: 

420 return None 

421 

422 delivery_config = notification.delivery_data(delivery) 

423 jpeg_opts = notification.media.get(ATTR_JPEG_OPTS, delivery_config.get(CONF_OPTIONS, {}).get(OPTION_JPEG)) 

424 png_opts = notification.media.get(ATTR_PNG_OPTS, delivery_config.get(CONF_OPTIONS, {}).get(OPTION_PNG)) 

425 reprocess_option = ( 

426 notification.media.get(MEDIA_OPTION_REPROCESS, delivery_config.get(CONF_OPTIONS, {}).get(MEDIA_OPTION_REPROCESS)) 

427 or "always" 

428 ) 

429 reprocess: ReprocessOption = ReprocessOption.ALWAYS 

430 try: 

431 reprocess = ReprocessOption(reprocess_option) 

432 except Exception: 

433 _LOGGER.warning("SUPERNOTIFY Invalid reprocess option: %s", reprocess_option) 

434 

435 if reprocess == ReprocessOption.NEVER: 

436 return raw_path 

437 

438 raw_ext = raw_path.suffix.lstrip(".").lower() 

439 # `jpeg_opts`/`png_opts` are None when not configured, and the reprocessed image keeps the 

440 # format of the original, since that is what `write_image_from_bitmap` saves 

441 relevant_opts: dict[str, Any] = ( 

442 (jpeg_opts or {}) if raw_ext in ("jpg", "jpeg") else (png_opts or {}) if raw_ext == "png" else {} 

443 ) 

444 processed_ext = raw_ext or "jpg" 

445 is_default = not relevant_opts and reprocess == ReprocessOption.ALWAYS 

446 if is_default: 

447 processed_name = f"{notification.id}.{processed_ext}" 

448 else: 

449 # a stable digest, unlike hash(), which is salted per process and so never matched an 

450 # image reprocessed before the last restart 

451 key = hashlib.sha1( # not security, just a cache key 

452 json.dumps([reprocess_option, relevant_opts], sort_keys=True, default=str).encode(), usedforsecurity=False 

453 ).hexdigest()[:12] 

454 processed_name = f"{notification.id}_{key}.{processed_ext}" 

455 processed_path = Path(media_path) / "image" / processed_name 

456 

457 if await processed_path.exists(): 

458 return await processed_path.resolve() 

459 

460 try: 

461 async with await raw_path.open("rb") as f: 

462 bitmap: bytes = await f.read() 

463 except OSError as e: 

464 _LOGGER.warning("SUPERNOTIFY Unable to read raw image %s: %s", raw_path, e) 

465 return None 

466 return await write_image_from_bitmap( 

467 context.hass_api, bitmap, processed_path, reprocess=reprocess, jpeg_opts=jpeg_opts, png_opts=png_opts 

468 ) 

469 

470 

471def _reprocess_bitmap( 

472 bitmap: bytes, 

473 reprocess: ReprocessOption, 

474 output_format: str | None = None, 

475 jpeg_opts: dict[str, Any] | None = None, 

476 png_opts: dict[str, Any] | None = None, 

477) -> tuple[str, bytes]: 

478 """Decode, optionally rewrite and re-encode a bitmap, returning its format and the bytes 

479 

480 Blocking from end to end, so it is run in an executor job rather than on the event loop. 

481 """ 

482 image: Image.Image = Image.open(io.BytesIO(bitmap)) 

483 input_format: str = image.format.lower() if image.format else "img" 

484 if reprocess == ReprocessOption.ALWAYS: 

485 # rewrite to remove metadata, incl custom CCTV comments that confuse python MIMEImage 

486 clean_image: Image.Image = Image.new(image.mode, image.size) 

487 # Pillow API changed in 12.1.0 and the original call will be removed in 2027 

488 # https://pillow.readthedocs.io/en/stable/releasenotes/12.1.0.html#image-getdata 

489 if hasattr(image, "get_flattened_data"): 

490 clean_image.putdata(image.get_flattened_data()) # added in jan 2026 # ty:ignore[call-non-callable] 

491 else: 

492 clean_image.putdata(image.getdata()) # being removed in 2027 

493 

494 image = clean_image 

495 

496 img_args: dict[str, Any] = {} 

497 if reprocess in (ReprocessOption.ALWAYS, ReprocessOption.PRESERVE): 

498 if input_format in ("jpg", "jpeg") and jpeg_opts: 

499 img_args.update(jpeg_opts) 

500 elif input_format == "png" and png_opts: 

501 img_args.update(png_opts) 

502 

503 buffer = BytesIO() 

504 image.save(buffer, output_format or input_format, **img_args) 

505 return input_format, buffer.getvalue() 

506 

507 

508async def write_image_from_bitmap( 

509 hass_api: HomeAssistantAPI, 

510 bitmap: bytes | None, 

511 output_path: Path, 

512 reprocess: ReprocessOption = ReprocessOption.ALWAYS, 

513 output_format: str | None = None, 

514 jpeg_opts: dict[str, Any] | None = None, 

515 png_opts: dict[str, Any] | None = None, 

516) -> Path | None: 

517 """Reprocess a raw image bitmap and write to an explicit output path.""" 

518 if bitmap is None: 

519 _LOGGER.debug("SUPERNOTIFY Empty bitmap for image") 

520 return None 

521 input_format: str = "img" 

522 try: 

523 await output_path.parent.mkdir(parents=True, exist_ok=True) 

524 

525 # every Pillow call in one job: decoding, copying the pixels and encoding are all 

526 # blocking, and only `Image.open` and `Image.new` used to be kept off the event loop 

527 input_format, encoded = await hass_api.create_job( 

528 _reprocess_bitmap, bitmap, reprocess, output_format, jpeg_opts, png_opts 

529 ) 

530 buffer = BytesIO(encoded) 

531 

532 output_path = await output_path.resolve() 

533 async with aiofiles.open(output_path, "wb") as file: 

534 await file.write(buffer.getbuffer()) 

535 return output_path 

536 except TypeError: 

537 # probably a jpeg or png option 

538 _LOGGER.exception("SUPERNOTIFY Image snap fail") 

539 except Exception: 

540 _LOGGER.exception("SUPERNOTIFY Failure saving %s bitmap", input_format) 

541 return None 

542 

543 

544class MediaStorage: 

545 def __init__( 

546 self, 

547 media_path: str | None, 

548 media_url_prefix: str | None = None, 

549 days: int = 7, 

550 ) -> None: 

551 self.media_path: Path | None = Path(media_path) if media_path else None 

552 self.last_purge: dt.datetime | None = None 

553 self.media_url_prefix = media_url_prefix 

554 self.purge_minute_interval = 60 * 6 

555 self.days = days 

556 

557 async def initialize(self, hass_api: HomeAssistantAPI) -> None: 

558 self.hass_api = hass_api # TODO: should not be set on initialize 

559 if self.media_path is not None and not self.media_path.is_absolute(): 

560 self.media_path = await self.media_path.absolute() 

561 _LOGGER.debug("SUPERNOTIFY Media path updated to %s", self.media_path) 

562 if self.media_path and not await self.media_path.exists(): 

563 _LOGGER.info("SUPERNOTIFY Media path not found at %s, attempting to create.", self.media_path) 

564 try: 

565 await self.media_path.mkdir(parents=True, exist_ok=True) 

566 except Exception as e: 

567 _LOGGER.warning("SUPERNOTIFY Media path %s cannot be created: %s", self.media_path, e) 

568 hass_api.raise_issue( 

569 "media_path", 

570 "media_path", 

571 {"path": str(self.media_path), "error": str(e)}, 

572 learn_more_url="https://supernotify.rhizomatics.org.uk/getting_started/", 

573 ) 

574 self.media_path = None 

575 if self.media_path is not None: 

576 _LOGGER.debug("SUPERNOTIFY Abs media path: %s", await self.media_path.absolute()) 

577 

578 if self.media_url_prefix is not None and self.media_path is not None: 

579 if await hass_api.register_web_path(self.media_path, self.media_url_prefix): 

580 _LOGGER.info("SUPERNOTIFY Media at %s available with prefixed URL %s", self.media_path, self.media_url_prefix) 

581 else: 

582 self.media_url_prefix = None 

583 

584 async def object_url(self, relative_path: Path) -> str | None: 

585 """Convert a local image path to an externally accessible URL via the registered static path.""" 

586 if self.media_url_prefix is None or self.media_path is None: 

587 _LOGGER.debug("SUPERNOTIFY Unable to generate object_url for %s", relative_path) 

588 return None 

589 try: 

590 relative = relative_path.relative_to(await self.media_path.absolute()) 

591 return self.hass_api.abs_url(f"{self.media_url_prefix}/{relative}") 

592 except ValueError as e: 

593 _LOGGER.warning("SUPERNOTIFY Invalid media path for URL %s: %s", relative_path, e) 

594 return None 

595 

596 async def share_path(self, artefact_path: Path) -> str | None: 

597 """Return the HA media-share path for a local file (e.g. '/media/images/raw/foo.jpg'). 

598 

599 Used by mobile push: the companion app resolves share-rooted paths against the HA base URL. 

600 Returns None when media_path is unconfigured or path is outside the media tree. 

601 """ 

602 if self.media_path is None or self.media_url_prefix is None: 

603 return None 

604 try: 

605 if artefact_path.is_absolute(): 

606 relative = artefact_path.relative_to(await self.media_path.absolute()) 

607 else: 

608 relative = artefact_path 

609 return str(Path(self.media_url_prefix) / relative) 

610 except ValueError as e: 

611 _LOGGER.debug("SUPERNOTIFY Failure creating shared media path for %s:%s", artefact_path, e) 

612 return None 

613 

614 async def size(self) -> int: 

615 path: Path | None = self.media_path 

616 if path and await path.exists(): 

617 return sum(1 for p in await aiofiles.os.listdir(path)) 

618 return 0 

619 

620 async def cleanup(self, days: int | None = None, force: bool = False) -> int: 

621 if ( 

622 not force 

623 and self.last_purge is not None 

624 and self.last_purge > dt.datetime.now(dt.UTC) - dt.timedelta(minutes=self.purge_minute_interval) 

625 ): 

626 _LOGGER.debug( 

627 "SUPERNOTIFY Media storage cleanup skipped, force: %s, last purge: %s, purge interval: %s", 

628 force, 

629 self.last_purge, 

630 self.purge_minute_interval, 

631 ) 

632 return 0 

633 days = days or self.days 

634 if days == 0 or self.media_path is None: 

635 _LOGGER.debug("SUPERNOTIFY Media storage cleanup skipped, %s days, %s", days, self.media_path) 

636 return 0 

637 

638 cutoff = dt.datetime.now(dt.UTC) - dt.timedelta(days=days) 

639 cutoff = cutoff.astimezone(dt.UTC) 

640 purged: int = 0 

641 skipped: int = 0 

642 if self.media_path and await self.media_path.exists(): 

643 try: 

644 queue: list[Path] = [self.media_path] 

645 while queue: 

646 current = queue.pop() 

647 for entry in await aiofiles.os.scandir(current): 

648 if entry.is_dir(): 

649 queue.append(Path(entry.path)) 

650 elif entry.is_file() and dt_util.utc_from_timestamp(entry.stat().st_mtime) <= cutoff: 

651 _LOGGER.debug("SUPERNOTIFY Purging %s", entry.path) 

652 await aiofiles.os.unlink(Path(entry.path)) 

653 purged += 1 

654 else: 

655 skipped += 1 

656 except Exception as e: 

657 _LOGGER.warning("SUPERNOTIFY Unable to clean up media storage at %s: %s", self.media_path, e, exc_info=True) 

658 _LOGGER.info("SUPERNOTIFY Purged %s media storage for cutoff %s, skipped %s", purged, cutoff, skipped) 

659 self.last_purge = dt.datetime.now(dt.UTC) 

660 else: 

661 _LOGGER.warning("SUPERNOTIFY Skipping media storage cleanup for unknown path %s", self.media_path) 

662 return purged