97 lines
3.7 KiB
Python
97 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Private SSH-stdio transport for one explicitly selected lab USB CLI port.
|
|
|
|
Stdout is exclusively bounded JSON-lines. The SSH connection owns the serial
|
|
session; EOF or a malformed/incomplete command closes it, without rebooting.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import math
|
|
import sys
|
|
|
|
MAX_LINE = 65536
|
|
MAX_COMMAND = 512
|
|
PROTOCOL = "meshcore-serial-rpc-1"
|
|
|
|
|
|
def validate_command(command, timeout):
|
|
if not isinstance(command, str) or not command or len(command) > MAX_COMMAND \
|
|
or any(character in command for character in "\r\n\0"):
|
|
raise ValueError("a bounded single-line command is required")
|
|
command.encode("ascii")
|
|
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) \
|
|
or not math.isfinite(timeout) or not 0 < timeout <= 30:
|
|
raise ValueError("command timeout must be finite and in (0,30]")
|
|
|
|
|
|
def encode_message(message):
|
|
data = (json.dumps(message, ensure_ascii=True, allow_nan=False) + "\n").encode("ascii")
|
|
if len(data) > MAX_LINE:
|
|
raise ValueError("RPC message exceeds the line limit")
|
|
return data
|
|
|
|
|
|
def decode_message(line):
|
|
if not line or len(line) > MAX_LINE or not line.endswith(b"\n"):
|
|
raise ValueError("RPC requires bounded, complete JSON lines")
|
|
message = json.loads(line.decode("utf-8"), parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value)))
|
|
if not isinstance(message, dict):
|
|
raise ValueError("RPC message must be an object")
|
|
return message
|
|
|
|
|
|
def serve(node, source, destination):
|
|
def send(message):
|
|
destination.write(encode_message(message))
|
|
destination.flush()
|
|
|
|
try:
|
|
send(dict(type="ready", protocol=PROTOCOL, identity=node.identity,
|
|
hello=getattr(node, "hello", "")))
|
|
while True:
|
|
line = source.readline(MAX_LINE + 1)
|
|
if not line:
|
|
return 0
|
|
request_id = None
|
|
try:
|
|
request = decode_message(line)
|
|
if set(request) != {"id", "command", "timeout"}:
|
|
raise ValueError("unexpected RPC request fields")
|
|
request_id = request["id"]
|
|
if type(request_id) is not int or request_id <= 0:
|
|
raise ValueError("positive integer request id required")
|
|
validate_command(request["command"], request["timeout"])
|
|
# No incidental diagnostic output may corrupt the protocol.
|
|
with contextlib.redirect_stdout(sys.stderr):
|
|
reply = node.command(request["command"], timeout=request["timeout"])
|
|
if not isinstance(reply, str):
|
|
raise ValueError("serial command did not return a text reply")
|
|
send(dict(id=request_id, completed=True, reply=reply))
|
|
except Exception as error:
|
|
send(dict(id=request_id, completed=False, error=str(error)[:1024]))
|
|
return 1 # Never attribute a late serial reply to another command.
|
|
finally:
|
|
with contextlib.redirect_stdout(sys.stderr):
|
|
node.close()
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--port", required=True)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
from ethernet_primary_probe import SerialNode
|
|
with contextlib.redirect_stdout(sys.stderr):
|
|
node = SerialNode(args.port)
|
|
return serve(node, sys.stdin.buffer, sys.stdout.buffer)
|
|
except Exception as error:
|
|
print("Serial RPC stopped: " + str(error), file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|