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,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())
|