sima-cli 0.0.18__py3-none-any.whl → 0.0.20__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.
- sima_cli/__version__.py +1 -1
- sima_cli/auth/login.py +2 -2
- sima_cli/cli.py +111 -2
- sima_cli/install/__init__.py +0 -0
- sima_cli/install/hostdriver.py +152 -0
- sima_cli/install/optiview.py +85 -0
- sima_cli/install/palette.py +87 -0
- sima_cli/serial/__init__.py +0 -0
- sima_cli/serial/serial.py +114 -0
- sima_cli/update/bmaptool.py +137 -0
- sima_cli/update/bootimg.py +339 -0
- sima_cli/update/netboot.py +408 -0
- sima_cli/update/remote.py +42 -9
- sima_cli/update/updater.py +103 -33
- sima_cli/utils/env.py +2 -3
- sima_cli/utils/net.py +29 -0
- {sima_cli-0.0.18.dist-info → sima_cli-0.0.20.dist-info}/METADATA +3 -1
- {sima_cli-0.0.18.dist-info → sima_cli-0.0.20.dist-info}/RECORD +22 -12
- {sima_cli-0.0.18.dist-info → sima_cli-0.0.20.dist-info}/WHEEL +0 -0
- {sima_cli-0.0.18.dist-info → sima_cli-0.0.20.dist-info}/entry_points.txt +0 -0
- {sima_cli-0.0.18.dist-info → sima_cli-0.0.20.dist-info}/licenses/LICENSE +0 -0
- {sima_cli-0.0.18.dist-info → sima_cli-0.0.20.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,408 @@
|
|
1
|
+
from sima_cli.update.updater import download_image
|
2
|
+
from sima_cli.utils.net import get_local_ip_candidates
|
3
|
+
from sima_cli.update.remote import get_remote_board_info, copy_file_to_remote_board, DEFAULT_PASSWORD, run_remote_command, init_ssh_session
|
4
|
+
import os
|
5
|
+
import platform
|
6
|
+
import threading
|
7
|
+
import socket
|
8
|
+
import select
|
9
|
+
import time
|
10
|
+
import logging
|
11
|
+
import click
|
12
|
+
from errno import EINTR
|
13
|
+
from tftpy import TftpServer, TftpException, TftpTimeout, TftpTimeoutExpectACK, DEF_TFTP_PORT, DEF_TIMEOUT_RETRIES
|
14
|
+
from tftpy.TftpContexts import TftpContextServer
|
15
|
+
from tftpy.TftpPacketFactory import TftpPacketFactory
|
16
|
+
|
17
|
+
# Configuration constants
|
18
|
+
MAX_BLKSIZE = 1468 # Block size for MTU compatibility
|
19
|
+
SOCK_TIMEOUT = 2 # Timeout for faster retransmits
|
20
|
+
|
21
|
+
log = logging.getLogger("tftpy.InteractiveTftpServer")
|
22
|
+
emmc_image_paths = []
|
23
|
+
|
24
|
+
def flash_emmc(client_manager, emmc_image_paths):
|
25
|
+
"""Flash eMMC on a selected client device."""
|
26
|
+
clients = [
|
27
|
+
(ip, info) for ip, info in client_manager.get_client_info()
|
28
|
+
if info.get("state") == "Connected"
|
29
|
+
]
|
30
|
+
|
31
|
+
if not clients:
|
32
|
+
click.echo("📭 No connected clients available to flash.")
|
33
|
+
return
|
34
|
+
|
35
|
+
if len(clients) == 1:
|
36
|
+
selected_ip = clients[0][0]
|
37
|
+
else:
|
38
|
+
click.echo("👥 Multiple connected clients found. Select one to flash:")
|
39
|
+
for idx, (ip, info) in enumerate(clients, 1):
|
40
|
+
board_info = info.get("board_info") or "Unknown"
|
41
|
+
click.echo(f" {idx}. {ip} - {board_info}")
|
42
|
+
while True:
|
43
|
+
choice = input("Enter the number of the client to flash: ").strip()
|
44
|
+
if choice.isdigit() and 1 <= int(choice) <= len(clients):
|
45
|
+
selected_ip = clients[int(choice) - 1][0]
|
46
|
+
break
|
47
|
+
click.echo("❌ Invalid choice. Try again.")
|
48
|
+
|
49
|
+
click.echo(f"📡 Selected client: {selected_ip}")
|
50
|
+
remote_dir = "/tmp"
|
51
|
+
|
52
|
+
for path in emmc_image_paths:
|
53
|
+
click.echo(f"📤 Copying {path} to {selected_ip}:{remote_dir}")
|
54
|
+
success = copy_file_to_remote_board(selected_ip, path, remote_dir, passwd=DEFAULT_PASSWORD)
|
55
|
+
if not success:
|
56
|
+
click.echo(f"❌ Failed to copy {path} to {selected_ip}. Aborting.")
|
57
|
+
return
|
58
|
+
|
59
|
+
try:
|
60
|
+
ssh = init_ssh_session(selected_ip, password=DEFAULT_PASSWORD)
|
61
|
+
|
62
|
+
# Step a: Check if eMMC exists
|
63
|
+
check_cmd = "[ -e /dev/mmcblk0p1 ] || (echo '❌ /dev/mmcblk0p1 not found'; exit 1)"
|
64
|
+
run_remote_command(ssh, check_cmd)
|
65
|
+
|
66
|
+
# Step b: Flash with bmaptool
|
67
|
+
wic_path = next((p for p in emmc_image_paths if p.endswith(".wic.gz")), None)
|
68
|
+
if not wic_path:
|
69
|
+
click.echo("❌ No .wic.gz image found in emmc_image_paths.")
|
70
|
+
return
|
71
|
+
|
72
|
+
# Step c: umount the emmc
|
73
|
+
pre_unmount_cmd = (
|
74
|
+
"sudo mount | grep mmcblk0 | awk '{print $3}' | while read mnt; do "
|
75
|
+
"sudo umount \"$mnt\"; done"
|
76
|
+
)
|
77
|
+
run_remote_command(ssh, pre_unmount_cmd)
|
78
|
+
|
79
|
+
# Step d: bmaptool copy the image
|
80
|
+
filename = os.path.basename(wic_path)
|
81
|
+
remote_path = f"/tmp/{filename}"
|
82
|
+
flash_cmd = f"sudo bmaptool copy {remote_path} /dev/mmcblk0"
|
83
|
+
run_remote_command(ssh, flash_cmd)
|
84
|
+
|
85
|
+
# Step d: Fix GPT
|
86
|
+
fix_cmd = 'sudo printf "fix\n" | sudo parted ---pretend-input-tty /dev/mmcblk0 print'
|
87
|
+
run_remote_command(ssh, fix_cmd)
|
88
|
+
|
89
|
+
click.echo("✅ Flash completed. Please reboot the board to boot from eMMC.")
|
90
|
+
except Exception as e:
|
91
|
+
click.echo(f"❌ Flashing failed: {e}")
|
92
|
+
|
93
|
+
class ClientManager:
|
94
|
+
"""Manages TFTP client state and monitoring."""
|
95
|
+
def __init__(self):
|
96
|
+
self.clients = {}
|
97
|
+
self.lock = threading.Lock()
|
98
|
+
self.shutdown_event = threading.Event()
|
99
|
+
|
100
|
+
def add_client(self, ip, filename):
|
101
|
+
"""Add a new client with initial state."""
|
102
|
+
with self.lock:
|
103
|
+
if ip not in self.clients:
|
104
|
+
start_time = time.time()
|
105
|
+
self.clients[ip] = {
|
106
|
+
'state': 'Booting',
|
107
|
+
'filename': filename,
|
108
|
+
'timestamp': start_time,
|
109
|
+
'board_info': None
|
110
|
+
}
|
111
|
+
click.echo(f"📥 New client connected: {ip}")
|
112
|
+
if filename:
|
113
|
+
click.echo(f"📄 Client {ip} requested file: {filename}")
|
114
|
+
# Start monitoring thread
|
115
|
+
threading.Thread(
|
116
|
+
target=self.monitor_client,
|
117
|
+
args=(ip, start_time),
|
118
|
+
daemon=True
|
119
|
+
).start()
|
120
|
+
|
121
|
+
def monitor_client(self, ip, start_time):
|
122
|
+
"""Monitor client by calling get_remote_board_info after 1 minutes, retry every 10 seconds."""
|
123
|
+
try:
|
124
|
+
# Wait ~1 minute before first attempt
|
125
|
+
if self.shutdown_event.wait(timeout=max(0, 60 - (time.time() - start_time))):
|
126
|
+
return
|
127
|
+
|
128
|
+
while not self.shutdown_event.is_set():
|
129
|
+
try:
|
130
|
+
board_type, build_version = get_remote_board_info(ip)
|
131
|
+
|
132
|
+
if board_type and build_version:
|
133
|
+
with self.lock:
|
134
|
+
self.clients[ip]['state'] = 'Connected'
|
135
|
+
self.clients[ip]['board_info'] = f"{board_type}|{build_version}"
|
136
|
+
click.echo(f"✅ Board info retrieved for {ip}: {board_type} with {build_version}")
|
137
|
+
break
|
138
|
+
else:
|
139
|
+
log.debug(f"Incomplete board info for {ip}: {board_type}, {build_version}")
|
140
|
+
|
141
|
+
except Exception as e:
|
142
|
+
log.info(f"get_remote_board_info failed for {ip}, retrying in 10s: {e}")
|
143
|
+
|
144
|
+
if self.shutdown_event.wait(timeout=10):
|
145
|
+
break
|
146
|
+
|
147
|
+
except Exception as e:
|
148
|
+
log.error(f"Unexpected error while monitoring {ip}: {e}")
|
149
|
+
|
150
|
+
def get_client_info(self):
|
151
|
+
"""Return sorted client information for display."""
|
152
|
+
with self.lock:
|
153
|
+
return sorted(self.clients.items(), key=lambda x: x[0])
|
154
|
+
|
155
|
+
def shutdown(self):
|
156
|
+
"""Signal monitoring threads to exit."""
|
157
|
+
self.shutdown_event.set()
|
158
|
+
|
159
|
+
class InteractiveTftpServer(TftpServer):
|
160
|
+
"""Custom TFTP server with client logging and monitoring."""
|
161
|
+
def __init__(self, tftproot, client_manager):
|
162
|
+
super().__init__(tftproot)
|
163
|
+
self.client_manager = client_manager
|
164
|
+
|
165
|
+
def listen(self, listenip="", listenport=DEF_TFTP_PORT, timeout=SOCK_TIMEOUT, retries=DEF_TIMEOUT_RETRIES):
|
166
|
+
"""Override listen to log client IPs and filenames."""
|
167
|
+
tftp_factory = TftpPacketFactory()
|
168
|
+
if not listenip:
|
169
|
+
listenip = "0.0.0.0"
|
170
|
+
log.info(f"Server requested on ip {listenip}, port {listenport}")
|
171
|
+
try:
|
172
|
+
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
173
|
+
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
174
|
+
self.sock.bind((listenip, listenport))
|
175
|
+
self.sock.setblocking(0)
|
176
|
+
_, self.listenport = self.sock.getsockname()
|
177
|
+
except OSError as err:
|
178
|
+
raise err
|
179
|
+
|
180
|
+
self.is_running.set()
|
181
|
+
log.info("Starting receive loop...")
|
182
|
+
while True:
|
183
|
+
log.debug("shutdown_immediately is %s" % self.shutdown_immediately)
|
184
|
+
log.debug("shutdown_gracefully is %s" % self.shutdown_gracefully)
|
185
|
+
if self.shutdown_immediately:
|
186
|
+
log.info("Shutting down now. Session count: %d" % len(self.sessions))
|
187
|
+
self.sock.close()
|
188
|
+
for key in self.sessions:
|
189
|
+
log.warning("Forcefully closed session with %s" % self.sessions[key].host)
|
190
|
+
self.sessions[key].end()
|
191
|
+
self.sessions = []
|
192
|
+
self.is_running.clear()
|
193
|
+
self.shutdown_gracefully = self.shutdown_immediately = False
|
194
|
+
self.client_manager.shutdown()
|
195
|
+
break
|
196
|
+
elif self.shutdown_gracefully:
|
197
|
+
if not self.sessions:
|
198
|
+
log.info("In graceful shutdown mode and all sessions complete.")
|
199
|
+
self.sock.close()
|
200
|
+
self.is_running.clear()
|
201
|
+
self.shutdown_gracefully = self.shutdown_immediately = False
|
202
|
+
self.client_manager.shutdown()
|
203
|
+
break
|
204
|
+
|
205
|
+
inputlist = [self.sock]
|
206
|
+
for key in self.sessions:
|
207
|
+
inputlist.append(self.sessions[key].sock)
|
208
|
+
|
209
|
+
try:
|
210
|
+
readyinput, _, _ = select.select(inputlist, [], [], timeout)
|
211
|
+
except OSError as err:
|
212
|
+
if err.errno == EINTR:
|
213
|
+
log.debug("Interrupted syscall, retrying")
|
214
|
+
continue
|
215
|
+
else:
|
216
|
+
raise
|
217
|
+
|
218
|
+
deletion_list = []
|
219
|
+
for readysock in readyinput:
|
220
|
+
if readysock == self.sock:
|
221
|
+
log.debug("Data ready on our main socket")
|
222
|
+
buffer, (raddress, rport) = self.sock.recvfrom(MAX_BLKSIZE)
|
223
|
+
log.debug("Read %d bytes", len(buffer))
|
224
|
+
|
225
|
+
if self.shutdown_gracefully:
|
226
|
+
log.warning("Discarding data on main port, in graceful shutdown mode")
|
227
|
+
continue
|
228
|
+
|
229
|
+
key = f"{raddress}:{rport}"
|
230
|
+
if key not in self.sessions:
|
231
|
+
log.debug("Creating new server context for session key = %s" % key)
|
232
|
+
filename = None
|
233
|
+
if buffer[:2] == b'\x00\x01': # RRQ packet
|
234
|
+
filename = buffer[2:].split(b'\x00')[0].decode()
|
235
|
+
self.client_manager.add_client(raddress, filename)
|
236
|
+
self.sessions[key] = TftpContextServer(
|
237
|
+
raddress,
|
238
|
+
rport,
|
239
|
+
timeout,
|
240
|
+
self.root,
|
241
|
+
self.dyn_file_func,
|
242
|
+
self.upload_open,
|
243
|
+
retries=retries
|
244
|
+
)
|
245
|
+
try:
|
246
|
+
self.sessions[key].start(buffer)
|
247
|
+
except TftpTimeoutExpectACK:
|
248
|
+
self.sessions[key].timeout_expectACK = True
|
249
|
+
except TftpException as err:
|
250
|
+
deletion_list.append(key)
|
251
|
+
log.error("Fatal exception thrown from session %s: %s" % (key, str(err)))
|
252
|
+
else:
|
253
|
+
log.warning("received traffic on main socket for existing session??")
|
254
|
+
log.info("Currently handling these sessions:")
|
255
|
+
for session_key, session in list(self.sessions.items()):
|
256
|
+
log.info(" %s" % session)
|
257
|
+
else:
|
258
|
+
for key in self.sessions:
|
259
|
+
if readysock == self.sessions[key].sock:
|
260
|
+
log.debug("Matched input to session key %s" % key)
|
261
|
+
self.sessions[key].timeout_expectACK = False
|
262
|
+
try:
|
263
|
+
self.sessions[key].cycle()
|
264
|
+
if self.sessions[key].state is None:
|
265
|
+
log.info("Successful transfer.")
|
266
|
+
deletion_list.append(key)
|
267
|
+
except TftpTimeoutExpectACK:
|
268
|
+
self.sessions[key].timeout_expectACK = True
|
269
|
+
except TftpException as err:
|
270
|
+
deletion_list.append(key)
|
271
|
+
log.error("Fatal exception thrown from session %s: %s" % (key, str(err)))
|
272
|
+
break
|
273
|
+
else:
|
274
|
+
log.error("Can't find the owner for this packet. Discarding.")
|
275
|
+
|
276
|
+
now = time.time()
|
277
|
+
for key in self.sessions:
|
278
|
+
try:
|
279
|
+
self.sessions[key].checkTimeout(now)
|
280
|
+
except TftpTimeout as err:
|
281
|
+
log.error(str(err))
|
282
|
+
self.sessions[key].retry_count += 1
|
283
|
+
if self.sessions[key].retry_count >= self.sessions[key].retries:
|
284
|
+
log.debug("hit max retries on %s, giving up" % self.sessions[key])
|
285
|
+
deletion_list.append(key)
|
286
|
+
else:
|
287
|
+
log.debug("resending on session %s" % self.sessions[key])
|
288
|
+
self.sessions[key].state.resendLast()
|
289
|
+
|
290
|
+
for key in deletion_list:
|
291
|
+
log.info("Session %s complete" % key)
|
292
|
+
if key in self.sessions:
|
293
|
+
log.debug("Gathering up metrics from session before deleting")
|
294
|
+
self.sessions[key].end()
|
295
|
+
metrics = self.sessions[key].metrics
|
296
|
+
if metrics.duration == 0:
|
297
|
+
log.info("Duration too short, rate undetermined")
|
298
|
+
else:
|
299
|
+
log.info("Transferred %d bytes in %.2f seconds" % (metrics.bytes, metrics.duration))
|
300
|
+
log.info("Average rate: %.2f kbps" % metrics.kbps)
|
301
|
+
click.echo(f"✅ Transfer to {self.sessions[key].host} complete: "
|
302
|
+
f"{metrics.bytes} bytes in {metrics.duration:.2f} s ({metrics.kbps:.2f} kbps)")
|
303
|
+
log.info("%.2f bytes in resent data" % metrics.resent_bytes)
|
304
|
+
log.info("%d duplicate packets" % metrics.dupcount)
|
305
|
+
log.debug("Deleting session %s" % key)
|
306
|
+
del self.sessions[key]
|
307
|
+
log.debug("Session list is now %s" % self.sessions)
|
308
|
+
else:
|
309
|
+
log.warning("Strange, session %s is not on the deletion list" % key)
|
310
|
+
|
311
|
+
self.is_running.clear()
|
312
|
+
self.shutdown_gracefully = self.shutdown_immediately = False
|
313
|
+
self.client_manager.shutdown()
|
314
|
+
|
315
|
+
def run_cli(client_manager):
|
316
|
+
"""Run the interactive CLI for netboot commands."""
|
317
|
+
click.echo("\n🛠 Type 'c' to see connected IPs and board info, 'f' to flash eMMC, or 'q' to quit.\n")
|
318
|
+
while True:
|
319
|
+
try:
|
320
|
+
user_input = input("netboot> ").strip().lower()
|
321
|
+
if user_input in {"q", "quit", "exit"}:
|
322
|
+
click.echo("🛑 Shutting down TFTP server.")
|
323
|
+
return True
|
324
|
+
elif user_input == "c":
|
325
|
+
client_info = client_manager.get_client_info()
|
326
|
+
if client_info:
|
327
|
+
click.echo("🧾 TFTP client IPs and status:")
|
328
|
+
for ip, info in client_info:
|
329
|
+
state = info['state']
|
330
|
+
filename = info['filename'] or "Unknown"
|
331
|
+
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(info['timestamp']))
|
332
|
+
board_info = info['board_info']
|
333
|
+
click.echo(f" • {ip}: {state}, Initial File: {filename}, First seen: {timestamp}")
|
334
|
+
if board_info:
|
335
|
+
click.echo(f" Board Info: {board_info}")
|
336
|
+
else:
|
337
|
+
click.echo("📭 No TFTP client requests received yet.")
|
338
|
+
elif user_input == "f":
|
339
|
+
click.echo("🔧 Initiating eMMC flash (implementation pending).")
|
340
|
+
flash_emmc(client_manager, emmc_image_paths)
|
341
|
+
elif user_input == "":
|
342
|
+
continue
|
343
|
+
else:
|
344
|
+
click.echo("❓ Unknown command. Try 'c' to print client list, 'f' to flash emmc, or 'q'.")
|
345
|
+
except (KeyboardInterrupt, EOFError):
|
346
|
+
click.echo("\n🛑 Exiting netboot session.")
|
347
|
+
return True
|
348
|
+
|
349
|
+
def setup_netboot(version: str, board: str, internal: bool = False, autoflash: bool = False):
|
350
|
+
"""
|
351
|
+
Download and serve a bootable image for network boot over TFTP with client monitoring.
|
352
|
+
|
353
|
+
Parameters:
|
354
|
+
version (str): Firmware version to download (e.g., "1.6.0").
|
355
|
+
board (str): Target board type, e.g., "modalix" or "davinci".
|
356
|
+
internal (bool): Whether to use internal download sources. Defaults to False.
|
357
|
+
autoflash (bool): Whether to automatically flash the devkit when networked booted. Defaults to False.
|
358
|
+
|
359
|
+
Raises:
|
360
|
+
RuntimeError: If the download or TFTP setup fails.
|
361
|
+
"""
|
362
|
+
global emmc_image_paths
|
363
|
+
|
364
|
+
if platform.system() == "Windows":
|
365
|
+
click.echo("❌ Netboot with built-in TFTP is not supported on Windows. Use macOS or Linux.")
|
366
|
+
exit(1)
|
367
|
+
|
368
|
+
try:
|
369
|
+
click.echo(f"⬇️ Downloading netboot image for version: {version}, board: {board}")
|
370
|
+
file_list = download_image(version, board, swtype="yocto", internal=internal, update_type='netboot')
|
371
|
+
if not isinstance(file_list, list):
|
372
|
+
raise ValueError("Expected list of extracted files, got something else.")
|
373
|
+
extract_dir = os.path.dirname(file_list[0])
|
374
|
+
click.echo(f"📁 Image extracted to: {extract_dir}")
|
375
|
+
# Extract specific image paths
|
376
|
+
wic_gz_file = next((f for f in file_list if f.endswith(".wic.gz")), None)
|
377
|
+
bmap_file = next((f for f in file_list if f.endswith(".wic.bmap")), None)
|
378
|
+
emmc_image_paths = [p for p in [wic_gz_file, bmap_file] if p]
|
379
|
+
click.echo(f"📁 eMMC image paths are: {emmc_image_paths}")
|
380
|
+
|
381
|
+
except Exception as e:
|
382
|
+
raise RuntimeError(f"❌ Failed to download and extract netboot image: {e}")
|
383
|
+
|
384
|
+
try:
|
385
|
+
click.echo(f"🚀 Starting TFTP server in: {extract_dir}")
|
386
|
+
ip_candidates = get_local_ip_candidates()
|
387
|
+
if not ip_candidates:
|
388
|
+
click.echo("❌ No suitable local IP addresses found.")
|
389
|
+
exit(1)
|
390
|
+
|
391
|
+
click.echo("🌐 TFTP server is listening on these interfaces (UDP port 69):")
|
392
|
+
for iface, ip in ip_candidates:
|
393
|
+
click.echo(f" 🔹 {iface}: {ip}")
|
394
|
+
|
395
|
+
client_manager = ClientManager()
|
396
|
+
|
397
|
+
server = InteractiveTftpServer(tftproot=extract_dir, client_manager=client_manager)
|
398
|
+
server_thread = threading.Thread(target=server.listen, args=('0.0.0.0', 69), daemon=True)
|
399
|
+
server_thread.start()
|
400
|
+
|
401
|
+
if run_cli(client_manager):
|
402
|
+
server.stop(now=True)
|
403
|
+
client_manager.shutdown()
|
404
|
+
|
405
|
+
except PermissionError:
|
406
|
+
raise RuntimeError("❌ Permission denied. You must run this command with sudo to bind to port 69.")
|
407
|
+
except OSError as e:
|
408
|
+
raise RuntimeError(f"❌ Failed to start TFTP server: {e}")
|
sima_cli/update/remote.py
CHANGED
@@ -83,7 +83,8 @@ def get_remote_board_info(ip: str, passwd: str = DEFAULT_PASSWORD) -> Tuple[str,
|
|
83
83
|
|
84
84
|
return board_type, build_version
|
85
85
|
|
86
|
-
except Exception:
|
86
|
+
except Exception as e:
|
87
|
+
click.echo(f"Unable to retrieve board info with error: {e}, board may be still booting.")
|
87
88
|
return "", ""
|
88
89
|
|
89
90
|
|
@@ -94,7 +95,7 @@ def _scp_file(sftp, local_path: str, remote_path: str):
|
|
94
95
|
sftp.put(local_path, remote_path)
|
95
96
|
click.echo("✅ Upload complete")
|
96
97
|
|
97
|
-
def
|
98
|
+
def run_remote_command(ssh, command: str, password: str = DEFAULT_PASSWORD):
|
98
99
|
"""
|
99
100
|
Run a remote command over SSH and stream its output live to the console.
|
100
101
|
If the command starts with 'sudo', pipe in the password.
|
@@ -139,6 +140,13 @@ def _run_remote_command(ssh, command: str, password: str = DEFAULT_PASSWORD):
|
|
139
140
|
for line in remaining_err.splitlines():
|
140
141
|
click.echo(f"⚠️ {line}")
|
141
142
|
|
143
|
+
def init_ssh_session(ip: str, password: str = DEFAULT_PASSWORD):
|
144
|
+
ssh = paramiko.SSHClient()
|
145
|
+
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
146
|
+
|
147
|
+
ssh.connect(ip, username=DEFAULT_USER, password=password, timeout=10)
|
148
|
+
return ssh
|
149
|
+
|
142
150
|
def reboot_remote_board(ip: str, passwd: str):
|
143
151
|
"""
|
144
152
|
Reboot remote board by sending SSH command
|
@@ -149,12 +157,36 @@ def reboot_remote_board(ip: str, passwd: str):
|
|
149
157
|
|
150
158
|
ssh.connect(ip, username=DEFAULT_USER, password=passwd, timeout=10)
|
151
159
|
|
152
|
-
|
153
|
-
|
160
|
+
run_remote_command(ssh, "sudo systemctl stop watchdog", password=passwd)
|
161
|
+
run_remote_command(ssh, "sudo bash -c 'echo b > /proc/sysrq-trigger'", password=passwd)
|
154
162
|
|
155
163
|
except Exception as reboot_err:
|
156
164
|
click.echo(f"⚠️ Unable to connect to the remote board")
|
157
165
|
|
166
|
+
|
167
|
+
def copy_file_to_remote_board(ip: str, file_path: str, remote_dir: str, passwd: str):
|
168
|
+
"""
|
169
|
+
Copy a file to the remote board over SSH.
|
170
|
+
Assumes default credentials: sima / edgeai.
|
171
|
+
"""
|
172
|
+
ssh = paramiko.SSHClient()
|
173
|
+
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
174
|
+
|
175
|
+
try:
|
176
|
+
ssh.connect(ip, username=DEFAULT_USER, password=passwd, timeout=10)
|
177
|
+
sftp = ssh.open_sftp()
|
178
|
+
|
179
|
+
# Upload the file
|
180
|
+
base_file_path = os.path.basename(file_path)
|
181
|
+
click.echo(f"📤 Uploading {file_path} → {remote_dir}")
|
182
|
+
sftp.put(file_path, os.path.join(remote_dir, base_file_path))
|
183
|
+
return True
|
184
|
+
|
185
|
+
except Exception as e:
|
186
|
+
click.echo(f"❌ Remote file copy failed: {e}")
|
187
|
+
|
188
|
+
return False
|
189
|
+
|
158
190
|
def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, passwd: str, reboot_and_wait: bool):
|
159
191
|
"""
|
160
192
|
Upload and install firmware images to remote board over SSH.
|
@@ -176,7 +208,7 @@ def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, pa
|
|
176
208
|
click.echo("🚀 Uploaded tRoot image.")
|
177
209
|
|
178
210
|
# Run tRoot update
|
179
|
-
|
211
|
+
run_remote_command(
|
180
212
|
ssh,
|
181
213
|
f"sudo swupdate -H simaai-image-troot:1.0 -i /tmp/{troot_name}", password=passwd
|
182
214
|
)
|
@@ -191,7 +223,7 @@ def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, pa
|
|
191
223
|
click.echo("🚀 Uploaded system image.")
|
192
224
|
|
193
225
|
# Run Palette update
|
194
|
-
|
226
|
+
run_remote_command(
|
195
227
|
ssh,
|
196
228
|
f"sudo swupdate -H simaai-image-palette:1.0 -i /tmp/{palette_name}",
|
197
229
|
password=passwd
|
@@ -205,11 +237,12 @@ def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, pa
|
|
205
237
|
click.echo("🔁 Rebooting board after update. Waiting for reconnection...")
|
206
238
|
|
207
239
|
try:
|
208
|
-
|
209
|
-
|
240
|
+
run_remote_command(ssh, "sudo systemctl stop watchdog", password=passwd)
|
241
|
+
run_remote_command(ssh, "sudo bash -c 'echo b > /proc/sysrq-trigger'", password=passwd)
|
210
242
|
|
211
243
|
except Exception as reboot_err:
|
212
244
|
click.echo(f"⚠️ SSH connection lost due to reboot (expected): {reboot_err}, please powercycle the board...")
|
245
|
+
click.confirm("⚠️ Have you powercycled the board?", default=True, abort=True)
|
213
246
|
|
214
247
|
try:
|
215
248
|
ssh.close()
|
@@ -228,7 +261,7 @@ def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, pa
|
|
228
261
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
229
262
|
ssh.connect(ip, username=DEFAULT_USER, password=passwd, timeout=10)
|
230
263
|
|
231
|
-
|
264
|
+
run_remote_command(ssh, "grep SIMA_BUILD_VERSION /etc/build 2>/dev/null || grep SIMA_BUILD_VERSION /etc/buildinfo 2>/dev/null", password=passwd)
|
232
265
|
ssh.close()
|
233
266
|
except Exception as e:
|
234
267
|
click.echo(f"❌ Unable to validate the version: {e}")
|