sima-cli 0.0.19__py3-none-any.whl → 0.0.21__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/cli.py +100 -12
- sima_cli/install/optiview.py +8 -3
- sima_cli/network/network.py +193 -0
- sima_cli/nvme/nvme.py +112 -0
- sima_cli/serial/__init__.py +0 -0
- sima_cli/serial/serial.py +114 -0
- sima_cli/update/bootimg.py +7 -6
- sima_cli/update/local.py +10 -8
- sima_cli/update/netboot.py +409 -0
- sima_cli/update/query.py +7 -5
- sima_cli/update/remote.py +84 -31
- sima_cli/update/updater.py +79 -37
- sima_cli/utils/env.py +22 -0
- sima_cli/utils/net.py +29 -0
- {sima_cli-0.0.19.dist-info → sima_cli-0.0.21.dist-info}/METADATA +3 -1
- {sima_cli-0.0.19.dist-info → sima_cli-0.0.21.dist-info}/RECORD +21 -15
- {sima_cli-0.0.19.dist-info → sima_cli-0.0.21.dist-info}/WHEEL +0 -0
- {sima_cli-0.0.19.dist-info → sima_cli-0.0.21.dist-info}/entry_points.txt +0 -0
- {sima_cli-0.0.19.dist-info → sima_cli-0.0.21.dist-info}/licenses/LICENSE +0 -0
- {sima_cli-0.0.19.dist-info → sima_cli-0.0.21.dist-info}/top_level.txt +0 -0
sima_cli/update/bootimg.py
CHANGED
@@ -4,13 +4,14 @@ import subprocess
|
|
4
4
|
import sys
|
5
5
|
import os
|
6
6
|
import select
|
7
|
-
import time
|
8
7
|
import re
|
8
|
+
|
9
|
+
from sima_cli.update.updater import download_image
|
10
|
+
|
9
11
|
try:
|
10
12
|
from tqdm import tqdm
|
11
13
|
except ImportError:
|
12
14
|
tqdm = None
|
13
|
-
from sima_cli.update.updater import download_image
|
14
15
|
|
15
16
|
|
16
17
|
def list_removable_devices():
|
@@ -177,7 +178,7 @@ def copy_image_to_device(image_path, device_path):
|
|
177
178
|
|
178
179
|
# Regex to parse dd progress (more robust to handle variations)
|
179
180
|
pattern = re.compile(
|
180
|
-
r"(?:
|
181
|
+
r"(?:dd:\s*)?(?P<bytes>\d+)\s+bytes(?:\s+\(.*?\))?\s+(?:transferred|copied),?\s+[\d\.]+\s*s?,?\s+[\d\.]+\s+MB/s",
|
181
182
|
re.IGNORECASE
|
182
183
|
)
|
183
184
|
|
@@ -298,7 +299,7 @@ def write_bootimg(image_path):
|
|
298
299
|
click.echo("ℹ️ Please manually eject the device.")
|
299
300
|
|
300
301
|
|
301
|
-
def write_image(version: str, board: str, swtype: str, internal: bool = False):
|
302
|
+
def write_image(version: str, board: str, swtype: str, internal: bool = False, flavor: str = 'headless'):
|
302
303
|
"""
|
303
304
|
Download and write a bootable firmware image to a removable storage device.
|
304
305
|
|
@@ -307,13 +308,14 @@ def write_image(version: str, board: str, swtype: str, internal: bool = False):
|
|
307
308
|
board (str): Target board type, e.g., "modalix" or "mlsoc".
|
308
309
|
swtype (str): Software image type, e.g., "yocto" or "exlr".
|
309
310
|
internal (bool): Whether to use internal download sources. Defaults to False.
|
311
|
+
flavor (str): Flavor of the software package - can be either headless or full.
|
310
312
|
|
311
313
|
Raises:
|
312
314
|
RuntimeError: If the download or write process fails.
|
313
315
|
"""
|
314
316
|
try:
|
315
317
|
click.echo(f"⬇️ Downloading boot image for version: {version}, board: {board}, swtype: {swtype}")
|
316
|
-
file_list = download_image(version, board, swtype, internal, update_type='bootimg')
|
318
|
+
file_list = download_image(version, board, swtype, internal, update_type='bootimg', flavor=flavor)
|
317
319
|
if not isinstance(file_list, list):
|
318
320
|
raise ValueError("Expected list of extracted files, got something else.")
|
319
321
|
|
@@ -330,7 +332,6 @@ def write_image(version: str, board: str, swtype: str, internal: bool = False):
|
|
330
332
|
except Exception as e:
|
331
333
|
raise RuntimeError(f"❌ Failed to write image: {e}")
|
332
334
|
|
333
|
-
|
334
335
|
if __name__ == "__main__":
|
335
336
|
if len(sys.argv) != 2:
|
336
337
|
click.echo("❌ Usage: python write_bootimg.py <image_file>")
|
sima_cli/update/local.py
CHANGED
@@ -73,7 +73,7 @@ def get_local_board_info() -> Tuple[str, str]:
|
|
73
73
|
return board_type, build_version
|
74
74
|
|
75
75
|
|
76
|
-
def push_and_update_local_board(troot_path: str, palette_path: str, passwd: str):
|
76
|
+
def push_and_update_local_board(troot_path: str, palette_path: str, passwd: str, flavor: str):
|
77
77
|
"""
|
78
78
|
Perform local firmware update using swupdate commands.
|
79
79
|
Calls swupdate directly on the provided file paths.
|
@@ -82,15 +82,17 @@ def push_and_update_local_board(troot_path: str, palette_path: str, passwd: str)
|
|
82
82
|
|
83
83
|
try:
|
84
84
|
# Run tRoot update
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
85
|
+
if troot_path != None:
|
86
|
+
click.echo("⚙️ Flashing tRoot image...")
|
87
|
+
if not _run_local_cmd(f"sudo swupdate -H simaai-image-troot:1.0 -i {troot_path}", passwd):
|
88
|
+
click.echo("❌ tRoot update failed.")
|
89
|
+
return
|
90
|
+
click.echo("✅ tRoot update completed.")
|
91
|
+
|
91
92
|
# Run Palette update
|
92
93
|
click.echo("⚙️ Flashing System image...")
|
93
|
-
|
94
|
+
_flavor = 'palette' if flavor == 'headless' else 'graphics'
|
95
|
+
if not _run_local_cmd(f"sudo swupdate -H simaai-image-{_flavor}:1.0 -i {palette_path}", passwd):
|
94
96
|
click.echo("❌ System image update failed.")
|
95
97
|
return
|
96
98
|
click.echo("✅ System image update completed. Please powercycle the device")
|
@@ -0,0 +1,409 @@
|
|
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, flavor: str = 'headless'):
|
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
|
+
flavor (str): The software flavor, can be either headless or full.
|
359
|
+
|
360
|
+
Raises:
|
361
|
+
RuntimeError: If the download or TFTP setup fails.
|
362
|
+
"""
|
363
|
+
global emmc_image_paths
|
364
|
+
|
365
|
+
if platform.system() == "Windows":
|
366
|
+
click.echo("❌ Netboot with built-in TFTP is not supported on Windows. Use macOS or Linux.")
|
367
|
+
exit(1)
|
368
|
+
|
369
|
+
try:
|
370
|
+
click.echo(f"⬇️ Downloading netboot image for version: {version}, board: {board}")
|
371
|
+
file_list = download_image(version, board, swtype="yocto", internal=internal, update_type='netboot', flavor=flavor)
|
372
|
+
if not isinstance(file_list, list):
|
373
|
+
raise ValueError("Expected list of extracted files, got something else.")
|
374
|
+
extract_dir = os.path.dirname(file_list[0])
|
375
|
+
click.echo(f"📁 Image extracted to: {extract_dir}")
|
376
|
+
# Extract specific image paths
|
377
|
+
wic_gz_file = next((f for f in file_list if f.endswith(".wic.gz")), None)
|
378
|
+
bmap_file = next((f for f in file_list if f.endswith(".wic.bmap")), None)
|
379
|
+
emmc_image_paths = [p for p in [wic_gz_file, bmap_file] if p]
|
380
|
+
click.echo(f"📁 eMMC image paths are: {emmc_image_paths}")
|
381
|
+
|
382
|
+
except Exception as e:
|
383
|
+
raise RuntimeError(f"❌ Failed to download and extract netboot image: {e}")
|
384
|
+
|
385
|
+
try:
|
386
|
+
click.echo(f"🚀 Starting TFTP server in: {extract_dir}")
|
387
|
+
ip_candidates = get_local_ip_candidates()
|
388
|
+
if not ip_candidates:
|
389
|
+
click.echo("❌ No suitable local IP addresses found.")
|
390
|
+
exit(1)
|
391
|
+
|
392
|
+
click.echo("🌐 TFTP server is listening on these interfaces (UDP port 69):")
|
393
|
+
for iface, ip in ip_candidates:
|
394
|
+
click.echo(f" 🔹 {iface}: {ip}")
|
395
|
+
|
396
|
+
client_manager = ClientManager()
|
397
|
+
|
398
|
+
server = InteractiveTftpServer(tftproot=extract_dir, client_manager=client_manager)
|
399
|
+
server_thread = threading.Thread(target=server.listen, args=('0.0.0.0', 69), daemon=True)
|
400
|
+
server_thread.start()
|
401
|
+
|
402
|
+
if run_cli(client_manager):
|
403
|
+
server.stop(now=True)
|
404
|
+
client_manager.shutdown()
|
405
|
+
|
406
|
+
except PermissionError:
|
407
|
+
raise RuntimeError("❌ Permission denied. You must run this command with sudo to bind to port 69.")
|
408
|
+
except OSError as e:
|
409
|
+
raise RuntimeError(f"❌ Failed to start TFTP server: {e}")
|
sima_cli/update/query.py
CHANGED
@@ -5,7 +5,7 @@ from sima_cli.utils.config import get_auth_token
|
|
5
5
|
|
6
6
|
ARTIFACTORY_BASE_URL = artifactory_url() + '/artifactory'
|
7
7
|
|
8
|
-
def _list_available_firmware_versions_internal(board: str, match_keyword: str = None):
|
8
|
+
def _list_available_firmware_versions_internal(board: str, match_keyword: str = None, flavor: str = 'headless'):
|
9
9
|
fw_path = f"{board}"
|
10
10
|
aql_query = f"""
|
11
11
|
items.find({{
|
@@ -46,7 +46,7 @@ def _list_available_firmware_versions_internal(board: str, match_keyword: str =
|
|
46
46
|
|
47
47
|
return top_level_folders
|
48
48
|
|
49
|
-
def _list_available_firmware_versions_external(board: str, match_keyword: str = None):
|
49
|
+
def _list_available_firmware_versions_external(board: str, match_keyword: str = None, flavor: str = 'headless'):
|
50
50
|
"""
|
51
51
|
Construct and return a list containing a single firmware download URL for a given board.
|
52
52
|
|
@@ -56,6 +56,7 @@ def _list_available_firmware_versions_external(board: str, match_keyword: str =
|
|
56
56
|
Args:
|
57
57
|
board (str): The name of the hardware board.
|
58
58
|
match_keyword (str, optional): A version string to match (e.g., '1.6' or '1.6.0').
|
59
|
+
flavor (str, optional): A string indicating firmware flavor - headless or full.
|
59
60
|
|
60
61
|
Returns:
|
61
62
|
list[str]: A list containing one formatted firmware download URL.
|
@@ -75,7 +76,7 @@ def _list_available_firmware_versions_external(board: str, match_keyword: str =
|
|
75
76
|
return [firmware_download_url]
|
76
77
|
|
77
78
|
|
78
|
-
def list_available_firmware_versions(board: str, match_keyword: str = None, internal: bool = False):
|
79
|
+
def list_available_firmware_versions(board: str, match_keyword: str = None, internal: bool = False, flavor: str = 'headless'):
|
79
80
|
"""
|
80
81
|
Public interface to list available firmware versions.
|
81
82
|
|
@@ -83,11 +84,12 @@ def list_available_firmware_versions(board: str, match_keyword: str = None, inte
|
|
83
84
|
- board: str – Name of the board (e.g. 'davinci')
|
84
85
|
- match_keyword: str – Optional keyword to filter versions (case-insensitive)
|
85
86
|
- internal: bool – Must be True to access internal Artifactory
|
87
|
+
- flavor (str, optional): A string indicating firmware flavor - headless or full.
|
86
88
|
|
87
89
|
Returns:
|
88
90
|
- List[str] of firmware version folder names, or None if access is not allowed
|
89
91
|
"""
|
90
92
|
if not internal:
|
91
|
-
return _list_available_firmware_versions_external(board, match_keyword)
|
93
|
+
return _list_available_firmware_versions_external(board, match_keyword, flavor)
|
92
94
|
|
93
|
-
return _list_available_firmware_versions_internal(board, match_keyword)
|
95
|
+
return _list_available_firmware_versions_internal(board, match_keyword, flavor)
|