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

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,357 @@
#!/usr/bin/env python3
"""读取 M5Stack Unit ADC v1.1 (U013-V11 / ADS1110) 的屏幕输入电压。
真实 I2C 读取仅支持 Linux;--self-test 不访问硬件,可在 Windows 上运行。
本脚本只使用 Python 标准库,不接入项目主服务。
"""
from __future__ import annotations
import argparse
import errno
import math
import os
import statistics
import sys
import time
from dataclasses import dataclass
from typing import Sequence
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
class AdcError(RuntimeError):
"""用户可处理的 ADC 访问错误。"""
class AdcProtocolError(AdcError):
"""ADS1110 返回的数据不符合本项目固定协议。"""
@dataclass(frozen=True)
class Reading:
raw_median: float
volts: float
config: int
def parse_address(value: str) -> int:
try:
address = int(value, 0)
except ValueError as exc:
raise argparse.ArgumentTypeError("I2C 地址应为 0x48 这类整数") from exc
if not 0x08 <= address <= 0x77:
raise argparse.ArgumentTypeError("7 位 I2C 地址必须在 0x08 到 0x77 之间")
return address
def positive_int(value: str) -> int:
try:
result = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("应输入正整数") from exc
if result <= 0:
raise argparse.ArgumentTypeError("应输入大于 0 的整数")
return result
def positive_float(value: str) -> float:
try:
result = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("应输入正数") from exc
if not math.isfinite(result) or result <= 0:
raise argparse.ArgumentTypeError("应输入大于 0 的有限数值")
return result
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("calibration_factor 必须是大于 0 的有限数值")
adc_volts = float(raw) * REFERENCE_VOLTS / ADC_CODE_SCALE
return adc_volts * BOARD_DIVIDER_RATIO * calibration_factor
def validate_config(config: int) -> None:
if not 0 <= config <= 0xFF:
raise AdcProtocolError(f"配置字节超出范围: {config!r}")
actual = config & CONFIG_VALUE_MASK
if actual != CONFIG_CONTINUOUS_15SPS_PGA1:
raise AdcProtocolError(
"ADS1110 配置不符合连续转换/15SPS/PGA=1: "
f"收到 0x{config:02X},低 7 位应为 0x{CONFIG_CONTINUOUS_15SPS_PGA1:02X}"
)
def decode_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]
validate_config(config)
return raw, config
def describe_os_error(action: str, exc: OSError, address: int) -> AdcError:
remote_io = getattr(errno, "EREMOTEIO", 121)
if exc.errno in (remote_io, errno.ENXIO):
return AdcError(
f"{action}失败:I2C 地址 0x{address:02X} 没有响应;"
"请检查 v1.1 型号、5V/GND、SDA/SCL,并先运行 i2cdetect -y 1"
)
if exc.errno == errno.EBUSY:
return AdcError(
f"{action}失败:I2C 地址 0x{address:02X} 已被内核驱动占用;"
"如果 i2cdetect 显示 UU,请先查明占用原因"
)
if exc.errno in (errno.EACCES, errno.EPERM):
return AdcError(
f"{action}失败:没有访问 I2C 设备的权限;请检查 /dev/i2c-1 权限和当前用户的 i2c 组"
)
return AdcError(f"{action}失败:{exc.strerror or exc} (errno={exc.errno})")
class Ads1110Device:
def __init__(self, bus_path: str, address: int) -> None:
self.bus_path = bus_path
self.address = address
self._fd: int | None = None
def __enter__(self) -> "Ads1110Device":
if os.name != "posix":
raise AdcError("真实 I2C 读取只支持 Linux;在 Windows 上请使用 --self-test")
try:
import fcntl
except ImportError as exc:
raise AdcError("当前 Python 缺少 Linux fcntl,无法访问 /dev/i2c-* 设备") from exc
try:
self._fd = os.open(self.bus_path, os.O_RDWR)
except FileNotFoundError as exc:
raise AdcError(
f"没有找到 {self.bus_path};请确认核桃派 I2C1 设备节点和总线编号"
) from exc
except PermissionError as exc:
raise AdcError(
f"没有权限打开 {self.bus_path};请检查设备权限和当前用户的 i2c 组"
) from exc
except OSError as exc:
raise describe_os_error(f"打开 {self.bus_path}", exc, self.address) from exc
try:
fcntl.ioctl(self._fd, I2C_SLAVE, self.address)
except OSError as exc:
self.close()
raise describe_os_error("选择 I2C 从设备", exc, self.address) 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 AdcError("I2C 设备尚未打开")
return self._fd
def configure(self) -> None:
fd = self._require_fd()
try:
written = os.write(fd, bytes([CONFIG_CONTINUOUS_15SPS_PGA1]))
except OSError as exc:
raise describe_os_error("写入 ADS1110 配置", exc, self.address) from exc
if written != 1:
raise AdcProtocolError(f"ADS1110 配置应写入 1 字节,实际写入 {written} 字节")
def read_fresh(self) -> tuple[int, int]:
fd = self._require_fd()
deadline = time.monotonic() + FRESH_DATA_TIMEOUT_SECONDS
while True:
time.sleep(CONVERSION_WAIT_SECONDS)
try:
data = os.read(fd, 3)
except OSError as exc:
raise describe_os_error("读取 ADS1110", exc, self.address) from exc
raw, config = decode_conversion(data)
if (config & DATA_READY_MASK) == 0:
return raw, config
if time.monotonic() >= deadline:
raise AdcProtocolError("等待 ADS1110 新转换数据超时,ST/DRDY 始终为 1")
def take_reading(
device: Ads1110Device,
sample_count: int,
calibration_factor: float,
) -> Reading:
raw_values: list[int] = []
last_config = CONFIG_CONTINUOUS_15SPS_PGA1
for _ in range(sample_count):
raw, last_config = device.read_fresh()
raw_values.append(raw)
raw_median = float(statistics.median(raw_values))
return Reading(
raw_median=raw_median,
volts=voltage_from_raw(raw_median, calibration_factor),
config=last_config,
)
def format_raw(raw: float) -> str:
if raw.is_integer():
return str(int(raw))
return f"{raw:.1f}"
def print_reading(reading: Reading) -> None:
print(
f"屏幕输入端电压: {reading.volts:.3f} V "
f"raw中位数: {format_raw(reading.raw_median)} "
f"配置: 0x{reading.config:02X}",
flush=True,
)
if reading.volts < 0 or reading.volts > 12:
print(
"警告:读数超出模块规定的 0V~12V 输入范围,请停止并检查型号、接线和测量点。",
file=sys.stderr,
flush=True,
)
def require_self_test(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def run_self_test() -> None:
require_self_test(CONFIG_CONTINUOUS_15SPS_PGA1 == 0x0C, "固定配置字节不是 0x0C")
require_self_test(voltage_from_raw(0) == 0.0, "raw=0 的换算结果不是 0V")
raw_5v, config = decode_conversion(bytes([0x33, 0x3B, 0x0C]))
require_self_test(raw_5v == 13115, "5V 样例 raw 解析错误")
require_self_test(config == 0x0C, "5V 样例配置解析错误")
require_self_test(
abs(voltage_from_raw(raw_5v) - 5.0) < 0.001,
"raw=13115 没有换算为约 5.000V",
)
negative_raw, config_with_old_data = decode_conversion(bytes([0xFF, 0xFF, 0x8C]))
require_self_test(negative_raw == -1, "16 位负数符号解析错误")
require_self_test(config_with_old_data == 0x8C, "ST/DRDY 状态位解析错误")
require_self_test(
abs(voltage_from_raw(32767) - 12.49241875) < 1e-9,
"正满量程换算错误",
)
try:
decode_conversion(bytes([0x00, 0x00]))
except AdcProtocolError:
pass
else:
raise AssertionError("短读取必须触发 AdcProtocolError")
try:
validate_config(0x08)
except AdcProtocolError:
pass
else:
raise AssertionError("错误采样配置必须触发 AdcProtocolError")
print("自测通过:配置 0x0C = 连续转换 / 15SPS / PGA=1")
print(f"自测通过:raw=13115 -> {voltage_from_raw(13115):.6f}V(约 5.000V)")
print("自测通过:16 位负数符号、满量程、短读取和错误配置检查正常")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="读取 M5Stack Unit ADC v1.1 (ADS1110) 的屏幕输入端电压"
)
parser.add_argument("--bus", default=DEFAULT_BUS, help=f"I2C 设备路径,默认 {DEFAULT_BUS}")
parser.add_argument(
"--address",
type=parse_address,
default=DEFAULT_ADDRESS,
help="7 位 I2C 地址,默认 0x48",
)
parser.add_argument(
"--samples",
type=positive_int,
default=5,
help="每次输出使用的新样本数量,取中位数,默认 5",
)
parser.add_argument(
"--calibration-factor",
type=positive_float,
default=1.0,
help="可选比例校正系数,默认 1.0",
)
parser.add_argument("--watch", action="store_true", help="连续读取,按 Ctrl+C 停止")
parser.add_argument(
"--interval",
type=positive_float,
default=1.0,
help="--watch 两次输出之间的最短秒数,默认 1.0",
)
parser.add_argument(
"--self-test",
action="store_true",
help="只测试协议常量和换算,不访问 I2C,可在 Windows 运行",
)
return parser
def run_hardware(args: argparse.Namespace) -> None:
with Ads1110Device(args.bus, args.address) as device:
device.configure()
while True:
started = time.monotonic()
print_reading(take_reading(device, args.samples, args.calibration_factor))
if not args.watch:
return
elapsed = time.monotonic() - started
time.sleep(max(0.0, args.interval - elapsed))
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.self_test:
run_self_test()
return 0
try:
run_hardware(args)
except KeyboardInterrupt:
print("\n已停止。")
return 0
except (AdcError, ValueError) as exc:
print(f"错误:{exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())