picolan 1.0.0__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,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: picolan
3
+ Version: 1.0.0
4
+ Summary: Update, find and monitor RP2350 + W5500 boards that run the picolan library, over Ethernet
5
+ Author: Avi Raviv
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: POSIX :: Linux
9
+ Classifier: Operating System :: Microsoft :: Windows
10
+ Classifier: Topic :: Software Development :: Embedded Systems
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: tools/LICENSE
14
+ Dynamic: license-file
15
+
16
+ # picolan command-line tool
17
+
18
+ The `picolan` command finds, updates and monitors boards that run the picolan firmware library, over Ethernet. You do not need the source code, PlatformIO or a GitHub account.
19
+
20
+ ## Install
21
+
22
+ Requirement: Python 3.9 or later. The tool uses only the Python standard library.
23
+
24
+ ```sh
25
+ pip install picolan
26
+ ```
27
+
28
+ Update: `pip install -U picolan`. Check the version: `picolan --version`.
29
+
30
+ | System | Status |
31
+ |---|---|
32
+ | Linux | Tested. All network interfaces are used. |
33
+ | Windows | Supported. Not yet tested on hardware. |
34
+ | macOS | Default network interface only. Not tested. |
35
+
36
+ ## Connect the PC
37
+
38
+ 1. Connect the PC to the network of the boards.
39
+ 2. Give the PC an IP address in the subnet of the boards. Example: boards on 192.168.1.x/24, PC on 192.168.1.50/24.
40
+ 3. Run `picolan nodes`.
41
+
42
+ `picolan nodes` also finds boards in another subnet. An update needs an IP route to the board.
43
+
44
+ ## Update a system from a release bundle
45
+
46
+ A bundle is a zip file. It holds one firmware image per product and a manifest with the SHA-256 of each image.
47
+
48
+ 1. Copy the bundle to the PC, for example `release-v6.4.0.zip`.
49
+ 2. Run `picolan nodes`. Make sure that each product of the bundle appears once.
50
+ 3. Run `picolan push release-v6.4.0.zip`.
51
+
52
+ For each image, the tool:
53
+
54
+ 1. Finds the one board that runs the product of the image.
55
+ 2. Sends the image. The board restarts on the new firmware.
56
+ 3. Waits until the new firmware has run for 10 s, then confirms it.
57
+ 4. Checks that the board runs the new firmware.
58
+
59
+ The tool updates the boards one at a time. It stops at the first failure. At the end it prints one result line per board.
60
+
61
+ | Situation | Result |
62
+ |---|---|
63
+ | A product of the bundle has no board, or more than one board | Nothing is installed. |
64
+ | An image was changed after the bundle was made | Nothing is installed. |
65
+ | A board already runs the image | Skipped. Use `--reinstall` to install again. |
66
+ | The new firmware fails | The board restores its previous firmware by itself. The tool stops. |
67
+
68
+ ## Commands
69
+
70
+ | Command | Use |
71
+ |---|---|
72
+ | `picolan nodes` | List the boards: product, chip ID, IP, build, state. |
73
+ | `picolan push <files>` | Update boards from `.bin` files or bundles. |
74
+ | `picolan logs` | Show the log of a board. Ctrl+C to stop. |
75
+ | `picolan wipe` | Erase the stored data of a board (LittleFS and EEPROM). The firmware stays. |
76
+ | `picolan reboot` | Restart a board. |
77
+ | `picolan blink [seconds]` | Blink the LED of a board, if the firmware has one configured. |
78
+ | `picolan bundle -o <zip> <files>` | Make a bundle from `.bin` files. |
79
+
80
+ ## Select a board
81
+
82
+ `logs`, `wipe`, `reboot` and `blink` act on one board. With one board on the network, no option is necessary. Otherwise add `--node`:
83
+
84
+ ```sh
85
+ picolan --node motor-controller logs # product name
86
+ picolan --node 192.168.1.4 reboot
87
+ picolan --node 5C82 wipe # chip ID or a unique prefix of it
88
+ ```
89
+
90
+ The value must match exactly one board, or the tool stops.
91
+
92
+ ## Install log
93
+
94
+ The tool adds one line per board to an install log:
95
+
96
+ | System | File |
97
+ |---|---|
98
+ | Linux | `~/.local/state/picolan/install.log` |
99
+ | Windows | `%LOCALAPPDATA%\picolan\install.log` |
100
+
101
+ Example line:
102
+
103
+ ```
104
+ 2026-11-14T19:02:58+0200 result=OK chip=5C829BF5E4BF9E53 ip=192.168.1.33 product=sensor-hub from=a1b2c3d/2B393BC5 to=d4e5f6a/585622FA release=my-system/v6.4.0 source=release-v6.4.0.zip:sensor-hub.bin
105
+ ```
106
+
107
+ `result` is `OK`, `FAILED`, `SKIPPED` or `UNCONFIRMED`. Use `--log <file>` or the environment variable `PICOLAN_LOG` for another file, and `--no-log` for none.
108
+
109
+ ## Network options
110
+
111
+ | Option | Use |
112
+ |---|---|
113
+ | `--iface <name or IP>` | Use only this network interface. Linux: interface name, for example `eth0`. Any system: a local IPv4 address. Repeatable. |
114
+ | `PICOLAN_IFACE=eth0,eth1` | The same, as an environment variable. |
115
+
116
+ Port: 8208, UDP (discovery, commands, log) and TCP (firmware transfer).
@@ -0,0 +1,7 @@
1
+ picolan.py,sha256=BF2cXCY8wpRwhfhbDQqGOKlI560MfvZ0kPE2cK688lg,36298
2
+ picolan-1.0.0.dist-info/licenses/tools/LICENSE,sha256=bxRQrmzgXg2M9IUAMt9g3FD5tnHmWOAbmJlSwQuCRx0,1066
3
+ picolan-1.0.0.dist-info/METADATA,sha256=I6C8klgli50u1fVMRxYMo7B3_jUpVe5oB3V4j9FGvZo,4398
4
+ picolan-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ picolan-1.0.0.dist-info/entry_points.txt,sha256=k3vnsJQpm9jMjI1jWvxJrMVNFQYE3wTtyZ3abdEw7-s,41
6
+ picolan-1.0.0.dist-info/top_level.txt,sha256=Nv5jX-MMgO5_omJoD5e3yQSot3bdjmNURr2CR1yW0VE,8
7
+ picolan-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ picolan = picolan:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Avi Raviv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ picolan
picolan.py ADDED
@@ -0,0 +1,888 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ """picolan: find, update and monitor picolan boards over Ethernet.
4
+
5
+ picolan nodes boards on the network
6
+ picolan push build the PlatformIO project here, update its board
7
+ picolan push app.bin [more.bin ...] update each board that runs the product of an image
8
+ picolan push release.zip update all boards of a release bundle
9
+ picolan logs network log, like pio device monitor
10
+ picolan blink [seconds] blink the LED of a board
11
+ picolan wipe erase LittleFS and EEPROM, restart the board
12
+ picolan reboot
13
+ picolan bundle -o release.zip a.bin b.bin
14
+ picolan mac <chip> MAC address derived from a chip ID
15
+
16
+ Board selection (--node, or --upload-port in PlatformIO): a chip ID or a
17
+ unique prefix of it, an IP address, or a product name. It must match exactly
18
+ one board. Without --node a command uses the only board of the product
19
+ (push: the product of the image; --product), or the only board on the network.
20
+
21
+ Uses only the Python standard library (3.9+). Linux and Windows; macOS: default
22
+ network interface only.
23
+ """
24
+ import argparse
25
+ import configparser
26
+ import datetime
27
+ import hashlib
28
+ import io
29
+ import json
30
+ import os
31
+ import re
32
+ import select
33
+ import shutil
34
+ import socket
35
+ import struct
36
+ import subprocess
37
+ import sys
38
+ import time
39
+ import zipfile
40
+ import zlib
41
+
42
+ __version__ = "1.0.0"
43
+
44
+ PROTOCOL = 1
45
+ TAG_M1 = 0x4C434950
46
+ TAG_M2 = 0x8E3D71A5
47
+ TAG_FMT = "<III32s32sI"
48
+ TAG_SIZE = struct.calcsize(TAG_FMT)
49
+ SO_BINDTODEVICE = getattr(socket, "SO_BINDTODEVICE", 25)
50
+ BUNDLE_FORMAT = "picolan-bundle"
51
+ BUNDLE_VERSION = 1
52
+ IS_LINUX = sys.platform.startswith("linux")
53
+
54
+
55
+ class ToolError(Exception):
56
+ pass
57
+
58
+
59
+ def say(msg=""):
60
+ print(msg, flush=True)
61
+
62
+
63
+ def out(text):
64
+ sys.stdout.write(text)
65
+ sys.stdout.flush()
66
+
67
+
68
+ # ---------------------------------------------------------------- images
69
+
70
+ def mac_from_chip(chip_hex):
71
+ h = 0xCBF29CE484222325
72
+ for b in bytes.fromhex(chip_hex):
73
+ h ^= b
74
+ h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
75
+ low = h & 0xFFFFFFFFFF
76
+ return ":".join("%02X" % x for x in [0xD2] + [(low >> (8 * i)) & 0xFF for i in range(4, -1, -1)])
77
+
78
+
79
+ def find_tag(image):
80
+ """Return (product, build, protocol) of the picolan tag in an image, or None."""
81
+ magic = struct.pack("<II", TAG_M1, TAG_M2)
82
+ start = 0
83
+ while True:
84
+ off = image.find(magic, start)
85
+ if off < 0 or off + TAG_SIZE > len(image):
86
+ return None
87
+ if off % 4 == 0:
88
+ _, _, proto, product, build, m3 = struct.unpack_from(TAG_FMT, image, off)
89
+ if m3 == (~TAG_M1 & 0xFFFFFFFF):
90
+ def cut(b):
91
+ return b.split(b"\0", 1)[0].decode(errors="replace")
92
+ return cut(product), cut(build), proto
93
+ start = off + 1
94
+
95
+
96
+ class Image:
97
+ """A firmware image and the data picolan needs from it."""
98
+
99
+ def __init__(self, data, source):
100
+ self.data = data
101
+ self.source = source
102
+ tag = find_tag(data)
103
+ if not tag:
104
+ raise ToolError("%s does not contain picolan. After this upload the board could not "
105
+ "be updated over Ethernet any more. Use USB." % source)
106
+ self.product, self.build, proto = tag
107
+ if proto != PROTOCOL:
108
+ raise ToolError("%s uses picolan protocol %d, this tool speaks %d"
109
+ % (source, proto, PROTOCOL))
110
+ self.crc = zlib.crc32(data) & 0xFFFFFFFF
111
+ self.sha256 = hashlib.sha256(data).hexdigest()
112
+
113
+ @property
114
+ def crc_hex(self):
115
+ return "%08X" % self.crc
116
+
117
+
118
+ def read_file(path):
119
+ try:
120
+ with open(path, "rb") as f:
121
+ return f.read()
122
+ except OSError as e:
123
+ raise ToolError("cannot read %s: %s" % (path, e))
124
+
125
+
126
+ # ---------------------------------------------------------------- bundles
127
+
128
+ def make_bundle(images, name, release):
129
+ """Return the bytes of a bundle (zip) that holds the images and a manifest."""
130
+ seen = {}
131
+ for im in images:
132
+ if im.product in seen:
133
+ raise ToolError("%s and %s are both for product '%s'. A bundle holds one image per "
134
+ "product." % (seen[im.product], im.source, im.product))
135
+ seen[im.product] = im.source
136
+ manifest = {
137
+ "format": BUNDLE_FORMAT,
138
+ "version": BUNDLE_VERSION,
139
+ "name": name,
140
+ "release": release,
141
+ "created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
142
+ "tool": __version__,
143
+ "images": [{"file": "%s.bin" % im.product, "product": im.product, "build": im.build,
144
+ "size": len(im.data), "crc32": im.crc_hex, "sha256": im.sha256}
145
+ for im in images],
146
+ }
147
+ buf = io.BytesIO()
148
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
149
+ z.writestr("manifest.json", json.dumps(manifest, indent=2) + "\n")
150
+ for im in images:
151
+ z.writestr("%s.bin" % im.product, im.data)
152
+ return buf.getvalue()
153
+
154
+
155
+ def load_bundle(data, source):
156
+ """Return (manifest, [Image]) after checking every image against the manifest."""
157
+ try:
158
+ z = zipfile.ZipFile(io.BytesIO(data))
159
+ manifest = json.loads(z.read("manifest.json").decode())
160
+ except (zipfile.BadZipFile, KeyError, ValueError) as e:
161
+ raise ToolError("%s is not a picolan bundle (%s)" % (source, e))
162
+ if manifest.get("format") != BUNDLE_FORMAT:
163
+ raise ToolError("%s is not a picolan bundle (no picolan manifest)" % source)
164
+ if manifest.get("version") != BUNDLE_VERSION:
165
+ raise ToolError("%s has bundle format %s; this tool reads format %d. Update the tool: "
166
+ "pip install -U picolan" % (source, manifest.get("version"),
167
+ BUNDLE_VERSION))
168
+ images, products = [], set()
169
+ for entry in manifest.get("images", []):
170
+ fname = entry.get("file", "")
171
+ try:
172
+ body = z.read(fname)
173
+ except KeyError:
174
+ raise ToolError("%s: manifest lists %s, but the file is missing" % (source, fname))
175
+ if hashlib.sha256(body).hexdigest() != entry.get("sha256"):
176
+ raise ToolError("%s: %s is damaged or was changed (SHA-256 differs from the manifest)"
177
+ % (source, fname))
178
+ im = Image(body, "%s:%s" % (os.path.basename(source), fname))
179
+ if im.product != entry.get("product") or im.build != entry.get("build"):
180
+ raise ToolError("%s: %s contains %s build %s, the manifest says %s build %s"
181
+ % (source, fname, im.product, im.build, entry.get("product"),
182
+ entry.get("build")))
183
+ if im.product in products:
184
+ raise ToolError("%s: two images for product '%s'" % (source, im.product))
185
+ products.add(im.product)
186
+ images.append(im)
187
+ if not images:
188
+ raise ToolError("%s contains no images" % source)
189
+ return manifest, images
190
+
191
+
192
+ def load_images(paths):
193
+ """Images from .bin files and bundles. Returns (images, bundle manifests)."""
194
+ images, manifests = [], []
195
+ for p in paths:
196
+ data = read_file(p)
197
+ if data[:4] == b"PK\x03\x04":
198
+ m, ims = load_bundle(data, p)
199
+ manifests.append(m)
200
+ images += ims
201
+ else:
202
+ images.append(Image(data, p))
203
+ seen = {}
204
+ for im in images:
205
+ if im.product in seen:
206
+ raise ToolError("%s and %s are both for product '%s'. Give one image per product."
207
+ % (seen[im.product], im.source, im.product))
208
+ seen[im.product] = im.source
209
+ return images, manifests
210
+
211
+
212
+ # ---------------------------------------------------------------- PlatformIO project
213
+
214
+ def find_pio():
215
+ for name in ("pio", "platformio"):
216
+ p = shutil.which(name)
217
+ if p:
218
+ return p
219
+ home = os.path.expanduser("~")
220
+ for p in (os.path.join(home, ".platformio", "penv", "bin", "pio"),
221
+ os.path.join(home, ".platformio", "penv", "Scripts", "pio.exe")):
222
+ if os.path.isfile(p):
223
+ return p
224
+ raise ToolError("PlatformIO not found. Install it, or give the firmware file: "
225
+ "picolan push firmware.bin")
226
+
227
+
228
+ def project_envs(ini_path):
229
+ cp = configparser.ConfigParser(inline_comment_prefixes=(";", "#"), strict=False,
230
+ interpolation=None)
231
+ cp.read(ini_path)
232
+ envs = [s[4:] for s in cp.sections() if s.startswith("env:")]
233
+ default = []
234
+ if cp.has_option("platformio", "default_envs"):
235
+ default = [e for e in re.split(r"[,\s]+", cp.get("platformio", "default_envs")) if e]
236
+ return envs, default
237
+
238
+
239
+ def build_project(env_name):
240
+ """Build the PlatformIO project in the current folder; return the image path."""
241
+ ini = os.path.join(os.getcwd(), "platformio.ini")
242
+ if not os.path.isfile(ini):
243
+ raise ToolError("no firmware file given and no platformio.ini in this folder")
244
+ envs, default = project_envs(ini)
245
+ if not env_name:
246
+ if len(default) == 1:
247
+ env_name = default[0]
248
+ elif len(envs) == 1:
249
+ env_name = envs[0]
250
+ else:
251
+ raise ToolError("this project has more than one environment (%s). Select one with "
252
+ "-e <env>." % ", ".join(envs))
253
+ if env_name not in envs:
254
+ raise ToolError("no environment '%s' in platformio.ini (%s)" % (env_name, ", ".join(envs)))
255
+ say("Building %s ..." % env_name)
256
+ rc = subprocess.call([find_pio(), "run", "-e", env_name])
257
+ if rc != 0:
258
+ raise ToolError("build failed")
259
+ return os.path.join(os.getcwd(), ".pio", "build", env_name, "firmware.bin")
260
+
261
+
262
+ # ---------------------------------------------------------------- install log
263
+
264
+ def default_log_path():
265
+ if os.name == "nt":
266
+ base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
267
+ return os.path.join(base, "picolan", "install.log")
268
+ base = os.environ.get("XDG_STATE_HOME") or os.path.join(os.path.expanduser("~"), ".local",
269
+ "state")
270
+ return os.path.join(base, "picolan", "install.log")
271
+
272
+
273
+ def write_log(path, fields):
274
+ if not path:
275
+ return
276
+ stamp = datetime.datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
277
+ line = stamp + " " + " ".join("%s=%s" % (k, str(v).replace(" ", "_")) for k, v in fields)
278
+ try:
279
+ os.makedirs(os.path.dirname(path), exist_ok=True)
280
+ with open(path, "a", encoding="utf-8") as f:
281
+ f.write(line + "\n")
282
+ except OSError as e:
283
+ say("warning: cannot write the install log %s: %s" % (path, e))
284
+
285
+
286
+ # ---------------------------------------------------------------- network
287
+
288
+ def linux_ifaces():
289
+ """Physical or USB network interfaces that are up (no loopback/virtual)."""
290
+ base = "/sys/class/net"
291
+ found = []
292
+ try:
293
+ names = sorted(os.listdir(base))
294
+ except OSError:
295
+ return found
296
+ for name in names:
297
+ if name == "lo" or os.path.exists(os.path.join("/sys/devices/virtual/net", name)):
298
+ continue
299
+ try:
300
+ with open(os.path.join(base, name, "operstate")) as f:
301
+ if f.read().strip() not in ("up", "unknown"):
302
+ continue
303
+ except OSError:
304
+ continue
305
+ found.append(name)
306
+ return found
307
+
308
+
309
+ def iface_ipv4(name):
310
+ """(address, netmask) of a Linux interface, or None."""
311
+ try:
312
+ import fcntl
313
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
314
+ req = struct.pack("256s", name.encode()[:15])
315
+ addr = socket.inet_ntoa(fcntl.ioctl(s.fileno(), 0x8915, req)[20:24]) # SIOCGIFADDR
316
+ mask = socket.inet_ntoa(fcntl.ioctl(s.fileno(), 0x891B, req)[20:24]) # SIOCGIFNETMASK
317
+ s.close()
318
+ return addr, mask
319
+ except (OSError, ImportError):
320
+ return None
321
+
322
+
323
+ def local_ipv4():
324
+ """IPv4 addresses of this PC (any OS), without loopback."""
325
+ addrs = set()
326
+ try:
327
+ for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
328
+ addrs.add(info[4][0])
329
+ except OSError:
330
+ pass
331
+ try:
332
+ addrs.update(socket.gethostbyname_ex(socket.gethostname())[2])
333
+ except OSError:
334
+ pass
335
+ return sorted(a for a in addrs if not a.startswith("127."))
336
+
337
+
338
+ def is_ipv4(text):
339
+ try:
340
+ socket.inet_aton(text)
341
+ except OSError:
342
+ return False
343
+ return text.count(".") == 3
344
+
345
+
346
+ class Bus:
347
+ """UDP broadcast on the management port, on every network interface.
348
+
349
+ Linux: one socket per interface (SO_BINDTODEVICE). Other systems: one
350
+ socket per local IPv4 address, plus one on all addresses. A broadcast
351
+ from a socket bound to an address leaves through that interface."""
352
+
353
+ def __init__(self, port, ifaces):
354
+ self.port = port
355
+ self.socks = []
356
+ if IS_LINUX:
357
+ for name in [i for i in ifaces if not is_ipv4(i)] or ([] if ifaces else linux_ifaces()):
358
+ s = self._sock()
359
+ try:
360
+ s.setsockopt(socket.SOL_SOCKET, SO_BINDTODEVICE, name.encode())
361
+ except OSError:
362
+ s.close()
363
+ continue
364
+ s.bind(("", 0))
365
+ self.socks.append((name, s))
366
+ addrs = [i for i in ifaces if is_ipv4(i)]
367
+ if not IS_LINUX and not ifaces:
368
+ addrs = local_ipv4()
369
+ for a in addrs:
370
+ s = self._sock()
371
+ try:
372
+ s.bind((a, 0))
373
+ except OSError:
374
+ s.close()
375
+ continue
376
+ self.socks.append((a, s))
377
+ if not self.socks or (not IS_LINUX and not ifaces):
378
+ s = self._sock()
379
+ s.bind(("", 0))
380
+ self.socks.append(("any", s))
381
+
382
+ @staticmethod
383
+ def _sock():
384
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
385
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
386
+ return s
387
+
388
+ def send(self, text):
389
+ for _, s in self.socks:
390
+ try:
391
+ s.sendto(text.encode(), ("255.255.255.255", self.port))
392
+ except OSError:
393
+ pass
394
+
395
+ def recv(self, timeout):
396
+ """Yield (iface, text) until timeout seconds have passed."""
397
+ end = time.time() + timeout
398
+ by_fd = {s.fileno(): (n, s) for n, s in self.socks}
399
+ while True:
400
+ left = end - time.time()
401
+ if left <= 0:
402
+ return
403
+ ready, _, _ = select.select(list(by_fd), [], [], left)
404
+ for fd in ready:
405
+ name, s = by_fd[fd]
406
+ try:
407
+ data, _ = s.recvfrom(2048)
408
+ except OSError:
409
+ continue
410
+ yield name, data.decode(errors="replace")
411
+
412
+
413
+ def parse_node(text, iface):
414
+ fields = dict(kv.split("=", 1) for kv in text.split()[1:] if "=" in kv)
415
+ fields["iface"] = iface
416
+ return fields
417
+
418
+
419
+ def discover(bus, timeout=1.2, max_wait=3.5):
420
+ """Collect NODE answers. Waits at least `timeout` s; resends DISCOVER once a
421
+ second until something answers or `max_wait` s have passed (a board with a
422
+ busy main loop can answer late)."""
423
+ nodes = {}
424
+ start = time.time()
425
+ while True:
426
+ bus.send("DISCOVER")
427
+ for iface, text in bus.recv(1.0):
428
+ if text.startswith("NODE "):
429
+ n = parse_node(text, iface)
430
+ if "chip" in n:
431
+ nodes[n["chip"]] = n
432
+ waited = time.time() - start
433
+ if (nodes and waited >= timeout) or waited >= max(max_wait, timeout):
434
+ return nodes
435
+
436
+
437
+ def print_table(nodes):
438
+ cols = ["product", "chip", "ip", "build", "state", "conflict", "iface"]
439
+ head = ["PRODUCT", "CHIP", "IP", "BUILD", "STATE", "IP CONFLICT", "IFACE"]
440
+ rows = [[n.get(c, "?") if c != "conflict" else ("YES" if n.get(c) == "1" else "no")
441
+ for c in cols]
442
+ for n in sorted(nodes.values(), key=lambda n: (n.get("product", ""), n["chip"]))]
443
+ widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(head)]
444
+ say(" ".join(h.ljust(w) for h, w in zip(head, widths)).rstrip())
445
+ for r in rows:
446
+ say(" ".join(v.ljust(w) for v, w in zip(r, widths)).rstrip())
447
+ seen = {}
448
+ for n in nodes.values():
449
+ seen.setdefault(n.get("ip"), []).append(n["chip"])
450
+ for ip, chips in seen.items():
451
+ if len(chips) > 1:
452
+ say("warning: %s is used by %d boards: %s" % (ip, len(chips), ", ".join(chips)))
453
+
454
+
455
+ def match_target(nodes, target):
456
+ """Boards that a --node value names: IP, product name, or chip ID prefix."""
457
+ if is_ipv4(target):
458
+ return [n for n in nodes.values() if n.get("ip") == target]
459
+ by_product = [n for n in nodes.values() if n.get("product") == target]
460
+ if by_product:
461
+ return by_product
462
+ if re.fullmatch(r"[0-9A-Fa-f]{1,16}", target):
463
+ t = target.upper()
464
+ return [n for c, n in nodes.items() if c.upper().startswith(t)]
465
+ return []
466
+
467
+
468
+ def select_node(nodes, target, product):
469
+ """The one board named by `target`, else the only board of `product`, else
470
+ the only board on the network."""
471
+ if target:
472
+ hits = match_target(nodes, target)
473
+ what = "'%s'" % target
474
+ else:
475
+ hits = [n for n in nodes.values() if not product or n.get("product") == product]
476
+ what = "product '%s'" % product if product else "any product"
477
+ if len(hits) == 1:
478
+ return hits[0]
479
+ if not hits:
480
+ if nodes:
481
+ say("Boards on the network:")
482
+ print_table(nodes)
483
+ raise ToolError("no board matches %s" % what)
484
+ raise ToolError("no board answered. Check the cable and that the firmware contains "
485
+ "picolan.")
486
+ say("More than one board matches %s:" % what)
487
+ print_table({n["chip"]: n for n in hits})
488
+ raise ToolError("select one with --node <chip ID or IP> (pio: --upload-port <chip ID or IP>)")
489
+
490
+
491
+ def subnet_hint(node):
492
+ ip = node.get("ip", "?")
493
+ mine = []
494
+ if IS_LINUX:
495
+ for name in linux_ifaces():
496
+ a = iface_ipv4(name)
497
+ if a:
498
+ mine.append("%s %s/%s" % (name, a[0], a[1]))
499
+ else:
500
+ mine = local_ipv4()
501
+ return ("cannot open a TCP connection to %s.\n"
502
+ " The PC needs an address in the board's subnet (%s/%s).\n"
503
+ " PC addresses: %s\n"
504
+ " Fix: give the PC an address in that subnet."
505
+ % (ip, ip, node.get("subnet", "?"), ", ".join(mine) or "unknown"))
506
+
507
+
508
+ def command(bus, node, text, expect, timeout=1.5, tries=3):
509
+ """Send a chip-addressed command; return the reply split into words, or None."""
510
+ for _ in range(tries):
511
+ bus.send(text)
512
+ for _, reply in bus.recv(timeout):
513
+ parts = reply.split()
514
+ if len(parts) >= 3 and parts[1] == expect and parts[2] == node["chip"]:
515
+ return parts
516
+ return None
517
+
518
+
519
+ def refused(parts):
520
+ return " ".join(parts[3:]) if parts and len(parts) > 3 else "no answer"
521
+
522
+
523
+ # ---------------------------------------------------------------- push
524
+
525
+ def read_line(sock, timeout):
526
+ sock.settimeout(timeout)
527
+ data = b""
528
+ while not data.endswith(b"\n"):
529
+ ch = sock.recv(1)
530
+ if not ch:
531
+ break
532
+ data += ch
533
+ return data.decode(errors="replace").strip()
534
+
535
+
536
+ def transfer(node, image, force):
537
+ """Send the image over TCP. Returns after the board answered OK."""
538
+ try:
539
+ sock = socket.create_connection((node["ip"], int(node.get("update", 8208))), timeout=4)
540
+ except OSError:
541
+ raise ToolError(subnet_hint(node))
542
+ with sock:
543
+ try:
544
+ hello = read_line(sock, 5).split()
545
+ if len(hello) < 3 or hello[0] != "PICOLAN":
546
+ raise ToolError("unexpected answer from %s: %s" % (node["ip"], " ".join(hello)))
547
+ if hello[2] != node["chip"]:
548
+ raise ToolError("%s answered as chip %s, not %s: two devices share this IP"
549
+ % (node["ip"], hello[2], node["chip"]))
550
+ sock.sendall(("PUSH %s %d %s %s %d\n" % (node["chip"], len(image.data), image.crc_hex,
551
+ image.product, 1 if force else 0)).encode())
552
+ ready = read_line(sock, 30)
553
+ if ready != "READY":
554
+ raise ToolError("board refused the update: %s" % (ready or "connection closed"))
555
+ data = image.data
556
+ sent, step = 0, max(len(data) // 20, 1)
557
+ mark = step
558
+ out("Sending ")
559
+ while sent < len(data):
560
+ sent += sock.send(data[sent:sent + 4096])
561
+ while sent >= mark:
562
+ out(".")
563
+ mark += step
564
+ result = read_line(sock, 30)
565
+ except socket.timeout:
566
+ raise ToolError("the board stopped answering during the transfer. It keeps its "
567
+ "current firmware.")
568
+ except OSError as e:
569
+ raise ToolError("connection lost during the transfer (%s). The board keeps its "
570
+ "current firmware." % e)
571
+ if result != "OK":
572
+ say("")
573
+ raise ToolError("board refused the image: %s" % (result or "connection closed"))
574
+
575
+
576
+ def push_one(bus, node, image, force, confirm):
577
+ """Update one board. Raises ToolError on failure."""
578
+ t0 = time.time()
579
+ say("Board %s %s %s build %s (%s)" % (node["chip"], node["ip"], node.get("product"),
580
+ node.get("build"), node.get("state")))
581
+ say("Image %s build %s, %d bytes, crc %s" % (image.product, image.build, len(image.data),
582
+ image.crc_hex))
583
+ transfer(node, image, force)
584
+ say(" OK (%.1f s)" % (time.time() - t0))
585
+
586
+ say("Waiting for the board to start the new firmware ...")
587
+ t1 = time.time()
588
+ back = None
589
+ while time.time() - t1 < 60:
590
+ n = discover(bus, 0.5, 1.0).get(node["chip"])
591
+ if not n:
592
+ continue
593
+ if n.get("state") == "trial" and n.get("trialcrc", "").upper() == image.crc_hex:
594
+ back = n
595
+ break
596
+ if n.get("state") == "rolledback" and n.get("crc") != image.crc_hex:
597
+ raise ToolError("the new firmware failed (it kept restarting); the board restored "
598
+ "the previous firmware. See: picolan logs")
599
+ if n.get("state") == "committed" and n.get("crc") != image.crc_hex and \
600
+ time.time() - t1 > 3:
601
+ raise ToolError("the new firmware did not start; the board runs its previous "
602
+ "firmware. See: picolan logs")
603
+ if not back:
604
+ raise ToolError("the board did not come back within 60 s. If the new firmware is broken, "
605
+ "the board restores the previous firmware by itself. See: picolan logs")
606
+ say("Running build %s on trial after %.1f s" % (back.get("build"), time.time() - t1))
607
+ if not confirm:
608
+ say("Not confirming (--no-confirm): the board restores the previous firmware after %s "
609
+ "unconfirmed boots." % back.get("maxboots", "?"))
610
+ return
611
+
612
+ # The board accepts the confirmation only after the new firmware has run
613
+ # normally for a while (confirmAfterMs, default 10 s).
614
+ out("Confirming ")
615
+ deadline = time.time() + 45
616
+ while True:
617
+ parts = command(bus, node, "COMMIT %s %s" % (node["chip"], image.crc_hex), "COMMIT",
618
+ timeout=2, tries=1)
619
+ if parts and parts[0] == "ACK":
620
+ break
621
+ if parts and parts[3:4] == ["rolledback"]:
622
+ say("")
623
+ raise ToolError("the new firmware failed (it kept restarting); the board restored "
624
+ "the previous firmware. See: picolan logs")
625
+ if parts and parts[3:5] == ["committed", "not-on-trial"]:
626
+ break # an earlier ACK was lost; verified below
627
+ if (parts and parts[4:5] != ["too-early"]) or time.time() > deadline:
628
+ say("")
629
+ raise ToolError("the board did not accept the confirmation (%s). It will restore "
630
+ "the previous firmware." % (" ".join(parts) if parts else "no answer"))
631
+ out(".")
632
+ time.sleep(1)
633
+ n = discover(bus, 0.5).get(node["chip"])
634
+ if not n or n.get("state") != "committed" or n.get("crc") != image.crc_hex:
635
+ say("")
636
+ raise ToolError("could not verify the result (board reports state %s, crc %s). "
637
+ "Check with: picolan nodes" % (n.get("state") if n else "?",
638
+ n.get("crc") if n else "?"))
639
+ say(" ok")
640
+ say("Confirmed in %.1f s." % (time.time() - t0))
641
+
642
+
643
+ def plan_push(nodes, images, target, force):
644
+ """Pair each image with the one board that receives it."""
645
+ if target:
646
+ if len(images) != 1:
647
+ raise ToolError("--node selects one board; give one image")
648
+ node = select_node(nodes, target, None)
649
+ im = images[0]
650
+ if node.get("product") != im.product and not force:
651
+ raise ToolError("board %s runs product '%s', the image is '%s'. Use --force-product "
652
+ "to change the product of this board."
653
+ % (node["chip"], node.get("product"), im.product))
654
+ return [(im, node)]
655
+ if force:
656
+ raise ToolError("--force-product changes the product of a board; name the board with "
657
+ "--node <chip ID or IP>")
658
+ plan, problems = [], []
659
+ for im in images:
660
+ hits = [n for n in nodes.values() if n.get("product") == im.product]
661
+ if len(hits) == 1:
662
+ plan.append((im, hits[0]))
663
+ elif not hits:
664
+ problems.append("no board runs product '%s'" % im.product)
665
+ else:
666
+ problems.append("%d boards run product '%s': %s. Update them one at a time with "
667
+ "--node." % (len(hits), im.product,
668
+ ", ".join("%s (%s)" % (n["chip"], n.get("ip"))
669
+ for n in hits)))
670
+ if problems:
671
+ if nodes:
672
+ say("Boards on the network:")
673
+ print_table(nodes)
674
+ raise ToolError("nothing was installed:\n " + "\n ".join(problems))
675
+ for im, node in plan:
676
+ if node.get("conflict") == "1":
677
+ raise ToolError("board %s reports an IP conflict on %s. Fix the IP first. "
678
+ "Nothing was installed." % (node["chip"], node["ip"]))
679
+ return plan
680
+
681
+
682
+ def cmd_push(bus, a):
683
+ files = a.files or [build_project(a.env)]
684
+ images, manifests = load_images(files)
685
+ for m in manifests:
686
+ say("Bundle %s %s (%d images, created %s)" % (m.get("name") or "-",
687
+ m.get("release") or "-",
688
+ len(m.get("images", [])),
689
+ m.get("created", "?")))
690
+ plan = plan_push(discover(bus), images, a.node, a.force_product)
691
+ log = None if a.no_log else (a.log or os.environ.get("PICOLAN_LOG") or default_log_path())
692
+ release = ",".join("%s/%s" % (m.get("name") or "-", m.get("release") or "-")
693
+ for m in manifests) or "-"
694
+ results = []
695
+ for i, (im, node) in enumerate(plan):
696
+ if len(plan) > 1:
697
+ say("")
698
+ say("[%d/%d] %s" % (i + 1, len(plan), im.product))
699
+ fields = [("chip", node["chip"]), ("ip", node.get("ip")), ("product", im.product),
700
+ ("from", "%s/%s" % (node.get("build"), node.get("crc"))),
701
+ ("to", "%s/%s" % (im.build, im.crc_hex)), ("release", release),
702
+ ("source", im.source)]
703
+ if node.get("crc") == im.crc_hex and node.get("state") == "committed" and not a.reinstall:
704
+ say("%s %s already runs build %s; skipped (--reinstall to install again)"
705
+ % (node["chip"], im.product, im.build))
706
+ results.append((im, node, "skipped", ""))
707
+ write_log(log, [("result", "SKIPPED")] + fields)
708
+ continue
709
+ try:
710
+ push_one(bus, node, im, a.force_product, not a.no_confirm)
711
+ except ToolError as e:
712
+ results.append((im, node, "FAILED", str(e)))
713
+ write_log(log, [("result", "FAILED")] + fields + [("reason", str(e).split("\n")[0])])
714
+ break
715
+ results.append((im, node, "ok", ""))
716
+ write_log(log, [("result", "OK" if not a.no_confirm else "UNCONFIRMED")] + fields)
717
+ failed = [r for r in results if r[2] == "FAILED"]
718
+ if len(plan) > 1 or failed:
719
+ say("")
720
+ say("Result:")
721
+ done = {r[0].product for r in results}
722
+ for im, node, res, why in results:
723
+ say(" %-24s %-16s %-15s %s%s" % (im.product, node["chip"], node.get("ip"), res,
724
+ (": " + why.split("\n")[0]) if why else ""))
725
+ for im, node in plan:
726
+ if im.product not in done:
727
+ say(" %-24s %-16s %-15s not started" % (im.product, node["chip"],
728
+ node.get("ip")))
729
+ if failed:
730
+ raise ToolError("update stopped at %s" % failed[0][0].product)
731
+ return 0
732
+
733
+
734
+ # ---------------------------------------------------------------- other commands
735
+
736
+ def cmd_nodes(bus, a):
737
+ nodes = discover(bus)
738
+ if not nodes:
739
+ say("No boards answered.")
740
+ return 1
741
+ print_table(nodes)
742
+ return 0
743
+
744
+
745
+ def cmd_logs(bus, a):
746
+ node = select_node(discover(bus), a.node, a.product)
747
+ chip = node["chip"]
748
+ say("--- picolan logs %s %s (%s), Ctrl+C to quit ---" % (node.get("product"), chip,
749
+ node.get("ip")))
750
+ boot, last, renew = None, None, 0.0
751
+ end = time.time() + a.seconds if a.seconds else float("inf")
752
+ pat = re.compile(r"LOG (\S+) ([0-9A-F]{4}) (\d+) ?(.*)", re.S)
753
+ try:
754
+ while time.time() < end:
755
+ if time.time() >= renew:
756
+ bus.send("MONITOR %s 10" % chip)
757
+ renew = time.time() + 3
758
+ for _, text in bus.recv(0.2):
759
+ m = pat.match(text)
760
+ if not m or m[1] != chip:
761
+ continue
762
+ b, seq, line = m[2], int(m[3]), m[4]
763
+ if b != boot:
764
+ if boot is not None:
765
+ say("--- board restarted ---")
766
+ boot, last = b, None
767
+ if last is not None:
768
+ if seq <= last:
769
+ continue
770
+ if seq > last + 1:
771
+ say("--- %d line(s) lost ---" % (seq - last - 1))
772
+ last = seq
773
+ say("%s %s" % (time.strftime("%H:%M:%S"), line))
774
+ except KeyboardInterrupt:
775
+ pass
776
+ return 0
777
+
778
+
779
+ def cmd_blink(bus, a):
780
+ node = select_node(discover(bus), a.node, a.product)
781
+ secs = int(a.args[0]) if a.args else 10
782
+ parts = command(bus, node, "IDENTIFY %s %d" % (node["chip"], secs), "IDENTIFY")
783
+ if not parts:
784
+ raise ToolError("no answer from %s" % node["chip"])
785
+ if parts[0] != "ACK":
786
+ raise ToolError("board %s cannot blink: %s" % (node["chip"], refused(parts)))
787
+ say("Board %s (%s) blinks for %d s." % (node["chip"], node["ip"], secs))
788
+ return 0
789
+
790
+
791
+ def cmd_wipe(bus, a):
792
+ node = select_node(discover(bus), a.node, a.product)
793
+ say("Erasing LittleFS and EEPROM of %s %s (%s) ..." % (node.get("product"), node["chip"],
794
+ node["ip"]))
795
+ parts = command(bus, node, "WIPE %s" % node["chip"], "WIPE", timeout=60, tries=1)
796
+ if not parts:
797
+ raise ToolError("no answer from %s; check the result with: picolan logs" % node["chip"])
798
+ if parts[0] != "ACK":
799
+ raise ToolError("wipe failed: %s" % refused(parts))
800
+ say("Board %s: storage erased and verified. It restarts."
801
+ % node["chip"])
802
+ return 0
803
+
804
+
805
+ def cmd_reboot(bus, a):
806
+ node = select_node(discover(bus), a.node, a.product)
807
+ parts = command(bus, node, "REBOOT %s" % node["chip"], "REBOOT")
808
+ if not parts or parts[0] != "ACK":
809
+ raise ToolError("board did not restart: %s" % refused(parts))
810
+ say("Board %s restarting." % node["chip"])
811
+ return 0
812
+
813
+
814
+ def cmd_bundle(a):
815
+ images, manifests = load_images(a.files)
816
+ if manifests:
817
+ raise ToolError("give .bin files, not bundles")
818
+ data = make_bundle(images, a.name, a.release)
819
+ try:
820
+ with open(a.output, "wb") as f:
821
+ f.write(data)
822
+ except OSError as e:
823
+ raise ToolError("cannot write %s: %s" % (a.output, e))
824
+ say("%s: %d images, %d bytes" % (a.output, len(images), len(data)))
825
+ for im in images:
826
+ say(" %-24s build %-16s %7d bytes crc %s" % (im.product, im.build, len(im.data),
827
+ im.crc_hex))
828
+ return 0
829
+
830
+
831
+ def main(argv=None):
832
+ ap = argparse.ArgumentParser(prog="picolan", description=__doc__,
833
+ formatter_class=argparse.RawDescriptionHelpFormatter)
834
+ ap.add_argument("--version", action="version", version="picolan " + __version__)
835
+ ap.add_argument("--node", "-n", help="board: chip ID (or unique prefix), IP or product name")
836
+ ap.add_argument("--product", help="without --node: use the only board of this product")
837
+ ap.add_argument("--iface", action="append", default=[],
838
+ help="network interface name (Linux) or local IPv4 address to use "
839
+ "(repeatable; default: all)")
840
+ ap.add_argument("--port", type=int, default=8208, help="picolan port (UDP)")
841
+ sub = ap.add_subparsers(dest="cmd", required=True)
842
+ sub.add_parser("nodes", aliases=["list"], help="list the boards on the network")
843
+ p = sub.add_parser("push", help="update boards")
844
+ p.add_argument("files", nargs="*", help=".bin images or bundles (default: build this project)")
845
+ p.add_argument("-e", "--env", help="PlatformIO environment to build (without files)")
846
+ p.add_argument("--force-product", action="store_true",
847
+ help="allow an image of another product (needs --node)")
848
+ p.add_argument("--reinstall", action="store_true",
849
+ help="install also when the board already runs this image")
850
+ p.add_argument("--no-confirm", action="store_true",
851
+ help="test only: do not confirm, so the board rolls back")
852
+ p.add_argument("--log", help="install log file (default: %s)" % default_log_path())
853
+ p.add_argument("--no-log", action="store_true", help="do not write the install log")
854
+ lg = sub.add_parser("logs", help="show the network log of a board")
855
+ lg.add_argument("--seconds", type=float, default=0)
856
+ for name in ("blink", "wipe", "reboot"):
857
+ sp = sub.add_parser(name)
858
+ sp.add_argument("args", nargs="*")
859
+ b = sub.add_parser("bundle", help="make a release bundle from .bin images")
860
+ b.add_argument("files", nargs="+")
861
+ b.add_argument("-o", "--output", required=True)
862
+ b.add_argument("--name", default="", help="system name, e.g. the product line")
863
+ b.add_argument("--release", default="", help="release version, e.g. v6.4.0")
864
+ m = sub.add_parser("mac", help="MAC address derived from a chip ID")
865
+ m.add_argument("chip")
866
+ a = ap.parse_args(argv)
867
+ try:
868
+ if a.cmd == "mac":
869
+ say(mac_from_chip(a.chip))
870
+ return 0
871
+ if a.cmd == "bundle":
872
+ return cmd_bundle(a)
873
+ ifaces = a.iface or [i for i in os.environ.get("PICOLAN_IFACE", "").split(",") if i]
874
+ bus = Bus(a.port, ifaces)
875
+ handlers = {"nodes": cmd_nodes, "list": cmd_nodes, "push": cmd_push, "logs": cmd_logs,
876
+ "blink": cmd_blink, "wipe": cmd_wipe,
877
+ "reboot": cmd_reboot}
878
+ return handlers[a.cmd](bus, a)
879
+ except ToolError as e:
880
+ say("picolan: %s" % e)
881
+ return 1
882
+ except BrokenPipeError: # output piped into a command that exited (head, grep -m)
883
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
884
+ return 0
885
+
886
+
887
+ if __name__ == "__main__":
888
+ sys.exit(main())