boardctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
boardctl/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """boardctl — 开发板控制工具包(设计参考 crates.io 上的 ostool)
2
+
3
+ 结构(松耦合,单向依赖):
4
+ config.py 板卡 TOML 加载(不依赖其他模块)
5
+ session.py U-Boot 串口会话(依赖 config)
6
+ power.py 电源控制与冷启动(依赖 config, session)
7
+ console.py 交互终端(依赖 config, session, power)
8
+ runner.py run/cmd 编排(依赖上述 + plugins)
9
+ cli.py 命令行入口,只做接线
10
+ plugins/ 插件(按目录约定自动发现,见 plugins/__init__.py)
11
+ """
boardctl/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
@@ -0,0 +1,67 @@
1
+ # Sophgo SG2002 开发板(Cvitek / RISC-V 玄铁 C906 / U-Boot 2021.10)
2
+ description = "Sophgo SG2002 (RISC-V C906, U-Boot 2021.10, soph# 提示符)"
3
+
4
+ # 命令执行位置:留空 = power/exec 等命令在本机执行;
5
+ # 填 ssh 别名(如 "gem12")= 这些命令经 ssh 在该主机上执行(别名/端口/用户走 ~/.ssh/config)
6
+ ssh_host = ""
7
+
8
+ [serial]
9
+ url = "socket://172.16.0.7:5000" # 串口桥(gem12 的 VPN 地址),裸 TCP 透传
10
+ timeout = 0.2
11
+
12
+ [uboot]
13
+ prompt = "soph#"
14
+ load_addr = "0x80080000"
15
+ ip_addr = "192.168.1.201" # 设备自身 IP(静态)
16
+ server_ip = "192.168.1.9" # TFTP 服务器 = gem12 的 wlan 口
17
+ ensure_server_ip = true # 该构建无 saveenv,每次连接自动恢复 serverip
18
+
19
+ [power]
20
+ # 两种方式二选一(改 method 一行切换):
21
+ method = "mijia" # 方式一:原生插件,进程内直连小米云(参数见 [power.mijia])
22
+ # method = "command" # 方式二:特制开关机命令(用下面的 on_cmd/off_cmd/status_cmd)
23
+ reset_delay = 3 # 断电到上电的间隔(秒)
24
+ off_on_exit = true # console 退出时自动断电(任何退出路径)
25
+ # 方式二的命令(method = "command" 时生效;经顶层 ssh_host 决定本机/远端执行):
26
+ on_cmd = "./run.sh 1" # 开机命令
27
+ off_cmd = "./run.sh 0" # 关机命令
28
+ status_cmd = "mijiaAPI get --dev_name sg2002 --prop_name on" # 查状态(可选)
29
+
30
+ [power.mijia] # 方式一的参数:dev_name 或 did 二选一
31
+ dev_name = "sg2002" # 米家里的设备名(即插座命名)
32
+
33
+ [tftp]
34
+ method = "remote" # remote: scp 到远端 tftp 目录 | local: 本机起内置 tftp 服务器
35
+ ssh_host = "gem12"
36
+ remote_dir = "/srv/tftp" # 需一次性: ssh -t gem12 'sudo chown $USER /srv/tftp'
37
+ local_dir = "tftpboot" # method=local 时的根目录
38
+
39
+ [loady]
40
+ sender = "/usr/bin/lrzsz-sb" # Arch 的 lrzsz;Debian/Ubuntu 为 /usr/bin/sb
41
+
42
+ # 一键启动目标:boardctl.py -b sg2002 run <名字>(省略名字列出全部)
43
+ [run.hello]
44
+ desc = "RISC-V 裸机测试(冷启动→go 跳转→5 行标记→断电收尾)"
45
+ file = "tests/baremetal/hello.bin"
46
+ addr = "0x80080000" # 加载地址(send/run 默认取 uboot.load_addr,可省)
47
+ # entry = "0x80080010" # 跳转地址(go 的目标);缺省 = addr。加载和入口不同时用它
48
+ exec = "go" # go / source / none(只加载不执行)
49
+ method = "tftp"
50
+ timeout = 8 # 执行后收集输出的秒数
51
+ reset_before = true # 关 → 开 → 等 U-Boot 提示符,从确定状态开始
52
+ after = "off" # 收尾:off 断电 | reset 重启回提示符 | none 保持
53
+ expect = [ # 输出必须全部包含这些标记才算 PASS(缺省则不判定)
54
+ "BM-TEST-START",
55
+ "BM-COUNTER-1",
56
+ "BM-COUNTER-2",
57
+ "BM-COUNTER-3",
58
+ "BM-TEST-DONE",
59
+ ]
60
+
61
+ [run.script]
62
+ desc = "U-Boot 脚本(source 执行,三标记后干净返回,设备保持开机)"
63
+ file = "tests/script/test_uboot.scr"
64
+ exec = "source"
65
+ method = "tftp"
66
+ timeout = 8
67
+ expect = ["SCRIPT-START", "SCRIPT-MIDDLE", "SCRIPT-DONE"]
boardctl/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ """命令行入口:参数解析与子命令分发,只做接线不含业务逻辑"""
2
+ import argparse
3
+ import os
4
+ import sys
5
+
6
+ from . import power, runner, shell
7
+ from .config import available_boards, load_board
8
+ from .console import do_console
9
+ from .plugins import TRANSPORT
10
+
11
+
12
+ def main():
13
+ ap = argparse.ArgumentParser(
14
+ prog='boardctl',
15
+ description='开发板控制工具(参考 ostool 设计,每板一个 boards/*.toml,插件化传输/执行)')
16
+ ap.add_argument('-b', '--board', default='sg2002', help='开发板名(boards/ 下的 TOML 文件名)')
17
+ sub = ap.add_subparsers(dest='op', required=True)
18
+
19
+ sub.add_parser('console', help='交互式串口终端')
20
+ p_power = sub.add_parser('power', help='电源控制')
21
+ p_power.add_argument('state', choices=['on', 'off', 'status'])
22
+ sub.add_parser('reset', help='断电重启')
23
+ p_cmd = sub.add_parser('cmd', help='在 U-Boot 提示符执行命令')
24
+ p_cmd.add_argument('commands', nargs='+')
25
+ p_send = sub.add_parser('send', help='向开发板传输文件')
26
+ p_send.add_argument('file')
27
+ p_send.add_argument('--method', choices=sorted(TRANSPORT) or ['loady'],
28
+ default='loady', help='传输插件')
29
+ p_send.add_argument('--addr', help='加载地址(默认取板卡 uboot.load_addr)')
30
+ p_run = sub.add_parser('run', help='按板卡配置的 [run.<名字>] 一键启动')
31
+ p_run.add_argument('name', nargs='?', help='启动目标名(省略则列出可用目标)')
32
+ p_exec = sub.add_parser('exec', help='在板卡命令环境执行 shell 命令(配置 ssh_host 则经 ssh 远端执行)')
33
+ p_exec.add_argument('command', nargs='+')
34
+ sub.add_parser('boards', help='列出开发板')
35
+
36
+ args = ap.parse_args()
37
+ if args.op == 'boards':
38
+ boards = available_boards()
39
+ if not boards:
40
+ sys.exit('没有找到任何板卡配置(搜索:$BOARDCTL_BOARDS → ./boards → ~/.config/boardctl/boards → 包内置)')
41
+ for name in sorted(boards):
42
+ cfg = load_board(name)
43
+ desc = f' — {cfg["description"]}' if cfg['description'] else ''
44
+ print(f'{cfg["name"]}{desc}')
45
+ return
46
+
47
+ cfg = load_board(args.board)
48
+ if args.op == 'console':
49
+ do_console(cfg)
50
+ elif args.op == 'power':
51
+ power.do_power(cfg, args.state)
52
+ elif args.op == 'reset':
53
+ power.do_reset(cfg)
54
+ elif args.op == 'cmd':
55
+ runner.do_cmd(cfg, args.commands)
56
+ elif args.op == 'send':
57
+ if not os.path.isfile(args.file):
58
+ sys.exit(f'文件不存在: {args.file}')
59
+ addr = args.addr or cfg['uboot']['load_addr']
60
+ ok = TRANSPORT[args.method].send(cfg, args.file, addr)
61
+ sys.exit(0 if ok else 1)
62
+ elif args.op == 'run':
63
+ runner.do_run(cfg, args.name)
64
+ elif args.op == 'exec':
65
+ r = shell.run(cfg, ' '.join(args.command), check=False, capture=True)
66
+ sys.stdout.write(r.stdout or '')
67
+ sys.stderr.write(r.stderr or '')
68
+ sys.exit(r.returncode)
boardctl/config.py ADDED
@@ -0,0 +1,77 @@
1
+ """板卡配置加载:boards/*.toml + 默认值合并(TOML 同名键覆盖)"""
2
+ import os
3
+ import sys
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+ # 项目根目录(本包的上一级):开发运行时 boards/、run.sh 等在这里
8
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ # 全局默认值(仅真正跨模块的段;插件自己的默认值由插件模块的 DEFAULTS 提供)
11
+ DEFAULTS = {
12
+ 'serial': {'url': 'socket://localhost:5000', 'timeout': 0.2},
13
+ 'uboot': {
14
+ 'prompt': '=>',
15
+ 'load_addr': '0x80080000',
16
+ 'ip_addr': '',
17
+ 'server_ip': '',
18
+ 'ensure_server_ip': False,
19
+ },
20
+ }
21
+
22
+
23
+ def boards_dirs():
24
+ """板卡配置目录搜索顺序(去重,仅保留存在的):
25
+ $BOARDCTL_BOARDS → 当前目录 boards/ → ~/.config/boardctl/boards → 包内置示例
26
+ """
27
+ candidates = [
28
+ os.environ.get('BOARDCTL_BOARDS'),
29
+ os.path.join(os.getcwd(), 'boards'),
30
+ str(Path.home() / '.config' / 'boardctl' / 'boards'),
31
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), 'boards'),
32
+ ]
33
+ seen, out = set(), []
34
+ for d in candidates:
35
+ if d:
36
+ d = os.path.abspath(d)
37
+ if d not in seen and os.path.isdir(d):
38
+ seen.add(d)
39
+ out.append(d)
40
+ return out
41
+
42
+
43
+ def available_boards():
44
+ """全部可用板卡名(按目录优先级去重,先出现的优先)"""
45
+ names = {}
46
+ for d in boards_dirs():
47
+ for f in sorted(os.listdir(d)):
48
+ if f.endswith('.toml'):
49
+ names.setdefault(f[:-5], os.path.join(d, f))
50
+ return names
51
+
52
+
53
+ def load_board(name):
54
+ boards = available_boards()
55
+ if name not in boards:
56
+ sys.exit(f"未知开发板 {name!r},可用: {' '.join(sorted(boards)) or '(没有任何 boards/ 目录里有配置)'}")
57
+ with open(boards[name], 'rb') as f:
58
+ data = tomllib.load(f)
59
+ cfg = {'name': name, 'description': data.get('description', ''),
60
+ 'ssh_host': data.get('ssh_host', '')}
61
+ for section, defaults in DEFAULTS.items():
62
+ cfg[section] = {**defaults, **data.get(section, {})}
63
+ cfg['power'] = dict(data.get('power', {}))
64
+ cfg['tftp'] = dict(data.get('tftp', {}))
65
+ cfg['run'] = data.get('run', {})
66
+
67
+ # 插件自带默认值:按插件的 CFG_SECTION 声明合并(TOML 值优先)。
68
+ # 函数内 import,避免 config <-> plugins 模块级循环依赖
69
+ from .plugins import all_plugins
70
+ for mod in all_plugins():
71
+ section = getattr(mod, 'CFG_SECTION', None)
72
+ defaults = getattr(mod, 'DEFAULTS', None)
73
+ if section and isinstance(defaults, dict):
74
+ merged = dict(defaults)
75
+ merged.update(cfg.get(section, {}))
76
+ cfg[section] = merged
77
+ return cfg
boardctl/console.py ADDED
@@ -0,0 +1,207 @@
1
+ """交互式串口终端:raw 模式、按键本地命令、autoboot 自动拦截、退出自动断电"""
2
+ import codecs
3
+ import os
4
+ import select
5
+ import signal
6
+ import sys
7
+ import termios
8
+ import threading
9
+ import time
10
+ import tty
11
+
12
+ import serial
13
+
14
+ from . import power
15
+
16
+ CTRL_A = 0x01
17
+ QUIT_BYTE = 0x1C # Ctrl-\ 任意时刻直接退出
18
+ QUIT_KEYS = (0x71, 0x78, 0x18) # Ctrl-A q / Ctrl-A x(ostool 惯例) / Ctrl-A Ctrl-X
19
+
20
+ BOOT_MARKERS = ('u-boot', 'hit any key', 'autoboot', 'arp retry')
21
+ SERVER_ERROR = 'device open failure'
22
+ INTERRUPT_INTERVAL = 0.1
23
+ INTERRUPT_WINDOW = 15.0
24
+
25
+ HELP = (
26
+ '\r\n[本地按键]\r\n'
27
+ ' Ctrl-\\ 退出(任意时刻)\r\n'
28
+ ' Ctrl-A x/q 退出\r\n'
29
+ ' Ctrl-A b 手动开启一轮 autoboot 拦截\r\n'
30
+ ' Ctrl-A h 显示本帮助\r\n'
31
+ ' 其他按键 原样转发到设备(包括 Ctrl-C)\r\n'
32
+ )
33
+
34
+
35
+ def cmsg(text):
36
+ print(f'\r\n{text}\r\n', end='', flush=True)
37
+
38
+
39
+ def console_reader(ser, write_lock, manual_trigger, stop, quit_event, prompts):
40
+ decoder = codecs.getincrementaldecoder('utf-8')(errors='replace')
41
+ buf = ''
42
+ window_until = 0.0
43
+ last_sent = 0.0
44
+
45
+ while not stop.is_set() and not quit_event.is_set():
46
+ try:
47
+ data = ser.read(100)
48
+ except Exception: # 关闭竞态:主线程 close() 后底层 socket 置 None
49
+ return
50
+ if data:
51
+ text = decoder.decode(data)
52
+ print(text, end='', flush=True)
53
+ low = text.lower()
54
+ if SERVER_ERROR in low:
55
+ cmsg('串口服务器打不开物理串口(服务器端设备不可用),稍后重连')
56
+ return
57
+ buf = (buf + low)[-160:]
58
+
59
+ now = time.monotonic()
60
+ if manual_trigger.is_set():
61
+ manual_trigger.clear()
62
+ window_until = now + INTERRUPT_WINDOW
63
+
64
+ if window_until == 0.0:
65
+ if any(m in buf for m in BOOT_MARKERS):
66
+ buf = ''
67
+ window_until = now + INTERRUPT_WINDOW
68
+ cmsg('检测到启动特征,发送按键打断 autoboot...')
69
+ elif now < window_until:
70
+ if any(p in buf for p in prompts):
71
+ buf = ''
72
+ window_until = 0.0 # 已停在提示符,停止发送
73
+ elif now - last_sent >= INTERRUPT_INTERVAL:
74
+ last_sent = now
75
+ try:
76
+ with write_lock:
77
+ ser.write(b'\r\x03')
78
+ except Exception: # 同上,关闭竞态
79
+ return
80
+ else:
81
+ buf = ''
82
+ window_until = 0.0
83
+
84
+
85
+ def handle_stdin(data, ser, write_lock, manual_trigger, state):
86
+ for c in data:
87
+ if c == QUIT_BYTE:
88
+ return 'quit'
89
+ if state['prefix']:
90
+ state['prefix'] = False
91
+ if c in QUIT_KEYS:
92
+ return 'quit'
93
+ if c == ord('b'):
94
+ manual_trigger.set()
95
+ cmsg('手动触发拦截窗口')
96
+ elif c == ord('h'):
97
+ print(HELP, end='', flush=True)
98
+ elif c == CTRL_A:
99
+ with write_lock:
100
+ ser.write(bytes([CTRL_A]))
101
+ elif c == CTRL_A:
102
+ state['prefix'] = True
103
+ else:
104
+ with write_lock:
105
+ ser.write(bytes([c]))
106
+ return None
107
+
108
+
109
+ def console_session(ser, write_lock, manual_trigger, stop, quit_event, state, prompts):
110
+ reader = threading.Thread(
111
+ target=console_reader,
112
+ args=(ser, write_lock, manual_trigger, stop, quit_event, prompts),
113
+ daemon=True)
114
+ reader.start()
115
+
116
+ with write_lock:
117
+ ser.write(b'\r')
118
+ manual_trigger.set()
119
+
120
+ while True:
121
+ if quit_event.is_set():
122
+ return 'quit'
123
+ r, _, _ = select.select([sys.stdin], [], [], 0.2)
124
+ if r:
125
+ data = os.read(sys.stdin.fileno(), 1024)
126
+ if not data:
127
+ return 'quit'
128
+ try:
129
+ if handle_stdin(data, ser, write_lock, manual_trigger, state) == 'quit':
130
+ return 'quit'
131
+ except Exception: # 串口失效(含关闭竞态)
132
+ return 'reconnect'
133
+ if not reader.is_alive():
134
+ return 'reconnect'
135
+
136
+
137
+ def console_wait_or_quit(seconds, state, quit_event):
138
+ end = time.monotonic() + seconds
139
+ while time.monotonic() < end:
140
+ if quit_event.is_set():
141
+ return False
142
+ r, _, _ = select.select([sys.stdin], [], [], 0.2)
143
+ if not r:
144
+ continue
145
+ data = os.read(sys.stdin.fileno(), 1024)
146
+ if not data:
147
+ return False
148
+ for c in data:
149
+ if c == QUIT_BYTE or (state['prefix'] and c in QUIT_KEYS):
150
+ return False
151
+ state['prefix'] = (c == CTRL_A)
152
+ return True
153
+
154
+
155
+ def do_console(cfg):
156
+ prompts = list(dict.fromkeys([cfg['uboot']['prompt'], '=>']))
157
+ raw_mode = sys.stdin.isatty()
158
+ if raw_mode:
159
+ old_attrs = termios.tcgetattr(sys.stdin.fileno())
160
+ tty.setraw(sys.stdin.fileno())
161
+
162
+ write_lock = threading.Lock()
163
+ manual_trigger = threading.Event()
164
+ stop = threading.Event()
165
+ quit_event = threading.Event()
166
+ state = {'prefix': False}
167
+ signal.signal(signal.SIGTERM, lambda *_: quit_event.set())
168
+ signal.signal(signal.SIGINT, lambda *_: quit_event.set())
169
+
170
+ power_off = cfg['power'].get('off_on_exit') and cfg['power'].get('off_cmd')
171
+ banner = f'连接 {cfg["serial"]["url"]} | Ctrl-\\ 退出, Ctrl-A h 帮助'
172
+ if power_off:
173
+ banner += ',退出时自动断电'
174
+ cmsg(banner)
175
+ try:
176
+ while True:
177
+ try:
178
+ ser = serial.serial_for_url(
179
+ cfg['serial']['url'], timeout=cfg['serial']['timeout'])
180
+ except serial.SerialException as e:
181
+ cmsg(f'连接失败: {e},3 秒后重试')
182
+ if not console_wait_or_quit(3, state, quit_event):
183
+ return
184
+ continue
185
+ try:
186
+ result = console_session(
187
+ ser, write_lock, manual_trigger, stop, quit_event, state, prompts)
188
+ finally:
189
+ try:
190
+ ser.close()
191
+ except OSError:
192
+ pass
193
+ if result == 'quit' or quit_event.is_set():
194
+ return
195
+ if not console_wait_or_quit(3, state, quit_event):
196
+ return
197
+ finally:
198
+ stop.set()
199
+ if raw_mode:
200
+ termios.tcdrain(sys.stdin.fileno())
201
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_attrs)
202
+ # 按配置在退出时断电(power.off_on_exit)
203
+ if power_off:
204
+ cmsg('退出:执行断电(power.off_on_exit)')
205
+ if not power.power_off(cfg, check=False):
206
+ cmsg('断电失败,请手动确认电源状态')
207
+ cmsg('已退出')
@@ -0,0 +1,42 @@
1
+ """插件注册表:按目录约定自动发现,无需注册代码。
2
+
3
+ - plugins/transport/<mod>.py 传输插件
4
+ - plugins/executors/<mod>.py 执行插件
5
+ - plugins/power/<mod>.py 电源插件
6
+
7
+ 新建插件 = 在对应目录加一个模块文件,自动生效。
8
+ """
9
+ import importlib
10
+ import pkgutil
11
+
12
+ from . import executors, power, transport
13
+
14
+ #: 传输插件注册表 {NAME: module},接口见 transport/__init__.py
15
+ TRANSPORT = {}
16
+
17
+ #: 执行插件注册表 {NAME: module},接口见 executors/__init__.py
18
+ EXECUTORS = {}
19
+
20
+ #: 电源插件注册表 {NAME: module},接口见 power/__init__.py
21
+ POWER = {}
22
+
23
+
24
+ def _scan(package, registry):
25
+ for m in pkgutil.iter_modules(package.__path__):
26
+ module = importlib.import_module(f'{package.__name__}.{m.name}')
27
+ name = getattr(module, 'NAME', None)
28
+ if name:
29
+ registry[name] = module
30
+
31
+
32
+ _scan(transport, TRANSPORT)
33
+ _scan(executors, EXECUTORS)
34
+ _scan(power, POWER)
35
+
36
+
37
+ def all_plugins():
38
+ """全部已注册插件模块的列表(config 合并插件自带 DEFAULTS 用)"""
39
+ mods = []
40
+ for registry in (TRANSPORT, EXECUTORS, POWER):
41
+ mods.extend(registry.values())
42
+ return mods
@@ -0,0 +1,8 @@
1
+ """执行插件接口约定(文件加载后如何启动):
2
+
3
+ 每个插件模块需要提供:
4
+ NAME: str # 名字,对应 [run.*].exec
5
+ build_cmd(addr) -> str | None # 返回要在 U-Boot 执行的命令;None = 只加载不执行
6
+
7
+ 在此目录新建 .py 文件即自动注册。
8
+ """
@@ -0,0 +1,7 @@
1
+ """go 执行插件:跳转到加载地址执行裸机代码(不保证返回,程序常以死循环结束)"""
2
+
3
+ NAME = 'go'
4
+
5
+
6
+ def build_cmd(addr):
7
+ return f'go {addr}'
@@ -0,0 +1,7 @@
1
+ """none 执行插件:只把文件加载到内存,不执行(配合后续手动操作)"""
2
+
3
+ NAME = 'none'
4
+
5
+
6
+ def build_cmd(addr):
7
+ return None
@@ -0,0 +1,7 @@
1
+ """source 执行插件:把加载的 uImage 脚本当 U-Boot 命令序列执行,结束回提示符"""
2
+
3
+ NAME = 'source'
4
+
5
+
6
+ def build_cmd(addr):
7
+ return f'source {addr}'
@@ -0,0 +1,10 @@
1
+ """电源插件接口约定:
2
+
3
+ 每个插件模块需要提供:
4
+ NAME: str # 名字,对应 [power].method
5
+ set_power(cfg, on: bool) # 开/关设备电源
6
+ get_power(cfg) -> bool # 查询电源状态
7
+
8
+ 插件在 boardctl 进程内原生执行(不经过 ssh_host)。
9
+ 在此目录新建 .py 文件即自动注册。
10
+ """
@@ -0,0 +1,31 @@
1
+ """command 电源插件:执行 [power] 里配置的 on_cmd / off_cmd / status_cmd 命令
2
+
3
+ "特制开关机命令"方式:任意 shell 命令,经顶层 ssh_host 决定本机/远端执行。
4
+ status_cmd 可缺省;命令输出无法可靠解析时 get_power 返回 None(上层原样打印)。
5
+ method 未配置时默认即本插件。
6
+ """
7
+ import sys
8
+
9
+ from ...shell import run as run_shell
10
+
11
+ NAME = 'command'
12
+ CFG_SECTION = 'power'
13
+ DEFAULTS = {'on_cmd': None, 'off_cmd': None, 'status_cmd': None}
14
+
15
+
16
+ def _cmd(cfg, key):
17
+ command = cfg['power'].get(key)
18
+ if not command:
19
+ sys.exit(f'[power] 缺少 {key}(command 电源插件需要 on_cmd/off_cmd)')
20
+ return command
21
+
22
+
23
+ def set_power(cfg, on):
24
+ run_shell(cfg, _cmd(cfg, 'on_cmd' if on else 'off_cmd'), check=True)
25
+
26
+
27
+ def get_power(cfg):
28
+ status = cfg['power'].get('status_cmd')
29
+ if status:
30
+ run_shell(cfg, status, check=False)
31
+ return None # 任意命令的输出无法可靠解析为 bool
@@ -0,0 +1,37 @@
1
+ """mijia 电源插件:原生调用 mijiaapi 包,经小米云控制智能插座
2
+
3
+ 凭证复用 mijiaAPI CLI 的登录态(~/.config/mijia-api/auth.json),
4
+ 首次使用前需先执行一次 `mijiaAPI login` 扫码。
5
+ """
6
+ import threading
7
+
8
+ NAME = 'mijia'
9
+
10
+ _lock = threading.Lock()
11
+ _dev_cache = {} # (did, dev_name) -> mijiaDevice,避免每次操作都拉设备列表
12
+
13
+
14
+ def _device(cfg):
15
+ with _lock:
16
+ p = cfg['power'].get('mijia', {})
17
+ key = (p.get('did'), p.get('dev_name'))
18
+ if key not in _dev_cache:
19
+ from mijiaAPI.apis import mijiaAPI
20
+ from mijiaAPI.devices import mijiaDevice
21
+ kwargs = {}
22
+ if p.get('did'):
23
+ kwargs['did'] = p['did']
24
+ elif p.get('dev_name'):
25
+ kwargs['dev_name'] = p['dev_name']
26
+ else:
27
+ raise ValueError('[power.mijia] 需要 dev_name 或 did')
28
+ _dev_cache[key] = mijiaDevice(mijiaAPI(), **kwargs)
29
+ return _dev_cache[key]
30
+
31
+
32
+ def set_power(cfg, on):
33
+ _device(cfg).set('on', bool(on))
34
+
35
+
36
+ def get_power(cfg):
37
+ return bool(_device(cfg).get('on'))
@@ -0,0 +1,9 @@
1
+ """传输插件接口约定:
2
+
3
+ 每个插件模块需要提供:
4
+ NAME: str # 名字,对应 --method / [run.*].method
5
+ send(cfg, path, addr) -> bool # 把文件传到设备的 addr,成功返回 True
6
+
7
+ 可用的依赖:boardctl.config(BASE_DIR 等)、boardctl.session(UbootSession)。
8
+ 插件之间禁止互相 import。在此目录新建 .py 文件即自动注册。
9
+ """
@@ -0,0 +1,70 @@
1
+ """loady 传输插件:设备端 loady(Ymodem)+ 本机 lrzsz 发送器,走串口,零网络依赖"""
2
+ import fcntl
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import time
9
+
10
+ from ...session import UbootSession
11
+
12
+ NAME = 'loady'
13
+ CFG_SECTION = 'loady'
14
+ DEFAULTS = {'sender': ''} # Ymodem 发送器;空则自动找 lrzsz-sb / sb
15
+
16
+
17
+ def find_sender(cfg):
18
+ sender = cfg['loady'].get('sender')
19
+ if sender and os.path.isfile(sender):
20
+ return sender
21
+ return shutil.which('lrzsz-sb') or shutil.which('sb')
22
+
23
+
24
+ def send(cfg, path, addr):
25
+ sender = find_sender(cfg)
26
+ if not sender:
27
+ sys.exit('找不到 Ymodem 发送器(Arch: lrzsz 包的 lrzsz-sb;Debian: lrzsz 的 sb)')
28
+ with UbootSession(cfg) as s:
29
+ ok, _ = s.wait_prompt()
30
+ if not ok:
31
+ sys.exit('等待 U-Boot 提示符超时,设备可能不在 U-Boot 命令行')
32
+ s.ser.write(f'loady {addr}\r'.encode())
33
+ time.sleep(1.5) # 等设备进入 Ymodem 接收态
34
+
35
+ # socket:// 的 pyserial 对象没有 fileno(),用底层 socket
36
+ raw = getattr(s.ser, 'sock', None)
37
+ fd = raw.fileno() if raw is not None else None
38
+ if fd is None:
39
+ try:
40
+ fd = s.ser.fileno()
41
+ except Exception:
42
+ fd = None
43
+ if fd is None:
44
+ sys.exit('该串口 URL 不支持把 fd 交给 Ymodem 发送器')
45
+ # 关键: 清掉 settimeout() 设置的 O_NONBLOCK,否则子进程 read 得到 EAGAIN 被当成超时
46
+ flags = fcntl.fcntl(fd, fcntl.F_GETFL)
47
+ fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
48
+
49
+ p = subprocess.Popen([sender, os.path.abspath(path)],
50
+ stdin=fd, stdout=fd, stderr=subprocess.PIPE)
51
+ try:
52
+ _, err = p.communicate(timeout=180)
53
+ except subprocess.TimeoutExpired:
54
+ p.kill()
55
+ p.communicate()
56
+ print('Ymodem 发送器超时(180s),已终止')
57
+ return False
58
+ tail, _ = s.read_until(s.cfg['uboot']['prompt'], 10)
59
+ print(err.decode('utf-8', 'replace').strip())
60
+ print(tail.strip('\r\n'))
61
+ m = re.search(r'Total Size\s*=\s*(0x[0-9a-fA-F]+|\d+)', tail)
62
+ if m:
63
+ size = int(m.group(1), 0)
64
+ actual = os.path.getsize(path)
65
+ ok = size == actual
66
+ print(f'loady {"OK" if ok else "大小不符"}: {size} 字节 -> {addr}'
67
+ + ('' if ok else f'(本地 {actual})'))
68
+ return ok
69
+ print('loady 传输失败(未见 Total Size 报告)')
70
+ return False