Skip to content

Commit f13f563

Browse files
committed
Add fetch_configs and reboot utility scripts
Add two command-line utilities under common/scripts: fetch_configs.py: Downloads JSON endpoints from a remote node (defaults /json/version and /json/list plus all entries from /json/config/directory), saves them to a local directory, and resolves the host once (prefers IPv4) to avoid repeated mDNS/DNS delays. Includes robust HTTP error handling and JSON validation. reboot.py: UDP-based reboot helper that queries devices with ?list#, issues ?reboot##, and waits for the device to come back online with a spinner. Supports a --forever/-f loop mode and uses udp_send.send_and_maybe_recv for communication.
1 parent 08e71d6 commit f13f563

2 files changed

Lines changed: 287 additions & 0 deletions

File tree

common/scripts/fetch_configs.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Download config JSON files listed by /json/config/directory from a remote node,
4+
plus default endpoints: /json/version and /json/list.
5+
6+
This version resolves the host ONCE (e.g. .local via mDNS) and then uses the IP
7+
for all subsequent HTTP requests to avoid repeated resolver delays.
8+
9+
Usage:
10+
python3 fetch_configs.py <remote_node> <local_directory>
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import json
17+
import re
18+
import socket
19+
import sys
20+
import urllib.error
21+
import urllib.request
22+
from pathlib import Path
23+
from typing import Any, Dict, Tuple
24+
25+
26+
def normalize_host(host: str) -> str:
27+
host = host.strip()
28+
host = re.sub(r"^https?://", "", host) # remove scheme if provided
29+
host = host.split("/")[0] # remove any path
30+
host = host.rstrip(".") # handle ".local." style
31+
return host
32+
33+
34+
def resolve_host_once(host: str) -> Tuple[str, str]:
35+
"""
36+
Resolve host to an IP once.
37+
Returns (display_host, ip) where display_host is the original host for logging.
38+
"""
39+
try:
40+
# getaddrinfo gives us both IPv4/IPv6; prefer IPv4 for embedded nodes.
41+
infos = socket.getaddrinfo(host, 80, type=socket.SOCK_STREAM)
42+
except socket.gaierror as e:
43+
raise RuntimeError(f"DNS/mDNS lookup failed for {host}: {e}") from e
44+
45+
ipv4 = next((ai for ai in infos if ai[0] == socket.AF_INET), None)
46+
chosen = ipv4 or infos[0]
47+
ip = chosen[4][0]
48+
return host, ip
49+
50+
51+
def http_get_json(url: str, timeout: float = 10.0) -> Any:
52+
req = urllib.request.Request(
53+
url,
54+
headers={
55+
"Accept": "application/json",
56+
"User-Agent": "fetch-configs/1.2",
57+
},
58+
method="GET",
59+
)
60+
try:
61+
with urllib.request.urlopen(req, timeout=timeout) as resp:
62+
charset = resp.headers.get_content_charset() or "utf-8"
63+
data = resp.read().decode(charset, errors="replace")
64+
return json.loads(data)
65+
except urllib.error.HTTPError as e:
66+
body = ""
67+
try:
68+
body = e.read().decode("utf-8", errors="replace")
69+
except Exception:
70+
pass
71+
raise RuntimeError(f"HTTP {e.code} for {url}\n{body}".strip()) from e
72+
except urllib.error.URLError as e:
73+
raise RuntimeError(f"URL error for {url}: {e.reason}") from e
74+
except json.JSONDecodeError as e:
75+
raise RuntimeError(f"Invalid JSON from {url}: {e}") from e
76+
77+
78+
def save_json(out_dir: Path, filename: str, payload: Any) -> None:
79+
path = out_dir / filename
80+
with path.open("w", encoding="utf-8") as f:
81+
json.dump(payload, f, indent=4, sort_keys=True)
82+
f.write("\n")
83+
84+
85+
def fetch_and_save(ip: str, out_dir: Path, url_path: str, out_name: str) -> bool:
86+
url_path = url_path if url_path.startswith("/") else "/" + url_path
87+
url = f"http://{ip}{url_path}"
88+
try:
89+
payload = http_get_json(url)
90+
save_json(out_dir, out_name, payload)
91+
print(f"[OK] {url} -> {out_name}")
92+
return True
93+
except Exception as e:
94+
print(f"[FAIL] {url}: {e}", file=sys.stderr)
95+
return False
96+
97+
98+
def main() -> int:
99+
ap = argparse.ArgumentParser(description="Fetch remote JSON endpoints into a local directory.")
100+
ap.add_argument("remote_node", help="Hostname/IP (optionally with scheme), e.g. gigadevice_486149.local.")
101+
ap.add_argument("local_directory", help="Destination directory")
102+
args = ap.parse_args()
103+
104+
host = normalize_host(args.remote_node)
105+
out_dir = Path(args.local_directory).expanduser().resolve()
106+
out_dir.mkdir(parents=True, exist_ok=True)
107+
108+
print(f"Resolving: {host}")
109+
# Resolve once (fixes repeated 5s delays with .local/mDNS)
110+
display_host, ip = resolve_host_once(host)
111+
112+
print(f"Remote: {display_host} (resolved -> {ip})")
113+
print(f"Output: {out_dir}")
114+
print("")
115+
116+
ok = 0
117+
fail = 0
118+
119+
# 1) Default endpoints
120+
defaults = {
121+
"/json/version": "version.json",
122+
"/json/list": "list.json",
123+
}
124+
125+
for path, out_name in defaults.items():
126+
if fetch_and_save(ip, out_dir, path, out_name):
127+
ok += 1
128+
else:
129+
fail += 1
130+
131+
# 2) Directory + config endpoints
132+
directory_url = f"http://{ip}/json/config/directory"
133+
try:
134+
directory = http_get_json(directory_url)
135+
print(f"[OK] {directory_url} -> config_directory.json")
136+
save_json(out_dir, "config_directory.json", directory)
137+
ok += 1
138+
except Exception as e:
139+
print(f"[FAIL] {directory_url}: {e}", file=sys.stderr)
140+
fail += 1
141+
print("")
142+
print(f"Done. OK={ok} FAIL={fail}")
143+
return 2
144+
145+
files: Dict[str, Any] = {}
146+
if isinstance(directory, dict) and isinstance(directory.get("files"), dict):
147+
files = directory["files"]
148+
else:
149+
print(f"[FAIL] Unexpected directory JSON shape from {directory_url}", file=sys.stderr)
150+
print("")
151+
print(f"Done. OK={ok} FAIL={fail + 1}")
152+
return 2
153+
154+
for path_key in files.keys():
155+
remote_path = path_key.lstrip("/")
156+
url_path = f"/json/{remote_path}"
157+
basename = remote_path.split("/")[-1]
158+
out_name = f"{basename}.json"
159+
160+
if fetch_and_save(ip, out_dir, url_path, out_name):
161+
ok += 1
162+
else:
163+
fail += 1
164+
165+
print("")
166+
print(f"Done. OK={ok} FAIL={fail}")
167+
return 0 if fail == 0 else 2
168+
169+
170+
if __name__ == "__main__":
171+
raise SystemExit(main())

