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