#!/usr/bin/env python3 """ watch_in2.py — 监听数字输入 IN2 的电平变化(Python 版) 原 Bash 脚本 watch_in2.sh 的功能等价实现: - IN2 编号:Linux 全局 GPIO 483 = gpiochip480(label "27010000.gpio",bank-name "porta")的第 3 线 - 推导依据可用 --check 现场复算 - 由于 483 被平台驱动占用,sysfs export 会 EBUSY, 唯一可读通道是 gpio_status,不支持 poll/select,只能定时采样。 - 该文件权限 0666,所以本脚本不需要 root。 用法: ./watch_in2.py [轮询间隔秒] 默认 0.02 ./watch_in2.py 0.005 5ms 细粒度 ./watch_in2.py --check 只做自检:复算编号 + 打印当前电平,然后退出 停止: Ctrl-C 打印汇总 """ import os import sys import time import signal import datetime import argparse from pathlib import Path # --------------------------------------------------------------------------- # 常量 # --------------------------------------------------------------------------- IN_INDEX = 2 GPIO_NUM = 483 GPIOCHIP = 480 DEBOUNCE = 2 # 连续多少次采样一致才确认跳变 DEFAULT_INTERVAL = 0.02 LOG_DIR_ENV = "DI_LOG_DIR" STATUS_FILE_ENV = "DI_STATUS_FILE" # 仅用于测试 # 候选状态文件路径 STATUS_CANDIDATES = [ f"/sys/bus/platform/devices/gpio-in-{IN_INDEX}/gpio_status", f"/dev/tv_gpioin_{IN_INDEX}", ] IN1_CANDIDATES = [ "/sys/bus/platform/devices/gpio-in-1/gpio_status", "/dev/tv_gpioin_1", ] DEVICETREE_BASE = Path("/sys/firmware/devicetree/base") # --------------------------------------------------------------------------- # 工具函数 # --------------------------------------------------------------------------- def usage() -> str: """返回脚本头部注释作为用法说明。""" return __doc__ or "" def read_level_of(path: str): """读取状态文件,返回 '0' / '1',失败返回 None。""" try: with open(path, "r") as f: v = f.read() except (OSError, IOError): return None v = v.strip().strip("\t\r\n ") if v in ("0", "1"): return v return None def ts_short() -> str: """HH:MM:SS.mmm""" now = datetime.datetime.now() return now.strftime("%H:%M:%S.") + f"{now.microsecond // 1000:03d}" def fmt_dur(ms: int) -> str: """毫秒格式化。""" if ms < 1000: return f"{ms}ms" return f"{ms / 1000:.3f}s" def probe_writable(path: Path) -> bool: """检查目录是否可写,必要时创建。""" try: path.mkdir(parents=True, exist_ok=True) except OSError: return False test_file = path / f".wtest.{os.getpid()}" try: with open(test_file, "w"): pass test_file.unlink() return True except OSError: try: test_file.unlink() except OSError: pass return False # --------------------------------------------------------------------------- # 设备树复算(等价于 Bash 的 derive_gpio) # --------------------------------------------------------------------------- def derive_gpio(n: int): """从设备树复算 IN 的全局 GPIO 号;失败返回 None。""" f = DEVICETREE_BASE / f"gpio-in-{n}" / "control-gpios" if not f.exists(): return None try: data = f.read_bytes() except OSError: return None if len(data) < 8: return None # 前 4 字节 phandle,后 4 字节 pin(大端) ph = int.from_bytes(data[0:4], "big") pin = int.from_bytes(data[4:8], "big") # phandle -> gpio-controller 节点 ctrl = None for c in DEVICETREE_BASE.glob("gpio@*/gpio-controller@*"): ph_file = c / "phandle" if not ph_file.exists(): continue try: cph = int.from_bytes(ph_file.read_bytes()[:4], "big") except OSError: continue if cph == ph: ctrl = c break if ctrl is None: return None # gpio@<地址>/gpio-controller@N -> gpiochip label "<地址>.gpio" -> base bank = ctrl.parent.name # gpio@27010000 bank = bank[len("gpio@"):] if bank.startswith("gpio@") else bank label = f"{bank}.gpio" base = None gpio_class = Path("/sys/class/gpio") if gpio_class.exists(): for g in gpio_class.glob("gpiochip*"): label_file = g / "label" base_file = g / "base" if not label_file.exists() or not base_file.exists(): continue try: if label_file.read_text().strip() == label: base = int(base_file.read_text().strip()) break except (OSError, ValueError): continue if base is None: return None return base + pin # --------------------------------------------------------------------------- # 主监听器 # --------------------------------------------------------------------------- class Watcher: def __init__(self, interval: float, dev: str, dev_in1: str, log_dir: Path): self.interval = interval self.dev = dev self.dev_in1 = dev_in1 self.log_dir = log_dir self.log_path = None self.log_fh = None self.state = None self.count = 0 self.ms_low = 0 self.ms_high = 0 self.state_since = 0 self._stop = False # ---------- 日志 ---------- def open_log(self): ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") self.log_path = self.log_dir / f"in2_{ts}.log" try: self.log_fh = open(self.log_path, "a", encoding="utf-8") except OSError: self.log_fh = None def say(self, msg: str): print(msg) if self.log_fh: try: self.log_fh.write(msg + "\n") self.log_fh.flush() except OSError: pass # ---------- 电平读取 ---------- def read_level(self): return read_level_of(self.dev) def peer(self) -> str: if self.dev_in1: v = read_level_of(self.dev_in1) if v is not None: return f"IN1={v}" return "IN1=-" # ---------- 信号处理 ---------- def request_stop(self, *_args): self._stop = True # ---------- 汇总 ---------- def print_summary(self): now_ms = int(time.time() * 1000) dur = now_ms - self.state_since if self.state == "0": self.ms_low += dur elif self.state == "1": self.ms_high += dur self.say("") self.say("============================================================") self.say(f"汇总 {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") self.say(f" 跳变次数:{self.count}") self.say(f" 低电平累计:{fmt_dur(self.ms_low)}") self.say(f" 高电平累计:{fmt_dur(self.ms_high)}") self.say(f" 结束电平:{self.state}") if self.count == 0: self.say("") self.say(f" 整段时间内 IN{IN_INDEX} 没有任何变化。") self.say(f" 若此时确实短接过 IN{IN_INDEX} 与 COM,说明激励没真正加到端子上;") self.say(f" 请确认接的是 IN{IN_INDEX}(不是 IN1)且 COM 已共地,必要时上 12V 再试。") self.say(f" 日志已保存:{self.log_path}") self.say("============================================================") # ---------- 主循环 ---------- def run(self): # 首次采样 state = self.read_level() if state is None: print(f"错误:读不出 {self.dev} 的有效电平(期望 0/1)。", file=sys.stderr) try: content = Path(self.dev).read_text()[:64] except OSError: content = "<无法读取>" print(f" 内容:{content}", file=sys.stderr) return 1 self.state = state self.state_since = int(time.time() * 1000) self.open_log() self.say("============================================================") self.say(f"IN{IN_INDEX} 监听开始 {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") self.say(f" IN{IN_INDEX} = GPIO {GPIO_NUM} = gpiochip{GPIOCHIP} 第 {GPIO_NUM - GPIOCHIP} 线") self.say(f" 状态文件:{self.dev}") self.say(f" 轮询间隔:{self.interval}s 抗抖:连续 {DEBOUNCE} 次采样一致") self.say(f" 日志:{self.log_path}") self.say("============================================================") self.say(f"初始电平:{self.state}") self.say("") self.say("把 IN1 与 IN2 短接约 2 秒再松开(实测这根接法才有效;单接 IN2-GND 无效)。") self.say("下面每行会同时给出 IN1 和 IN2 的电平。Ctrl-C 结束。") self.say("") cand = None cand_n = 0 # 注册信号 signal.signal(signal.SIGINT, self.request_stop) signal.signal(signal.SIGTERM, self.request_stop) try: while not self._stop: v = self.read_level() if v is None: self.say(f"{ts_short()} 读取失败,跳过本次采样") time.sleep(self.interval) continue now_ms = int(time.time() * 1000) if v == self.state: cand = None cand_n = 0 else: if v == cand: cand_n += 1 else: cand = v cand_n = 1 if cand_n >= DEBOUNCE: dur = now_ms - self.state_since if self.state == "0": self.ms_low += dur else: self.ms_high += dur self.count += 1 self.say( f"{ts_short()} 变化 IN{IN_INDEX}: {self.state} -> {v}" f" [{self.peer()}] 上一状态持续 {fmt_dur(dur)}" ) self.state = v self.state_since = now_ms cand = None cand_n = 0 time.sleep(self.interval) finally: self.print_summary() if self.log_fh: self.log_fh.close() return 0 # --------------------------------------------------------------------------- # --check 模式 # --------------------------------------------------------------------------- def do_check(dev: str, interval: float): print(f"=== IN{IN_INDEX} 编号自检 ===") print() print(f"状态文件:{dev}") print(f"轮询间隔:{interval}s(本模式不轮询)") print() print("--- 从设备树现场复算 ---") ok = True for n in (1, 2): got = derive_gpio(n) if got is None: print(f" IN{n}:复算失败(设备树 gpio-in-{n} 或对应 gpiochip 不可读)") ok = False continue expect = 481 + n # IN1 -> 482, IN2 -> 483 if got == expect: print(f" IN{n} -> GPIO {got} ✓ 与预期一致") else: print(f" IN{n} -> GPIO {got} ✗ 预期 {expect},需人工核对") ok = False print() if ok: print(f"结论:IN{IN_INDEX} = GPIO {GPIO_NUM}" f"(gpiochip{GPIOCHIP} 第 {GPIO_NUM - GPIOCHIP} 线),推导成立。") else: print("结论:推导与实测不符,请把上面的输出贴出来再排查。") print() print("--- 当前电平 ---") v = read_level_of(dev) if v is not None: print(f" gpio-in-{IN_INDEX}/gpio_status = {v} (对应的全局 GPIO 为 {GPIO_NUM})") print(" 注:待机时读到 0 是正常的,激励到来时才会翻转。") else: print(f" 读取失败:{dev} 内容不是 0/1。") return 0 # --------------------------------------------------------------------------- # 入口 # --------------------------------------------------------------------------- def find_status_file() -> str: """定位 IN2 状态文件。""" # 测试用环境变量 test_file = os.environ.get(STATUS_FILE_ENV) if test_file and os.access(test_file, os.R_OK): return test_file for c in STATUS_CANDIDATES: if os.access(c, os.R_OK): return c return "" def find_in1_file() -> str: """定位 IN1 状态文件(可选)。""" for c in IN1_CANDIDATES: if os.access(c, os.R_OK): return c return "" def main(argv=None): parser = argparse.ArgumentParser( description=usage(), formatter_class=argparse.RawDescriptionHelpFormatter, add_help=True, ) parser.add_argument( "interval", nargs="?", default=str(DEFAULT_INTERVAL), help=f"轮询间隔秒(默认 {DEFAULT_INTERVAL})" ) parser.add_argument( "--check", action="store_true", help="只做自检:复算编号 + 打印当前电平,然后退出" ) args = parser.parse_args(argv) # 解析 interval try: interval = float(args.interval) except ValueError: print(f"错误:轮询间隔 '{args.interval}' 不是正数。", file=sys.stderr) return 1 if interval <= 0: print(f"错误:轮询间隔 '{args.interval}' 不是正数。", file=sys.stderr) return 1 # 定位状态文件 dev = find_status_file() if not dev: print(f"错误:找不到 gpio-in-{IN_INDEX} 的状态文件。", file=sys.stderr) print(f" 已尝试:{STATUS_CANDIDATES[0]}", file=sys.stderr) print(f" {STATUS_CANDIDATES[1]}", file=sys.stderr) print(" 排查:ls /sys/bus/platform/devices/ | grep gpio-in", file=sys.stderr) return 1 # --check 模式 if args.check: return do_check(dev, interval) # 准备日志目录 log_dir = Path(os.environ.get(LOG_DIR_ENV, str(Path.home() / "di_logs"))) if not probe_writable(log_dir): print(f"提示:{log_dir} 不可写(属主可能是 root),本次日志改写到 {Path.home()}", file=sys.stderr) print(f" 想恢复到 {log_dir}:sudo chown {os.environ.get('USER', '')} {log_dir}", file=sys.stderr) log_dir = Path.home() # IN1 一并盯着 dev_in1 = find_in1_file() watcher = Watcher(interval, dev, dev_in1, log_dir) return watcher.run() if __name__ == "__main__": sys.exit(main())