sima-cli 0.0.19__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 CHANGED
@@ -1,2 +1,2 @@
1
1
  # sima_cli/__version__.py
2
- __version__ = "0.0.19"
2
+ __version__ = "0.0.20"
sima_cli/cli.py CHANGED
@@ -9,10 +9,11 @@ from sima_cli.__version__ import __version__
9
9
  from sima_cli.utils.config import CONFIG_PATH
10
10
  from sima_cli.install.optiview import install_optiview
11
11
  from sima_cli.install.hostdriver import install_hostdriver
12
+ from sima_cli.serial.serial import connect_serial
12
13
 
13
14
  # Entry point for the CLI tool using Click's command group decorator
14
15
  @click.group()
15
- @click.option('-i', '--internal', is_flag=True, help="Use internal Artifactory resources.")
16
+ @click.option('-i', '--internal', is_flag=True, help="Use internal Artifactory resources, Authorized Sima employees only")
16
17
  @click.pass_context
17
18
  def main(ctx, internal):
18
19
  """
@@ -226,18 +227,23 @@ def show_mla_memory_usage(ctx):
226
227
  @main.command(name="bootimg")
227
228
  @click.option("-v", "--version", required=True, help="Firmware version to download and write (e.g., 1.6.0)")
228
229
  @click.option("--boardtype", type=click.Choice(["modalix", "mlsoc"], case_sensitive=False), default="mlsoc", show_default=True, help="Target board type.")
230
+ @click.option("--netboot", is_flag=True, default=False, show_default=True, help="Prepare image for network boot and launch TFTP server.")
231
+ @click.option("--autoflash", is_flag=True, default=False, show_default=True, help="Net boot the DevKit and automatically flash the internal storage")
229
232
  @click.pass_context
230
- def bootimg_cmd(ctx, version, boardtype):
233
+ def bootimg_cmd(ctx, version, boardtype, netboot, autoflash):
231
234
  """
232
- Download and burn a bootable image onto removable media (e.g., SD card or USB stick).
235
+ Download and burn a removable media or setup TFTP boot.
233
236
 
234
237
  Examples:
235
238
  sima-cli bootimg -v 1.6.0
236
239
  sima-cli bootimg -v 1.6.0 --boardtype mlsoc
237
240
  sima-cli bootimg -v 1.6.0 --boardtype modalix
241
+ sima-cli bootimg -v 1.6.0 --boardtype modalix --netboot
242
+ sima-cli bootimg -v 1.6.0 --boardtype modalix --autoflash
238
243
  """
239
244
 
240
245
  from sima_cli.update.bootimg import write_image
246
+ from sima_cli.update.netboot import setup_netboot
241
247
 
242
248
  internal = ctx.obj.get("internal", False)
243
249
 
@@ -247,7 +253,12 @@ def bootimg_cmd(ctx, version, boardtype):
247
253
 
248
254
  try:
249
255
  boardtype = boardtype if boardtype != 'mlsoc' else 'davinci'
250
- write_image(version, boardtype, 'yocto', internal)
256
+ if netboot or autoflash:
257
+ setup_netboot(version, boardtype, internal, autoflash)
258
+ click.echo("✅ Netboot image prepared and TFTP server is running.")
259
+ else:
260
+ write_image(version, boardtype, 'yocto', internal)
261
+ click.echo("✅ Boot image successfully written.")
251
262
  click.echo("✅ Boot image successfully written.")
252
263
  except Exception as e:
253
264
  click.echo(f"❌ Failed to write boot image: {e}", err=True)
@@ -269,10 +280,10 @@ def install_cmd(ctx, component, version):
269
280
  Install supported components such as SDKs or tools.
270
281
 
271
282
  Examples:
272
- cli install palette -v 1.6.0
273
- cli install hostdriver -v 1.6.0
274
283
 
275
- cli install optiview
284
+ sima-cli install hostdriver -v 1.6.0
285
+
286
+ sima-cli install optiview
276
287
  """
277
288
  component = component.lower()
278
289
  internal = ctx.obj.get("internal", False)
@@ -295,9 +306,27 @@ def install_cmd(ctx, component, version):
295
306
  install_optiview()
296
307
 
297
308
  click.echo("✅ Installation complete.")
298
-
299
309
 
300
310
  # ----------------------
