初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,5 @@
"""屏幕供电监测组件。"""
from app.power.voltage import VoltageMonitor
__all__ = ["VoltageMonitor"]
@@ -0,0 +1,207 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from decimal import Decimal, ROUND_FLOOR
LOW_VOLTAGE_CRITICAL_THRESHOLD = 4.5
LOW_VOLTAGE_LIMIT_THRESHOLD = 4.8
LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT = 35
PROTECTION_MODE_DISABLED = "disabled"
PROTECTION_MODE_UNAVAILABLE = "unavailable"
PROTECTION_MODE_INACTIVE = "inactive"
PROTECTION_MODE_LIMITING = "limiting"
PROTECTION_MODE_CRITICAL = "critical"
NORMAL_SAMPLING_INTERVAL_SECONDS = 5.0
LIMITING_SAMPLING_INTERVAL_SECONDS = 1.0
FIRST_CRITICAL_SAMPLING_INTERVAL_SECONDS = 0.5
MIN_CRITICAL_SAMPLING_INTERVAL_SECONDS = 0.5
MAX_CRITICAL_SAMPLING_INTERVAL_SECONDS = 2.0
MAX_CRITICAL_SLOWDOWN_STEP_SECONDS = 0.5
ERROR_RETRY_INTERVALS_SECONDS = (1.0, 2.0, 5.0)
@dataclass(frozen=True)
class LowVoltageProtectionDirective:
sequence: int
enabled: bool
mode: str
brightness_limit_percent: int | None
reading_stale: bool
def brightness_limit_for_voltage(volts: float) -> int | None:
"""Return the output cap for a calibrated, unrounded voltage reading."""
voltage = float(volts)
if not math.isfinite(voltage):
raise ValueError("voltage must be finite")
if voltage > LOW_VOLTAGE_LIMIT_THRESHOLD:
return None
if voltage < LOW_VOLTAGE_CRITICAL_THRESHOLD:
return LOW_VOLTAGE_CRITICAL_BRIGHTNESS_PERCENT
if voltage == LOW_VOLTAGE_LIMIT_THRESHOLD:
return 100
if voltage == LOW_VOLTAGE_CRITICAL_THRESHOLD:
return 50
decimal_voltage = Decimal(str(voltage))
limit = int(
(
Decimal(50)
+ Decimal(50)
* (decimal_voltage - Decimal("4.5"))
/ Decimal("0.3")
).to_integral_value(rounding=ROUND_FLOOR)
)
return max(50, min(100, limit))
def protection_directive_for_voltage(
sequence: int,
enabled: bool,
volts: float | None,
*,
reading_stale: bool = False,
) -> LowVoltageProtectionDirective:
if not enabled:
return LowVoltageProtectionDirective(
sequence=sequence,
enabled=False,
mode=PROTECTION_MODE_DISABLED,
brightness_limit_percent=None,
reading_stale=False,
)
if volts is None:
return LowVoltageProtectionDirective(
sequence=sequence,
enabled=True,
mode=PROTECTION_MODE_UNAVAILABLE,
brightness_limit_percent=None,
reading_stale=reading_stale,
)
limit = brightness_limit_for_voltage(volts)
if volts > LOW_VOLTAGE_LIMIT_THRESHOLD:
mode = PROTECTION_MODE_INACTIVE
limit = None
elif volts < LOW_VOLTAGE_CRITICAL_THRESHOLD:
mode = PROTECTION_MODE_CRITICAL
else:
mode = PROTECTION_MODE_LIMITING
return LowVoltageProtectionDirective(
sequence=sequence,
enabled=True,
mode=mode,
brightness_limit_percent=limit,
reading_stale=reading_stale,
)
def same_protection_level(
left: LowVoltageProtectionDirective,
right: LowVoltageProtectionDirective,
) -> bool:
return (
left.enabled == right.enabled
and left.mode == right.mode
and left.brightness_limit_percent == right.brightness_limit_percent
)
def is_more_restrictive(
candidate: LowVoltageProtectionDirective,
current: LowVoltageProtectionDirective,
) -> bool:
ranks = {
PROTECTION_MODE_DISABLED: 0,
PROTECTION_MODE_UNAVAILABLE: 0,
PROTECTION_MODE_INACTIVE: 0,
PROTECTION_MODE_LIMITING: 1,
PROTECTION_MODE_CRITICAL: 2,
}
candidate_rank = ranks[candidate.mode]
current_rank = ranks[current.mode]
if candidate_rank != current_rank:
return candidate_rank > current_rank
if candidate.mode == PROTECTION_MODE_LIMITING:
assert candidate.brightness_limit_percent is not None
assert current.brightness_limit_percent is not None
return candidate.brightness_limit_percent < current.brightness_limit_percent
return False
def more_conservative(
left: LowVoltageProtectionDirective,
right: LowVoltageProtectionDirective,
) -> LowVoltageProtectionDirective:
"""Choose the stricter directive while retaining the newest sequence on a tie."""
if is_more_restrictive(left, right):
return left
if is_more_restrictive(right, left):
return right
return right if right.sequence >= left.sequence else left
def error_retry_interval(consecutive_errors: int) -> float:
if consecutive_errors <= 0:
return NORMAL_SAMPLING_INTERVAL_SECONDS
index = min(consecutive_errors, len(ERROR_RETRY_INTERVALS_SECONDS)) - 1
return ERROR_RETRY_INTERVALS_SECONDS[index]
def successful_sampling_interval(
*,
enabled: bool,
mode: str,
volts: float,
previous_volts: float | None,
elapsed_seconds: float | None,
previous_interval_seconds: float,
first_critical_sample: bool,
) -> float:
"""Return the next group-start interval for a successful voltage sample."""
if not enabled:
return NORMAL_SAMPLING_INTERVAL_SECONDS
if mode == PROTECTION_MODE_LIMITING:
return LIMITING_SAMPLING_INTERVAL_SECONDS
if mode == PROTECTION_MODE_CRITICAL:
if first_critical_sample:
return FIRST_CRITICAL_SAMPLING_INTERVAL_SECONDS
rate = _voltage_rate(volts, previous_volts, elapsed_seconds)
target = _clamp(
MIN_CRITICAL_SAMPLING_INTERVAL_SECONDS,
MAX_CRITICAL_SAMPLING_INTERVAL_SECONDS,
0.02 / max(abs(rate), 0.01),
)
if target > previous_interval_seconds:
return min(
target,
previous_interval_seconds + MAX_CRITICAL_SLOWDOWN_STEP_SECONDS,
)
return target
if mode == PROTECTION_MODE_INACTIVE:
rate = _voltage_rate(volts, previous_volts, elapsed_seconds)
if rate < 0:
seconds_to_limit = (volts - LOW_VOLTAGE_LIMIT_THRESHOLD) / -rate
return _clamp(1.0, NORMAL_SAMPLING_INTERVAL_SECONDS, seconds_to_limit)
return NORMAL_SAMPLING_INTERVAL_SECONDS
def _voltage_rate(
volts: float,
previous_volts: float | None,
elapsed_seconds: float | None,
) -> float:
if (
previous_volts is None
or elapsed_seconds is None
or not math.isfinite(elapsed_seconds)
or elapsed_seconds <= 0
):
return 0.0
return (volts - previous_volts) / elapsed_seconds
def _clamp(minimum: float, maximum: float, value: float) -> float:
return max(minimum, min(maximum, value))
@@ -0,0 +1,754 @@
from __future__ import annotations
import errno
import copy
import logging
import math
import os
import statistics
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Protocol
from uuid import uuid4
from app.config.store import ConfigStore
from app.power.protection import (
NORMAL_SAMPLING_INTERVAL_SECONDS,
LowVoltageProtectionDirective,
error_retry_interval,
is_more_restrictive,
more_conservative,
protection_directive_for_voltage,
same_protection_level,
successful_sampling_interval,
)
logger = logging.getLogger(__name__)
DEFAULT_BUS = "/dev/i2c-1"
DEFAULT_ADDRESS = 0x48
I2C_SLAVE = 0x0703
CONFIG_CONTINUOUS_15SPS_PGA1 = 0x0C
CONFIG_VALUE_MASK = 0x7F
DATA_READY_MASK = 0x80
REFERENCE_VOLTS = 2.048
ADC_CODE_SCALE = 32768
BOARD_DIVIDER_RATIO = 6.1
CONVERSION_WAIT_SECONDS = (1.0 / 15.0) + 0.010
FRESH_DATA_TIMEOUT_SECONDS = 0.75
NORMAL_SAMPLE_COUNT = 5
CALIBRATION_SAMPLE_COUNT = 15
POLL_INTERVAL_SECONDS = 5.0
CALIBRATION_PROPOSAL_SECONDS = 120
CALIBRATION_REFERENCE_MIN = 4.5
CALIBRATION_REFERENCE_MAX = 5.5
CALIBRATION_FACTOR_MIN = 0.8
CALIBRATION_FACTOR_MAX = 1.2
CALIBRATION_MAX_SPREAD_VOLTS = 0.05
PROTECTION_ENFORCEMENT_ERROR_CODE = "display_enforcement_failed"
class AdcError(RuntimeError):
code = "adc_error"
class AdcDisconnectedError(AdcError):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
class AdcProtocolError(AdcError):
code = "protocol_error"
class VoltageMonitorError(RuntimeError):
pass
class CalibrationError(VoltageMonitorError):
pass
class CalibrationUnavailableError(CalibrationError):
pass
class CalibrationConflictError(CalibrationError):
pass
class AdcDevice(Protocol):
def __enter__(self) -> "AdcDevice": ...
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: ...
def configure(self) -> None: ...
def read_fresh(self) -> tuple[int, int]: ...
@dataclass(frozen=True)
class SampleGroup:
raw_median: float
uncalibrated_volts: float
config: int
spread_volts: float
@dataclass(frozen=True)
class CalibrationProposal:
proposal_id: str
reference_volts: float
uncalibrated_volts: float
current_factor: float
proposed_factor: float
created_at: datetime
expires_at: datetime
def response(self) -> dict[str, Any]:
return {
"proposal_id": self.proposal_id,
"created_at": isoformat_utc(self.created_at),
"expires_at": isoformat_utc(self.expires_at),
"reference_volts": self.reference_volts,
"uncalibrated_volts": self.uncalibrated_volts,
"current_factor": self.current_factor,
"proposed_factor": self.proposed_factor,
"projected_volts": self.uncalibrated_volts * self.proposed_factor,
}
def isoformat_utc(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def voltage_from_raw(raw: int | float, calibration_factor: float = 1.0) -> float:
if not -32768 <= raw <= 32767:
raise ValueError("ADS1110 raw 必须在 -32768 到 32767 之间")
if not math.isfinite(calibration_factor) or calibration_factor <= 0:
raise ValueError("校准系数必须是正有限数")
return float(raw) * REFERENCE_VOLTS / ADC_CODE_SCALE * BOARD_DIVIDER_RATIO * calibration_factor
def validate_conversion(data: bytes) -> tuple[int, int]:
if len(data) != 3:
raise AdcProtocolError(f"ADS1110 应返回 3 字节,实际收到 {len(data)} 字节")
raw = int.from_bytes(data[:2], byteorder="big", signed=True)
config = data[2]
if (config & CONFIG_VALUE_MASK) != CONFIG_CONTINUOUS_15SPS_PGA1:
raise AdcProtocolError(
f"ADS1110 配置不符:收到 0x{config:02X},低 7 位应为 0x0C"
)
return raw, config
def disconnected_from_os_error(action: str, exc: OSError) -> AdcDisconnectedError:
remote_io = getattr(errno, "EREMOTEIO", 121)
if exc.errno in (remote_io, errno.ENXIO):
return AdcDisconnectedError("address_no_response", f"{action}:I2C 地址 0x48 没有响应")
if exc.errno == errno.EBUSY:
return AdcDisconnectedError("device_busy", f"{action}:I2C 地址 0x48 已被占用")
if exc.errno in (errno.EACCES, errno.EPERM):
return AdcDisconnectedError("permission_denied", f"{action}:没有 I2C 访问权限")
return AdcDisconnectedError("io_error", f"{action}:{exc.strerror or exc}")
class Ads1110Device:
def __init__(self, bus_path: str = DEFAULT_BUS, address: int = DEFAULT_ADDRESS) -> None:
self.bus_path = bus_path
self.address = address
self._fd: int | None = None
def __enter__(self) -> "Ads1110Device":
if os.name != "posix":
raise AdcDisconnectedError("unsupported_platform", "真实 I2C 读取只支持 Linux")
try:
import fcntl
except ImportError as exc:
raise AdcDisconnectedError("unsupported_platform", "当前 Python 缺少 Linux fcntl") from exc
try:
self._fd = os.open(self.bus_path, os.O_RDWR)
except FileNotFoundError as exc:
raise AdcDisconnectedError("bus_missing", f"没有找到 {self.bus_path}") from exc
except PermissionError as exc:
raise AdcDisconnectedError("permission_denied", f"没有权限打开 {self.bus_path}") from exc
except OSError as exc:
raise disconnected_from_os_error(f"打开 {self.bus_path} 失败", exc) from exc
try:
fcntl.ioctl(self._fd, I2C_SLAVE, self.address)
except OSError as exc:
self.close()
raise disconnected_from_os_error("选择 I2C 从设备失败", exc) from exc
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
self.close()
def close(self) -> None:
if self._fd is not None:
os.close(self._fd)
self._fd = None
def _require_fd(self) -> int:
if self._fd is None:
raise AdcDisconnectedError("device_closed", "I2C 设备尚未打开")
return self._fd
def configure(self) -> None:
try:
written = os.write(self._require_fd(), bytes([CONFIG_CONTINUOUS_15SPS_PGA1]))
except OSError as exc:
raise disconnected_from_os_error("写入 ADS1110 配置失败", exc) from exc
if written != 1:
raise AdcProtocolError(f"ADS1110 配置应写入 1 字节,实际写入 {written} 字节")
def read_fresh(self) -> tuple[int, int]:
deadline = time.monotonic() + FRESH_DATA_TIMEOUT_SECONDS
while True:
time.sleep(CONVERSION_WAIT_SECONDS)
try:
data = os.read(self._require_fd(), 3)
except OSError as exc:
raise disconnected_from_os_error("读取 ADS1110 失败", exc) from exc
raw, config = validate_conversion(data)
if (config & DATA_READY_MASK) == 0:
return raw, config
if time.monotonic() >= deadline:
raise AdcProtocolError("等待 ADS1110 新转换数据超时")
class VoltageMonitor:
def __init__(
self,
config_store: ConfigStore,
device_factory: Callable[[], AdcDevice] | None = None,
poll_interval: float | None = None,
) -> None:
if poll_interval is not None and (
isinstance(poll_interval, bool)
or not math.isfinite(float(poll_interval))
or float(poll_interval) <= 0
):
raise ValueError("poll_interval must be a positive finite number")
self.config_store = config_store
self._device_factory = device_factory or Ads1110Device
self._fixed_poll_interval = (
None if poll_interval is None else float(poll_interval)
)
self._state_lock = threading.RLock()
self._io_lock = threading.Lock()
self._listener_lock = threading.Lock()
self._stop = threading.Event()
self._reschedule = threading.Event()
self._thread: threading.Thread | None = None
self._proposal: CalibrationProposal | None = None
self._sequence = 0
self._last_published_sequence = 0
self._snapshot: dict[str, Any] = {
"status": "starting",
"volts": None,
"sampled_at": None,
"calibrated": self._is_calibrated(),
"error_code": None,
}
enabled = self.config_store.config["low_voltage_protection_enabled"]
self._protection = protection_directive_for_voltage(0, enabled, None)
self._protection_revision = 0
self._protection_listener: (
Callable[[LowVoltageProtectionDirective], None] | None
) = None
self._enforced_sequence = -1
self._enforcement_error_code: str | None = None
self._has_successful_protection_sample = False
self._relaxation_candidate: LowVoltageProtectionDirective | None = None
self._relaxation_success_count = 0
self._last_successful_voltage: float | None = None
self._last_successful_monotonic: float | None = None
self._last_sample_mode: str | None = None
self._consecutive_errors = 0
self._sampling_interval = (
self._fixed_poll_interval
if self._fixed_poll_interval is not None
else NORMAL_SAMPLING_INTERVAL_SECONDS
)
def start(self) -> None:
with self._state_lock:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
self._reschedule.clear()
self._thread = threading.Thread(
target=self._run,
name="screen-voltage-monitor",
daemon=True,
)
self._thread.start()
def close(self) -> None:
self._stop.set()
self._reschedule.set()
with self._state_lock:
thread = self._thread
interval = self._sampling_interval
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=max(2.0, min(6.0, interval + 1.0)))
def get_status(self) -> dict[str, Any]:
with self._state_lock:
return copy.deepcopy(self._snapshot)
def get_protection_status(self) -> dict[str, Any]:
with self._state_lock:
status = {
"enabled": self._protection.enabled,
"mode": self._protection.mode,
"brightness_limit_percent": (
self._protection.brightness_limit_percent
),
"reading_stale": self._protection.reading_stale,
"revision": self._protection_revision,
"sampling_interval_seconds": self._sampling_interval,
"enforcement_error_code": self._enforcement_error_code,
}
return copy.deepcopy(status)
def set_protection_listener(
self,
callback: Callable[[LowVoltageProtectionDirective], None] | None,
) -> None:
if callback is not None and not callable(callback):
raise TypeError("protection listener must be callable or None")
with self._state_lock:
self._protection_listener = callback
self._enforced_sequence = -1
self._enforcement_error_code = None
self._retry_protection_listener()
def set_protection_enabled(self, enabled: bool) -> dict[str, Any]:
if type(enabled) is not bool:
raise TypeError("enabled must be a boolean")
schedule_changed = False
with self._state_lock:
if enabled != self._protection.enabled:
sequence = self._allocate_sequence_locked()
self._last_published_sequence = sequence
self._replace_protection_locked(
protection_directive_for_voltage(sequence, enabled, None)
)
self._has_successful_protection_sample = False
self._clear_relaxation_locked()
self._last_successful_voltage = None
self._last_successful_monotonic = None
self._last_sample_mode = None
self._consecutive_errors = 0
schedule_changed = self._set_sampling_interval_locked(
self._fixed_poll_interval
if self._fixed_poll_interval is not None
else NORMAL_SAMPLING_INTERVAL_SECONDS
)
if schedule_changed:
self._request_reschedule()
self._retry_protection_listener()
return self.get_protection_status()
def sample_now(self, sample_count: int = NORMAL_SAMPLE_COUNT) -> dict[str, Any]:
sequence = self._allocate_sequence()
try:
group = self._read_group(sample_count)
return self._publish_success(sequence, group)
except AdcDisconnectedError as exc:
return self._publish_failure(sequence, "disconnected", exc.code)
except (AdcProtocolError, ValueError):
return self._publish_failure(sequence, "error", "invalid_reading")
except Exception:
return self._publish_failure(sequence, "error", "unexpected_error")
def preview_calibration(self, reference_volts: float) -> dict[str, Any]:
reference = float(reference_volts)
if not math.isfinite(reference) or not CALIBRATION_REFERENCE_MIN <= reference <= CALIBRATION_REFERENCE_MAX:
raise CalibrationError("万用表参考电压必须在 4.500V 到 5.500V 之间")
sequence = self._allocate_sequence()
try:
group = self._read_group(CALIBRATION_SAMPLE_COUNT)
except AdcDisconnectedError as exc:
self._publish_failure(sequence, "disconnected", exc.code)
raise CalibrationUnavailableError("电压传感器断开,无法校准") from exc
except (AdcProtocolError, ValueError) as exc:
self._publish_failure(sequence, "error", "invalid_reading")
raise CalibrationUnavailableError("电压读取异常,无法校准") from exc
self._publish_success(sequence, group)
if group.spread_volts > CALIBRATION_MAX_SPREAD_VOLTS:
raise CalibrationError(
f"采样波动 {group.spread_volts:.3f}V,超过 0.050V;请等待供电稳定后重试"
)
if group.uncalibrated_volts <= 0:
raise CalibrationError("未校准电压必须大于 0V")
proposed_factor = reference / group.uncalibrated_volts
if not CALIBRATION_FACTOR_MIN <= proposed_factor <= CALIBRATION_FACTOR_MAX:
raise CalibrationError(
f"建议系数 {proposed_factor:.6f} 超出 0.8 到 1.2;请检查型号、接线和共地"
)
now = datetime.now(timezone.utc)
proposal = CalibrationProposal(
proposal_id=str(uuid4()),
reference_volts=reference,
uncalibrated_volts=group.uncalibrated_volts,
current_factor=float(self.config_store.config["voltage_calibration_factor"]),
proposed_factor=proposed_factor,
created_at=now,
expires_at=now + timedelta(seconds=CALIBRATION_PROPOSAL_SECONDS),
)
with self._state_lock:
self._proposal = proposal
return proposal.response()
def confirm_calibration(self, proposal_id: str) -> dict[str, Any]:
now = datetime.now(timezone.utc)
with self._state_lock:
proposal = self._proposal
status = self._snapshot["status"]
if proposal is None or proposal.proposal_id != proposal_id:
raise CalibrationConflictError("校准提案不存在或已经失效,请重新预览")
if now > proposal.expires_at:
with self._state_lock:
self._proposal = None
raise CalibrationConflictError("校准提案已超过 120 秒,请重新预览")
if status != "ok":
raise CalibrationUnavailableError("传感器状态已经变化,请恢复后重新预览")
self.config_store.update({
"voltage_calibration_factor": proposal.proposed_factor,
"voltage_calibrated_at": isoformat_utc(now),
"voltage_calibration_reference": proposal.reference_volts,
"voltage_calibration_uncalibrated": proposal.uncalibrated_volts,
})
with self._state_lock:
self._proposal = None
return self.sample_now()
def reset_calibration(self) -> dict[str, Any]:
self.config_store.update({
"voltage_calibration_factor": 1.0,
"voltage_calibrated_at": None,
"voltage_calibration_reference": None,
"voltage_calibration_uncalibrated": None,
})
with self._state_lock:
self._proposal = None
return self.sample_now()
def _run(self) -> None:
# The application performs one synchronous startup sample. Waiting here
# avoids immediately opening the I2C device for the same information.
wait_seconds = self._current_sampling_interval()
while not self._stop.is_set():
if self._wait_until_due(wait_seconds):
return
started = time.monotonic()
self.sample_now()
wait_seconds = max(
0.0,
self._current_sampling_interval() - (time.monotonic() - started),
)
def _wait_until_due(self, seconds: float) -> bool:
deadline = time.monotonic() + seconds
while not self._stop.is_set():
remaining = max(0.0, deadline - time.monotonic())
if not self._reschedule.wait(remaining):
return False
self._reschedule.clear()
if self._stop.is_set():
return True
deadline = time.monotonic() + self._current_sampling_interval()
return True
def _read_group(self, sample_count: int) -> SampleGroup:
if sample_count <= 0:
raise ValueError("样本数量必须大于 0")
raw_values: list[int] = []
last_config = CONFIG_CONTINUOUS_15SPS_PGA1
with self._io_lock:
with self._device_factory() as device:
device.configure()
for _ in range(sample_count):
raw, last_config = device.read_fresh()
raw_values.append(raw)
raw_median = float(statistics.median(raw_values))
uncalibrated = voltage_from_raw(raw_median)
minimum = voltage_from_raw(min(raw_values))
maximum = voltage_from_raw(max(raw_values))
if not 0.0 <= uncalibrated <= 12.0:
raise AdcProtocolError(f"屏幕输入电压 {uncalibrated:.3f}V 超出 0V 到 12V")
return SampleGroup(
raw_median=raw_median,
uncalibrated_volts=uncalibrated,
config=last_config,
spread_volts=maximum - minimum,
)
def _publish_success(self, sequence: int, group: SampleGroup) -> dict[str, Any]:
now = datetime.now(timezone.utc)
sampled_monotonic = time.monotonic()
factor = float(self.config_store.config["voltage_calibration_factor"])
volts = group.uncalibrated_volts * factor
snapshot = {
"status": "ok",
"volts": volts,
"sampled_at": isoformat_utc(now),
"calibrated": self._is_calibrated(),
"error_code": None,
}
accepted = False
schedule_changed = False
previous_status = "ok"
with self._state_lock:
if sequence > self._last_published_sequence:
accepted = True
self._last_published_sequence = sequence
previous_status = self._snapshot["status"]
self._snapshot = snapshot
self._consecutive_errors = 0
elapsed = (
None
if self._last_successful_monotonic is None
else sampled_monotonic - self._last_successful_monotonic
)
candidate = protection_directive_for_voltage(
sequence,
self._protection.enabled,
volts,
)
first_critical = (
candidate.mode == "critical"
and self._last_sample_mode != "critical"
)
if self._protection.enabled:
self._accept_successful_protection_candidate_locked(candidate)
interval = (
self._fixed_poll_interval
if self._fixed_poll_interval is not None
else successful_sampling_interval(
enabled=self._protection.enabled,
mode=candidate.mode,
volts=volts,
previous_volts=self._last_successful_voltage,
elapsed_seconds=elapsed,
previous_interval_seconds=self._sampling_interval,
first_critical_sample=first_critical,
)
)
schedule_changed = self._set_sampling_interval_locked(interval)
self._last_successful_voltage = volts
self._last_successful_monotonic = sampled_monotonic
self._last_sample_mode = candidate.mode
result = copy.deepcopy(self._snapshot)
if accepted and previous_status != "ok":
logger.info("Screen voltage sensor is available")
if schedule_changed:
self._request_reschedule()
self._retry_protection_listener()
return result
def _publish_failure(
self,
sequence: int,
status: str,
error_code: str,
) -> dict[str, Any]:
snapshot = {
"status": status,
"volts": None,
"sampled_at": None,
"calibrated": self._is_calibrated(),
"error_code": error_code,
}
accepted = False
schedule_changed = False
previous = (status, error_code)
with self._state_lock:
if sequence > self._last_published_sequence:
accepted = True
self._last_published_sequence = sequence
previous = (self._snapshot["status"], self._snapshot["error_code"])
self._snapshot = snapshot
self._proposal = None
self._clear_relaxation_locked()
self._consecutive_errors += 1
self._mark_protection_stale_locked(sequence)
interval = (
self._fixed_poll_interval
if self._fixed_poll_interval is not None
else error_retry_interval(self._consecutive_errors)
)
schedule_changed = self._set_sampling_interval_locked(interval)
result = copy.deepcopy(self._snapshot)
if accepted and previous != (status, error_code):
logger.warning("Screen voltage sensor status=%s error_code=%s", status, error_code)
if schedule_changed:
self._request_reschedule()
self._retry_protection_listener()
return result
def _accept_successful_protection_candidate_locked(
self,
candidate: LowVoltageProtectionDirective,
) -> None:
if not self._has_successful_protection_sample:
self._has_successful_protection_sample = True
self._clear_relaxation_locked()
self._replace_protection_locked(candidate)
return
current = self._protection
if is_more_restrictive(candidate, current):
self._clear_relaxation_locked()
self._replace_protection_locked(candidate)
return
if same_protection_level(candidate, current):
self._clear_relaxation_locked()
if current.reading_stale:
self._replace_protection_locked(candidate)
return
# A successful sample makes the reading fresh immediately, while any
# output relaxation still waits for a second consecutive success.
if current.reading_stale:
self._replace_protection_locked(
LowVoltageProtectionDirective(
sequence=candidate.sequence,
enabled=current.enabled,
mode=current.mode,
brightness_limit_percent=current.brightness_limit_percent,
reading_stale=False,
)
)
self._relaxation_success_count += 1
self._relaxation_candidate = (
candidate
if self._relaxation_candidate is None
else more_conservative(self._relaxation_candidate, candidate)
)
if self._relaxation_success_count >= 2:
conservative = self._relaxation_candidate
assert conservative is not None
self._replace_protection_locked(
LowVoltageProtectionDirective(
sequence=candidate.sequence,
enabled=conservative.enabled,
mode=conservative.mode,
brightness_limit_percent=(
conservative.brightness_limit_percent
),
reading_stale=False,
)
)
self._clear_relaxation_locked()
def _mark_protection_stale_locked(self, sequence: int) -> None:
if not self._protection.enabled:
return
if self._has_successful_protection_sample:
if not self._protection.reading_stale:
self._replace_protection_locked(
LowVoltageProtectionDirective(
sequence=sequence,
enabled=True,
mode=self._protection.mode,
brightness_limit_percent=(
self._protection.brightness_limit_percent
),
reading_stale=True,
)
)
return
# There is no successful reading to become stale yet. Keep the
# non-restrictive unavailable directive and expose the ADC error via
# the sensor status instead of churning the protection revision.
def _replace_protection_locked(
self,
directive: LowVoltageProtectionDirective,
) -> None:
if directive == self._protection:
return
self._protection = directive
self._protection_revision += 1
def _clear_relaxation_locked(self) -> None:
self._relaxation_candidate = None
self._relaxation_success_count = 0
def _retry_protection_listener(self) -> None:
with self._listener_lock:
with self._state_lock:
listener = self._protection_listener
directive = self._protection
needs_enforcement = (
listener is not None
and (
self._enforced_sequence != directive.sequence
or self._enforcement_error_code is not None
)
)
if not needs_enforcement or listener is None:
return
try:
listener(directive)
except Exception:
with self._state_lock:
if self._protection == directive:
self._enforcement_error_code = (
PROTECTION_ENFORCEMENT_ERROR_CODE
)
logger.exception("Failed to enforce low-voltage display protection")
return
with self._state_lock:
if self._protection == directive:
self._enforced_sequence = directive.sequence
self._enforcement_error_code = None
def _allocate_sequence(self) -> int:
with self._state_lock:
return self._allocate_sequence_locked()
def _allocate_sequence_locked(self) -> int:
self._sequence += 1
return self._sequence
def _set_sampling_interval_locked(self, interval: float) -> bool:
normalized = float(interval)
changed = normalized != self._sampling_interval
self._sampling_interval = normalized
return changed
def _current_sampling_interval(self) -> float:
with self._state_lock:
return self._sampling_interval
def _request_reschedule(self) -> None:
with self._state_lock:
thread = self._thread
if thread is not None and threading.current_thread() is not thread:
self._reschedule.set()
def _is_calibrated(self) -> bool:
return self.config_store.config.get("voltage_calibrated_at") is not None