"""Live Warsaw-temperature background check mixin for ``StatusWindow``. Fetches the same reading the real locker's heat-skip check would see (``_temperature.fetch_current_temp_with_status``), in a background thread, bounded to ``_temperature.HARD_TIMEOUT_SECONDS`` — display-only, never writes to ``log.json``. Host class must provide: - ``self.root`` — the Tk root, for ``root.after`` polling. - ``self.temperature_fetcher`` — injected fetch callable (tests override it). - ``self._temp_future`` / ``self._temp_result`` — state slots, initialized by the host's ``__init__``. - ``self._last_snapshot`` — the most recently rendered ``StatusSnapshot``. - ``self.render(snapshot)`` — full redraw, called after a result resolves. """ from __future__ import annotations import logging from typing import TYPE_CHECKING from screen_locker import _glyphs as g from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD from screen_locker._temperature import HARD_TIMEOUT_SECONDS, submit_background if TYPE_CHECKING: import tkinter as tk from screen_locker._temperature import TemperatureCheck _logger = logging.getLogger(__name__) class TemperatureStatusMixin: """Provides the background Warsaw-temperature fetch/poll/render.""" def _section_temperature(self, parent: tk.Widget) -> None: """Render the live temperature against the heat-skip threshold. The same reading the real locker's heat-skip check would see, fetched in the background. textgate: was "Warsaw Temperature" and a sentence per state; now ``▲`` with the reading (or wait / clock-fail / fail glyphs), danger-coloured with ``↷`` when it would heat-skip. """ del parent result = self._temp_result palette = self._colors.palette if result is None: self._text(f"{g.HEAT} {g.WAIT}", role="body", color=palette.muted) return if result.timed_out: _logger.warning( "Temperature check timed out after ~%ds", int(HARD_TIMEOUT_SECONDS) ) self._text( f"{g.HEAT} {g.CLOCK}{g.FAIL}", role="body", color=palette.warning ) return if result.temp_celsius is None: self._text(f"{g.HEAT} {g.FAIL}", role="body", color=palette.warning) return hot = result.temp_celsius >= HEAT_SKIP_TEMP_THRESHOLD relation = "≥" if hot else "<" self._text( f"{g.HEAT} {result.temp_celsius:.0f}°C {relation} " f"{HEAT_SKIP_TEMP_THRESHOLD}°C" + (f" {g.SKIP}" if hot else ""), role="body", color=palette.danger if hot else palette.muted, ) def _start_temperature_check(self) -> None: """Submit a background Warsaw-temperature fetch and start polling it. Unlike ``_on_check_phone_clicked`` (an explicit, opt-in user action), this runs on every window open and refresh, so the first poll is always deferred through ``root.after`` rather than checked synchronously — otherwise a worker thread that happens to finish before this call returns would trigger a second, redundant render from within the same call stack that hasn't finished the first one. """ # Shares _temperature.submit_background rather than spinning up its # own ThreadPoolExecutor per open and per refresh: those workers are # non-daemon and joined at interpreter exit, so one stalled fetch # held the whole process open past sys.exit(0). self._temp_future = submit_background( lambda: self.temperature_fetcher(HEAT_SKIP_CITY) ) self.root.after(500, self._poll_temperature_check) def _poll_temperature_check(self) -> None: """Poll the background temperature-check future until it resolves.""" if self._temp_future is not None and self._temp_future.done(): self._on_temperature_check_result(self._temp_future.result()) else: self.root.after(500, self._poll_temperature_check) def _on_temperature_check_result(self, result: TemperatureCheck) -> None: """Store the resolved temperature check and redraw to show it. Re-renders the last snapshot already on screen rather than re-gathering from disk — the temperature reading is independent of on-disk workout state, so there's nothing new to read. """ self._temp_result = result self.render(self._last_snapshot)