311
+ # Serial Subcommands
312
+ # ----------------------
313
+ @main.command(name="serial")
314
+ @click.option("-b", "--baud", default=115200, show_default=True, help="Baud rate for the serial connection")
315
+ @click.pass_context
316
+ def serial_cmd(ctx, baud):
317
+ """
318
+ Connect to the UART serial console of the DevKit.
319
+
320
+ Automatically detects the serial port and launches a terminal emulator:
321
+
322
+ - macOS: uses 'picocom'
323
+
324
+ - Linux: uses 'picocom'
325
+
326
+ - Windows: shows PuTTY/Tera Term setup instructions
327
+ """
328
+ connect_serial(ctx, baud)
329
+ # ----------------------
301
330
  # App Zoo Subcommands
302
331
  # ----------------------
303
332
  # @main.group()
@@ -1,9 +1,7 @@
1
1
  import sys
2
2
  import platform
3
3
  import click
4
- import urllib
5
4
  import json
6
- import tempfile
7
5
  import tarfile
8
6
  import os
9
7
  import subprocess
@@ -31,6 +29,12 @@ def install_optiview():
31
29
  version_url = f"{download_url_base}optiview/metadata.json"
32
30
  downloads_dir = Path.home() / "Downloads" / "optiview-installer"
33
31
  downloads_dir.mkdir(parents=True, exist_ok=True)
32
+
33
+ # Always redownload the metadata file to get the latest version
34
+ metadata_path = downloads_dir / "metadata.json"
35
+ if metadata_path.exists():
36
+ metadata_path.unlink()
37
+
34
38
  metadata_path = download_file_from_url(version_url, dest_folder=downloads_dir, internal=False)
35
39
 
36
40
  with open(metadata_path, "r") as f:
@@ -70,7 +74,8 @@ def install_optiview():
70
74
  else:
71
75
  subprocess.run(["bash", os.path.basename(script_path)], check=True, cwd=downloads_dir)
72
76
 
73
- click.echo(f" Optiview installed successfully. Run {downloads_dir}/run.sh to start optiview")
77
+ script_name = "run.bat" if platform.system() == "Windows" else "run.sh"
78
+ click.echo(f"✅ Optiview installed successfully. Run {downloads_dir}/{script_name} to start OptiView")
74
79
 
75
80
  except Exception as e:
76
81
  click.echo(f"❌ Installation failed: {e}")
