Files
T

358 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())