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.
@@ -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 _run_remote_command(ssh, command: str, password: str = DEFAULT_PASSWORD):
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
- _run_remote_command(ssh, "sudo systemctl stop watchdog", password=passwd)
153
- _run_remote_command(ssh, "sudo bash -c 'echo b > /proc/sysrq-trigger'", password=passwd)
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
- _run_remote_command(
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
- _run_remote_command(
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
- _run_remote_command(ssh, "sudo systemctl stop watchdog", password=passwd)
209
- _run_remote_command(ssh, "sudo bash -c 'echo b > /proc/sysrq-trigger'", password=passwd)
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
- _run_remote_command(ssh, "grep SIMA_BUILD_VERSION /etc/build 2>/dev/null || grep SIMA_BUILD_VERSION /etc/buildinfo 2>/dev/null", password=passwd)
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}")