File without changes
@@ -0,0 +1,114 @@
1
+ import platform
2
+ import subprocess
3
+ import shutil
4
+ import click
5
+ from sima_cli.utils.env import is_sima_board
6
+
7
+ def connect_serial(ctx, baud):
8
+ """
9
+ Connect to the UART serial console of the DevKit.
10
+ Automatically installs required tools if missing.
11
+ """
12
+ if is_sima_board():
13
+ click.echo("🚫 This command is not supported on the DevKit. Please run it from your host machine.")
14
+ ctx.exit(1)
15
+
16
+ system = platform.system()
17
+ internal = ctx.obj.get("internal", False)
18
+
19
+ if system == "Darwin":
20
+ _connect_mac(baud)
21
+ elif system == "Linux":
22
+ _connect_linux(baud)
23
+ elif system == "Windows":
24
+ _print_windows_instructions()
25
+ else:
26
+ click.echo(f"⚠️ Unsupported OS: {system}. Only macOS, Linux, and Windows are supported.")
27
+ ctx.exit(1)
28
+
29
+ click.echo("✅ Serial session ended.")
30
+
31
+
32
+ def _connect_mac(baud):
33
+ terminal = "picocom"
34
+ if not shutil.which(terminal):
35
+ click.echo("⚙️ 'picocom' is not installed. Attempting to install with Homebrew...")
36
+ if shutil.which("brew"):
37
+ subprocess.run(["brew", "install", "picocom"], check=True)
38
+ else:
39
+ click.echo("❌ Homebrew not found. Please install Homebrew first: https://brew.sh/")
40
+ raise SystemExit(1)
41
+
42
+ ports = sorted(
43
+ subprocess.getoutput("ls /dev/tty.usbserial-* /dev/cu.usbserial-* 2>/dev/null").splitlines()
44
+ )
45
+ if not ports:
46
+ click.echo("❌ No USB serial device found.")
47
+ raise SystemExit(1)
48
+
49
+ click.echo(f"Connecting to device with picocom ({baud} baud)...")
50
+ click.echo("🧷 To exit: Press Ctrl + A, then Ctrl + X")
51
+ click.echo("📜 Scrollback will work in your terminal as expected.\n")
52
+
53
+ if not click.confirm("Proceed to connect?", default=True):
54
+ click.echo("❎ Connection aborted by user.")
55
+ return
56
+
57
+ port = ports[0]
58
+ click.echo(f"🔌 Connecting to {port} with picocom (115200 8N1)...")
59
+ try:
60
+ subprocess.run([
61
+ terminal,
62
+ "-b", f"{baud}",
63
+ "--databits", "8",
64
+ "--parity", "n",
65
+ "--stopbits", "1",
66
+ port
67
+ ])
68
+ except KeyboardInterrupt:
69
+ click.echo("\n❎ Serial connection interrupted by user.")
70
+
71
+
72
+ def _connect_linux(baud):
73
+ terminal = "picocom"
74
+ if not shutil.which(terminal):
75
+ click.echo("⚙️ 'picocom' is not installed. Attempting to install via apt...")
76
+ if shutil.which("apt-get"):
77
+ subprocess.run(["sudo", "apt-get", "update"], check=True)
78
+ subprocess.run(["sudo", "apt-get", "install", "-y", "picocom"], check=True)
79
+ else:
80
+ click.echo("❌ 'apt-get' not found. Please install picocom manually.")
81
+ raise SystemExit(1)
82
+
83
+ ports = sorted(
84
+ subprocess.getoutput("ls /dev/ttyUSB* 2>/dev/null").splitlines()
85
+ )
86
+ if not ports:
87
+ click.echo("❌ No USB serial device found.")
88
+ raise SystemExit(1)
89
+
90
+ port = ports[0]
91
+ click.echo(f"🔌 Connecting to {port} with picocom ({baud} 8N1)...")
92
+ try:
93
+ subprocess.run(
94
+ ["sudo", terminal, "-b", f"{baud}", "--databits", "8", "--parity", "n", "--stopbits", "1", port]
95
+ )
96
+ except KeyboardInterrupt:
97
+ click.echo("\n❎ Serial connection interrupted by user.")
98
+
99
+
100
+ def _print_windows_instructions():
101
+ click.echo("📘 To connect to the DevKit via a serial terminal on Windows, follow these steps:\n")
102
+
103
+ click.echo("1. Identify the COM Port:")
104
+ click.echo(" • Open **Device Manager** → Expand **Ports (COM & LPT)**.")
105
+ click.echo(" • Look for an entry like **USB Serial Port (COMx)**.\n")
106
+
107
+ click.echo("2. Install and Open a Serial Terminal:")
108
+ click.echo(" • Use **PuTTY** (Download from https://www.putty.org/) or **Tera Term**.")
109
+ click.echo(" • Set the **Connection Type** to **Serial**.")
110
+ click.echo(" • Enter the correct **COM Port** (e.g., COM3).")
111
+ click.echo(" • Set **Baud Rate** to **115200**.")
112
+ click.echo(" • Click **Open** to start the connection.\n")
113
+
114
+ click.echo("🔌 You are now ready to connect to the DevKit over serial.")
@@ -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
+ import threading
9
+ from tftpy import TftpServer
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
 
@@ -330,7 +331,6 @@ def write_image(version: str, board: str, swtype: str, internal: bool = False):
330
331
  except Exception as e:
331
332
  raise RuntimeError(f"❌ Failed to write image: {e}")
332
333
 
333
-
334
334
  if __name__ == "__main__":
335
335
  if len(sys.argv) != 2:
336
336
  click.echo("❌ Usage: python write_bootimg.py <image_file>")
@@ -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
@@ -84,7 +84,7 @@ def get_remote_board_info(ip: str, passwd: str = DEFAULT_PASSWORD) -> Tuple[str,
84
84
  return board_type, build_version
85
85
 
86
86
  except Exception as e:
87
- click.echo(f"Unable to retrieve board info {e}")
87
+ click.echo(f"Unable to retrieve board info with error: {e}, board may be still booting.")
88
88
  return "", ""
89
89
 
90
90
 
@@ -95,7 +95,7 @@ def _scp_file(sftp, local_path: str, remote_path: str):
95
95
  sftp.put(local_path, remote_path)
96
96
  click.echo("✅ Upload complete")
97
97
 
98
- def _run_remote_command(ssh, command: str, password: str = DEFAULT_PASSWORD):
98
+ def run_remote_command(ssh, command: str, password: str = DEFAULT_PASSWORD):
99
99
  """
100
100
  Run a remote command over SSH and stream its output live to the console.
101
101
  If the command starts with 'sudo', pipe in the password.
@@ -140,6 +140,13 @@ def _run_remote_command(ssh, command: str, password: str = DEFAULT_PASSWORD):
140
140
  for line in remaining_err.splitlines():
141
141
  click.echo(f"⚠️ {line}")
142
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
+
143
150
  def reboot_remote_board(ip: str, passwd: str):
144
151
  """
145
152
  Reboot remote board by sending SSH command
@@ -150,12 +157,36 @@ def reboot_remote_board(ip: str, passwd: str):
150
157
 
151
158
  ssh.connect(ip, username=DEFAULT_USER, password=passwd, timeout=10)
152
159
 
153
- _run_remote_command(ssh, "sudo systemctl stop watchdog", password=passwd)
154
- _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)
155
162
 
