初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user