velxio/test/pi3_protocols/test_pi3_bme280_attach.py

284 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""
Phase 2.5 end-to-end: BME280 attach over I2C
============================================
Boots the Pi 3 (-M virt) the exact same way ``qemu_manager.py`` does
(pipe chardev for the protocol channel) and then runs a small
host-side loop that mirrors what ``QemuManager._handle_gpio_line``
does for I2C frames: it forwards the frame to the
``pi_protocol_dispatcher`` in the pro overlay, then writes the reply
back over the proto pipe.
We pre-attach a BME280 to the per-client ``PiSlaveRegistry`` via
``pi_slave_handler.handle('attach', ...)`` before booting, then run
guest Python:
import smbus2
bus = smbus2.SMBus(1)
chip = bus.read_byte_data(0x76, 0xD0)
print(f'CHIP=0x{chip:02x}')
Assertion: console reads back ``CHIP=0x60`` (the real BME280's chip ID).
What this catches (above and beyond test_pi3_protocols.py):
- dispatcher → registry → model lookup chain
- shim I2C wire format (RR with hex register / length tokens)
- reply line emitted as ``I2C_DATA <bus> <addr> <hex>``
- shim correctly parsing the reply hex back into an int
Run inside the velxio-app container:
docker exec velxio-app python3 /tmp/test_pi3_bme280_attach.py
"""
from __future__ import annotations
import asyncio
import base64
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
# Make `app.*` resolvable. Inside the prod container the backend
# package lives at /app/app/ — the pro overlay is COPYed beside it
# at build time as /app/app/pro/.
sys.path.insert(0, '/app')
BOOT_IMAGES = Path('/var/cache/velxio/boot-images/raspberry-pi-3-virt')
KERNEL = BOOT_IMAGES / 'velxio-kernel-arm64'
INITRAMFS = BOOT_IMAGES / 'velxio-initramfs-arm64.cpio.gz'
ROOTFS = BOOT_IMAGES / 'velxio-pi-rootfs-arm64.ext4'
CLIENT_ID = 'phase2.5-bme280-test'
BOOT_TIMEOUT_S = 90
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
return s.getsockname()[1]
def _make_overlay() -> str:
overlay = tempfile.NamedTemporaryFile(suffix='.qcow2', delete=False)
overlay.close()
subprocess.run(
['qemu-img', 'create', '-f', 'qcow2',
'-b', str(ROOTFS), '-F', 'raw', overlay.name],
check=True, capture_output=True,
)
return overlay.name
def _mk_proto_pipe() -> str:
base = tempfile.mktemp(prefix='velxio-pi-bme280-test-')
for suffix in ('.in', '.out'):
os.mkfifo(base + suffix, 0o600)
return base
def _qemu_argv(overlay: str, cons_port: int, proto_base: str) -> list[str]:
return [
'qemu-system-aarch64',
'-M', 'virt', '-cpu', 'cortex-a53', '-smp', '4', '-m', '1G',
'-kernel', str(KERNEL), '-initrd', str(INITRAMFS),
'-drive', f'if=none,file={overlay},format=qcow2,id=rootfs',
'-device', 'virtio-blk-pci,drive=rootfs',
'-nic', 'none', '-display', 'none', '-monitor', 'none', '-serial', 'none',
'-chardev',
f'socket,id=cons,host=127.0.0.1,port={cons_port},server=on,wait=off',
'-device', 'virtio-serial-pci,id=virtio-serial0',
'-device', 'virtconsole,chardev=cons',
'-chardev', f'pipe,id=proto,path={proto_base}',
'-device', 'virtserialport,chardev=proto,name=velxio-protocol',
'-append', 'console=hvc0 root=/dev/vda rw panic=10',
]
def _proto_router_thread(proto_in: int, proto_out: int, stop: threading.Event) -> None:
"""Mirror QemuManager's _handle_gpio_line loop on the host side.
We poll ``proto_out`` (guest → host) for newline-terminated frames,
feed each one to the pro overlay's protocol dispatcher, and write
the reply (if any) back to ``proto_in`` (host → guest).
"""
from app.pro.services.pi_protocol_dispatcher import dispatch
buf = bytearray()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
while not stop.is_set():
try:
data = os.read(proto_out, 4096)
except BlockingIOError:
time.sleep(0.02)
continue
except OSError:
return
if not data:
time.sleep(0.02)
continue
buf.extend(data)
while b'\n' in buf:
line, _, rest = buf.partition(b'\n')
buf = bytearray(rest)
tokens = line.decode('ascii', 'replace').strip().split()
if not tokens or tokens[0] not in ('I2C', 'SPI', 'UART'):
continue
print(f'[proto] >>> {tokens}')
reply = loop.run_until_complete(dispatch(CLIENT_ID, tokens))
if reply:
print(f'[proto] <<< {reply}')
os.write(proto_in, (reply + '\n').encode('ascii'))
def run() -> int:
for p in (KERNEL, INITRAMFS, ROOTFS):
if not p.exists():
print(f'FAIL: missing boot image: {p}', file=sys.stderr)
return 2
# Pre-register the dispatcher + slave handler the same way
# register_pro() does, then attach a BME280 to the registry.
from app.pro.services import pi_slave_handler
asyncio.run(pi_slave_handler.handle(CLIENT_ID, 'attach', {
'bus_kind': 'i2c',
'bus_num': 1,
'address': 0x76,
'model_id': 'bme280',
'config': {'temperature_c': 22.0, 'humidity_pct': 47.0,
'pressure_pa': 101000.0},
}))
overlay = _make_overlay()
proto_base = _mk_proto_pipe()
cons_port = _find_free_port()
argv = _qemu_argv(overlay, cons_port, proto_base)
print('[test] launching:', ' '.join(argv))
proto_in = os.open(proto_base + '.in', os.O_RDWR | os.O_NONBLOCK)
proto_out = os.open(proto_base + '.out', os.O_RDWR | os.O_NONBLOCK)
stop = threading.Event()
router = threading.Thread(
target=_proto_router_thread,
args=(proto_in, proto_out, stop),
daemon=True,
)
router.start()
qemu = subprocess.Popen(
argv, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stdin=subprocess.DEVNULL,
)
try:
sock: socket.socket | None = None
deadline = time.monotonic() + BOOT_TIMEOUT_S
while time.monotonic() < deadline:
try:
sock = socket.create_connection(('127.0.0.1', cons_port),
timeout=5)
break
except (ConnectionRefusedError, OSError):
time.sleep(0.3)
if not sock:
print('FAIL: console TCP connection refused', file=sys.stderr)
return 1
sock.settimeout(2)
buf = bytearray()
saw_prompt = False
sent_test = False
while time.monotonic() < deadline:
try:
chunk = sock.recv(4096)
except socket.timeout:
continue
if not chunk:
break
buf.extend(chunk)
if not saw_prompt and b':~#' in buf:
saw_prompt = True
print('[test] bash prompt reached, sending Python BME280 read')
time.sleep(3)
try:
while True:
more = sock.recv(4096)
if not more:
break
buf.extend(more)
except socket.timeout:
pass
py = (
'import smbus2\n'
'bus = smbus2.SMBus(1)\n'
'chip = bus.read_byte_data(0x76, 0xD0)\n'
"print(f'CHIP=0x{chip:02x}')\n"
'data = bus.read_i2c_block_data(0x76, 0xF7, 8)\n'
"print('BLOCK=' + ''.join(f'{b:02x}' for b in data))\n"
)
b64 = base64.b64encode(py.encode()).decode()
cmd = (
f'echo {b64} | base64 -d > /tmp/bme280_test.py && '
f'python3 /tmp/bme280_test.py\n'
).encode()
sock.sendall(cmd)
sent_test = True
if sent_test and b'CHIP=0x60' in buf and b'BLOCK=' in buf:
print('[test] OK — guest read chip ID = 0x60')
# Also check that BLOCK= came back as 16 hex chars (8 bytes)
txt = buf.decode('utf-8', 'replace')
for ln in txt.splitlines():
if ln.startswith('BLOCK='):
# Allow trailing CR / ANSI from the shell echo
value = ln[len('BLOCK='):].strip().split()[0]
if len(value) == 16:
print(f'[test] OK — block read {ln}')
return 0
print(f'FAIL: block length wrong ({len(value)}): {ln}',
file=sys.stderr)
return 1
# Fall through: BLOCK= present but no parseable line yet,
# keep draining
continue
if sent_test and (b'Traceback' in buf or b'ModuleNotFoundError' in buf):
print('FAIL: guest python raised:', file=sys.stderr)
print(buf[-1500:].decode('utf-8', 'replace'))
return 1
print(f'FAIL: timeout. saw_prompt={saw_prompt} '
f'sent_test={sent_test} buf_len={len(buf)}',
file=sys.stderr)
print(buf[-1500:].decode('utf-8', 'replace'), file=sys.stderr)
return 1
finally:
stop.set()
try:
qemu.terminate()
qemu.wait(timeout=5)
except subprocess.TimeoutExpired:
qemu.kill()
for fd in (proto_in, proto_out):
try: os.close(fd)
except OSError: pass
for suffix in ('.in', '.out'):
try: os.unlink(proto_base + suffix)
except OSError: pass
try: os.unlink(overlay)
except OSError: pass
asyncio.run(pi_slave_handler.handle(CLIENT_ID, 'shutdown', {}))
if __name__ == '__main__':
sys.exit(run())