common/scripts/reboot.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env python3
2+
"""
3+
reboot.py
4+
5+
Default:
6+
Reboot once.
7+
8+
Optional:
9+
--forever / -f Loop forever
10+
11+
Usage:
12+
python3 reboot.py <ip_address>
13+
python3 reboot.py <ip_address> --forever
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import sys
19+
import time
20+
import argparse
21+
from typing import Optional
22+
23+
from udp_send import send_and_maybe_recv
24+
25+
26+
LIST_CMD = b"?list#"
27+
REBOOT_CMD = b"?reboot##"
28+
29+
30+
def _reply_text(reply: Optional[bytes]) -> str:
31+
if not reply:
32+
return ""
33+
try:
34+
return reply.decode("utf-8", errors="replace").strip()
35+
except Exception:
36+
return repr(reply)
37+
38+
39+
def _color(s: str, code: str) -> str:
40+
return f"\033[{code}m{s}\033[0m"
41+
42+
43+
def wait_for_list(ip: str, *, timeout_sec: float = 1.0) -> str:
44+
while True:
45+
_sent, reply = send_and_maybe_recv(ip, LIST_CMD, timeout_sec=timeout_sec)
46+
text = _reply_text(reply)
47+
if text:
48+
return text
49+
time.sleep(0.1)
50+
51+
52+
def spinner_wait_for_online(ip: str, *, timeout_sec: float = 1.0) -> str:
53+
frames = ["|", "/", "-", "\\"]
54+
i = 0
55+
last_poll = 0.0
56+
poll_interval = 0.25
57+
58+
while True:
59+
now = time.monotonic()
60+
if now - last_poll >= poll_interval:
61+
last_poll = now
62+
_sent, reply = send_and_maybe_recv(ip, LIST_CMD, timeout_sec=timeout_sec)
63+
text = _reply_text(reply)
64+
if text:
65+
sys.stdout.write("\r" + " " * 60 + "\r")
66+
sys.stdout.flush()
67+
return text
68+
69+
sys.stdout.write(f"\rWaiting for reboot {frames[i % len(frames)]}")
70+
sys.stdout.flush()
71+
i += 1
72+
time.sleep(0.08)
73+
74+
75+
def do_reboot_cycle(ip: str) -> None:
76+
_sent, reply = send_and_maybe_recv(ip, LIST_CMD, timeout_sec=1.0)
77+
online = _reply_text(reply)
78+
79+
print(_color(f"[{online}]", "33")) # yellow
80+
81+
if not online:
82+
online = wait_for_list(ip)
83+
print(_color(f"[{online}]", "36")) # cyan
84+
85+
time.sleep(2)
86+
87+
send_and_maybe_recv(ip, REBOOT_CMD, timeout_sec=1.0)
88+
89+
online_after = spinner_wait_for_online(ip)
90+
print(_color(f"Back online: [{online_after}]", "32")) # green
91+
92+
93+
def main(argv: list[str]) -> int:
94+
parser = argparse.ArgumentParser(description="UDP reboot tool")
95+
parser.add_argument("ip", help="Target IP address")
96+
parser.add_argument(
97+
"-f",
98+
"--forever",
99+
action="store_true",
100+
help="Reboot forever (loop mode)",
101+
)
102+
103+
args = parser.parse_args(argv[1:])
104+
105+
if args.forever:
106+
while True:
107+
do_reboot_cycle(args.ip)
108+
else:
109+
do_reboot_cycle(args.ip)
110+
111+
return 0
112+
113+
114+
if __name__ == "__main__":
115+
raise SystemExit(main(sys.argv))
116+

0 commit comments

Comments
 (0)