156
163
  except Exception as reboot_err:
157
164
  click.echo(f"⚠️ Unable to connect to the remote board")
158
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
+
159
190
  def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, passwd: str, reboot_and_wait: bool):
160
191
  """
161
192
  Upload and install firmware images to remote board over SSH.
@@ -177,7 +208,7 @@ def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, pa
177
208
  click.echo("🚀 Uploaded tRoot image.")
178
209
 
179
210
  # Run tRoot update
180
- _run_remote_command(
211
+ run_remote_command(
181
212
  ssh,
182
213
  f"sudo swupdate -H simaai-image-troot:1.0 -i /tmp/{troot_name}", password=passwd
183
214
  )
@@ -192,7 +223,7 @@ def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, pa
192
223
  click.echo("🚀 Uploaded system image.")
193
224
 
194
225
  # Run Palette update
195
- _run_remote_command(
226
+ run_remote_command(
196
227
  ssh,
197
228
  f"sudo swupdate -H simaai-image-palette:1.0 -i /tmp/{palette_name}",
198
229
  password=passwd
@@ -206,11 +237,12 @@ def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, pa
206
237
  click.echo("🔁 Rebooting board after update. Waiting for reconnection...")
207
238
 
208
239
  try:
209
- _run_remote_command(ssh, "sudo systemctl stop watchdog", password=passwd)
210
- _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)
211
242
 
212
243
  except Exception as reboot_err:
213
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)
214
246
 
215
247
  try:
216
248
  ssh.close()
@@ -229,7 +261,7 @@ def push_and_update_remote_board(ip: str, troot_path: str, palette_path: str, pa
229
261
  ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
230
262
  ssh.connect(ip, username=DEFAULT_USER, password=passwd, timeout=10)
231
263
 
232
- _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)
233
265
  ssh.close()
234
266
  except Exception as e:
235
267
  click.echo(f"❌ Unable to validate the version: {e}")
@@ -144,7 +144,18 @@ def _extract_required_files(tar_path: str, board: str, update_type: str = 'stand
144
144
  f"simaai-image-palette-{board}.wic.gz",
145
145
  f"simaai-image-palette-{board}.wic.bmap"
146
146
  }
147
-
147
+ elif update_type == 'netboot':
148
+ target_filenames = {
149
+ "Image",
150
+ "netboot.scr.uimg",
151
+ f"{board}-hhhl.dtb",
152
+ f"{board}-som.dtb",
153
+ f"{board}-dvt.dtb",
154
+ f"{board}-hhhl_x16.dtb",
155
+ f"simaai-image-palette-{board}.wic.gz",
156
+ f"simaai-image-palette-{board}.wic.bmap",
157
+ f"simaai-image-palette-{board}.cpio.gz"
158
+ }
148
159
  extracted_paths = []
149
160
 
150
161
  try:
sima_cli/utils/net.py ADDED
@@ -0,0 +1,29 @@
1
+ import psutil
2
+ import socket
3
+
4
+ def get_local_ip_candidates():
5
+ """
6
+ Return a list of IPv4 addresses on physical interfaces,
7
+ excluding VPN, loopback, and link-local interfaces.
8
+ """
9
+ vpn_prefixes = ("tun", "tap", "utun", "tailscale", "wg", "docker") # WireGuard, Tailscale, etc.
10
+ ip_list = []
11
+
12
+ for iface_name, iface_addrs in psutil.net_if_addrs().items():
13
+ # Exclude VPN or tunnel interfaces
14
+ if iface_name.startswith(vpn_prefixes):
15
+ continue
16
+
17
+ for addr in iface_addrs:
18
+ if addr.family == socket.AF_INET:
19
+ ip = addr.address
20
+
21
+ # Skip loopback and link-local
22
+ if ip.startswith("127.") or ip.startswith("169.254."):
23
+ continue
24
+
25
+ ip_list.append((iface_name, ip))
26
+
27
+ # Prioritize physical interfaces: eth0, en0, wlan0, etc.
28
+ ip_list.sort(key=lambda x: (not x[0].startswith(("eth", "en", "wlan", "wl")), x[0]))
29
+ return ip_list
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sima-cli
3
- Version: 0.0.19
3
+ Version: 0.0.20
4
4
  Summary: CLI tool for SiMa Developer Portal to download models, firmware, and apps.
5
5
  Home-page: https://developer.sima.ai/
6
6
  Author: SiMa.ai
@@ -24,6 +24,8 @@ Requires-Dist: paramiko
24
24
  Requires-Dist: plotext
25
25
  Requires-Dist: rich
26
26
  Requires-Dist: InquirerPy
27
+ Requires-Dist: tftpy
28
+ Requires-Dist: psutil
27
29
  Dynamic: author
28
30
  Dynamic: license-file
29
31
  Dynamic: requires-python
@@ -1,7 +1,7 @@
1
1
  sima_cli/__init__.py,sha256=Nb2jSg9-CX1XvSc1c21U9qQ3atINxphuNkNfmR-9P3o,332
2
2
  sima_cli/__main__.py,sha256=ehzD6AZ7zGytC2gLSvaJatxeD0jJdaEvNJvwYeGsWOg,69
3
- sima_cli/__version__.py,sha256=2xNp40YbbA9TjpFNNqUbe-Cjo-Xe8LvBbe2706ROs3k,49
4
- sima_cli/cli.py,sha256=k4w4NB6yfqjTrNBEv338yQTBWIMZ-ul7W-0vo7y01dc,11326
3
+ sima_cli/__version__.py,sha256=U-M6HF-1PsXkez7yhqMRRsr6FObzLVRQ6Spmo4bkl30,49
4
+ sima_cli/cli.py,sha256=b4ScjEa5Ltikry4ftZdJaNxBlSMYtmjYlodqzbRyWRk,12658
5
5
  sima_cli/app_zoo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  sima_cli/app_zoo/app.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  sima_cli/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -13,27 +13,31 @@ sima_cli/download/__init__.py,sha256=6y4O2FOCYFR2jdnQoVi3hRtEoZ0Gw6rydlTy1SGJ5FE
13
13
  sima_cli/download/downloader.py,sha256=pHfqcg_ujBQjds_EkcRV85M2mRYGrysoZaiR-FIrpf4,5161
14
14
  sima_cli/install/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  sima_cli/install/hostdriver.py,sha256=kAWDLebs60mbWIyTbUxmNrChcKW1uD5r7FtWNSUVUE4,5852
16
- sima_cli/install/optiview.py,sha256=VT8C1_EX-DW-n7DuxI9d0vPb87JBX-Rg9Bw9JHh1aeo,3108
16
+ sima_cli/install/optiview.py,sha256=i5eWVor-9MScEfrQm3Ty9OP4VpSsCgWvNh7AvYdZu7s,3365
17
17
  sima_cli/install/palette.py,sha256=uRznoHa4Mv9ZXHp6AoqknfC3RxpYNKi9Ins756Cyifk,3930
18
18
  sima_cli/mla/meminfo.py,sha256=ndc8kQJmWGEIdvNh6iIhATGdrkqM2pbddr_eHxaPNfg,1466
19
19
  sima_cli/model_zoo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  sima_cli/model_zoo/model.py,sha256=q91Nrg62j1TqwPO8HiX4nlEFCCmzNEFcyFTBVMbJm8w,9836
21
21
  sima_cli/sdk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  sima_cli/sdk/syscheck.py,sha256=h9zCULW67y4i2hqiGc-hc1ucBDShA5FAe9NxwBGq-fM,4575
23
+ sima_cli/serial/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
+ sima_cli/serial/serial.py,sha256=6xRta_PzE_DmmooYq35lbK76TYpAny5SEJAdYC_3fH0,4141
23
25
  sima_cli/update/__init__.py,sha256=0P-z-rSaev40IhfJXytK3AFWv2_sdQU4Ry6ei2sEus0,66
24
26
  sima_cli/update/bmaptool.py,sha256=KrhUGShBwY4Wzz50QiuMYAxxPgEy1nz5C68G-0a4qF4,4988
25
- sima_cli/update/bootimg.py,sha256=iMe8b42JK0KF-udSB6Kl044XUJtNgMUmBNxyuWneAkU,13387
27
+ sima_cli/update/bootimg.py,sha256=AOZI9bXrY8x-1gwlbIINdOUNO_naw9qteNJqeQ9_o-Y,13407
26
28
  sima_cli/update/local.py,sha256=CyUFLs5Lz5w4VyM6ip4wndKBBLz3_KZ-3scEvSiOrcg,3299
29
+ sima_cli/update/netboot.py,sha256=xTtRf8LMuqC_Ye-m6tlv5kbwkZwexc623kRymwiLTf4,18528
27
30
  sima_cli/update/query.py,sha256=cVkUMLZkONJ2XMEwqEC-JqLVB38hOqfWM2hB2ehBK6Y,3272
28
- sima_cli/update/remote.py,sha256=DvcvUX1ydpyF4DwUsVXagb7My6H2MNoGiCQfGfO06iI,8939
29
- sima_cli/update/updater.py,sha256=SprOcvSzRSwViGd2dYTtyTQfiwdLQcqZoS3KBQpxz4s,18466
31
+ sima_cli/update/remote.py,sha256=RXQbNCDr7d8wLJ7pdGwA6G3gzgwrfZ9l_7YNYUfGHDU,10067
32
+ sima_cli/update/updater.py,sha256=1K87YDj_isuMtL38ZI-hwBTIRmk3WqbkE6s-J0IVp-Q,18884
30
33
  sima_cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
34
  sima_cli/utils/artifactory.py,sha256=6YyVpzVm8ATy7NEwT9nkWx-wptkXrvG7Wl_zDT6jmLs,2390
32
35
  sima_cli/utils/config.py,sha256=wE-cPQqY_gOqaP8t01xsRHD9tBUGk9MgBUm2GYYxI3E,1616
33
36
  sima_cli/utils/config_loader.py,sha256=7I5we1yiCai18j9R9jvhfUzAmT3OjAqVK35XSLuUw8c,2005
34
37
  sima_cli/utils/env.py,sha256=Jrb062EnpMBr1jGMotBlI2j9LEH6W1Z5Tgt6LHY7yYQ,5753
38
+ sima_cli/utils/net.py,sha256=WVntA4CqipkNrrkA4tBVRadJft_pMcGYh4Re5xk3rqo,971
35
39
  sima_cli/utils/network.py,sha256=UvqxbqbWUczGFyO-t1SybG7Q-x9kjUVRNIn_D6APzy8,1252
36
- sima_cli-0.0.19.dist-info/licenses/LICENSE,sha256=a260OFuV4SsMZ6sQCkoYbtws_4o2deFtbnT9kg7Rfd4,1082
40
+ sima_cli-0.0.20.dist-info/licenses/LICENSE,sha256=a260OFuV4SsMZ6sQCkoYbtws_4o2deFtbnT9kg7Rfd4,1082
37
41
  tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
42
  tests/test_app_zoo.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
43
  tests/test_auth.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -42,8 +46,8 @@ tests/test_download.py,sha256=t87DwxlHs26_ws9rpcHGwr_OrcRPd3hz6Zmm0vRee2U,4465
42
46
  tests/test_firmware.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
47
  tests/test_model_zoo.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
48
  tests/test_utils.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
- sima_cli-0.0.19.dist-info/METADATA,sha256=7_B7RJl70GRcQOHvMQRrxhjvQhuQD9pqX3FUQvKRr1M,3631
46
- sima_cli-0.0.19.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
47
- sima_cli-0.0.19.dist-info/entry_points.txt,sha256=xRYrDq1nCs6R8wEdB3c1kKuimxEjWJkHuCzArQPT0Xk,47
48
- sima_cli-0.0.19.dist-info/top_level.txt,sha256=FtrbAUdHNohtEPteOblArxQNwoX9_t8qJQd59fagDlc,15
49
- sima_cli-0.0.19.dist-info/RECORD,,
49
+ sima_cli-0.0.20.dist-info/METADATA,sha256=0KwVrHOfciP7OQWLcpOhnarYPVPkTcffT4QSKNXqzwo,3674
50
+ sima_cli-0.0.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
51
+ sima_cli-0.0.20.dist-info/entry_points.txt,sha256=xRYrDq1nCs6R8wEdB3c1kKuimxEjWJkHuCzArQPT0Xk,47
52
+ sima_cli-0.0.20.dist-info/top_level.txt,sha256=FtrbAUdHNohtEPteOblArxQNwoX9_t8qJQd59fagDlc,15
53
+ sima_cli-0.0.20.dist-info/RECORD,,