#!/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())