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.
@@ -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"(?:(?:dd: )?(\d+)\s+bytes(?:.*?)copied)|(?:^(\d+)\s+bytes\s+transferred)",
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
- click.echo("⚙️ Flashing tRoot image...")
86
- if not _run_local_cmd(f"sudo swupdate -H simaai-image-troot:1.0 -i {troot_path}", passwd):
87
- click.echo(" tRoot update failed.")
88
- return
89
- click.echo("✅ tRoot update completed.")
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
- if not _run_local_cmd(f"sudo swupdate -H simaai-image-palette:1.0 -i {palette_path}", passwd):
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)