pandora-sdk 0.4.1__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,32 @@
1
+ """Hands from the 01 Glove in a few lines of Python.
2
+
3
+ import pandora_sdk
4
+
5
+ with pandora_sdk.connect() as gloves: # through the running app
6
+ gloves.subscribe(hands=["right"], rate_hz=50, bones=False)
7
+ for hand in gloves.frames():
8
+ print(hand.angles_deg)
9
+
10
+ connect() reads through the app, which must be running with its Protocol
11
+ adapter switched on. open_receiver() reads the receiver itself, with no app,
12
+ from the profiles the `pandora calibrate` command writes
13
+ (pandora_sdk/calibrate.py). Both give the same Frame, Raw and Debug, take the
14
+ same commands for a glove (haptics, Hall range) and give its Calibration;
15
+ what they hold is in PROTOCOL.md (attached to each release of pandora-sdk).
16
+ """
17
+ from pandora_core_rs.glove import Profile
18
+ from pandora_core_rs.protocol import (DEFAULT_PORT, Calibration, Debug, Device, Frame,
19
+ ProtocolError, Raw, Subscription)
20
+ from pandora_core_rs.receiver import ReceiverPort
21
+
22
+ from pandora_sdk.client import Client, Server, connect
23
+ from pandora_sdk.local import (Local, default_profiles_dir, list_receivers, load_profiles,
24
+ open_link, open_receiver, save_profile)
25
+ from pandora_sdk.source import (CONNECTION_FAILED_CODES, DISCONNECTED_CODES, ConnectionFailed,
26
+ Disconnected, PandoraError, ServerError, Source)
27
+
28
+ __all__ = ["CONNECTION_FAILED_CODES", "DEFAULT_PORT", "DISCONNECTED_CODES", "Calibration",
29
+ "Client", "ConnectionFailed", "Debug", "Device", "Disconnected", "Frame", "Local",
30
+ "PandoraError", "Profile", "ProtocolError", "Raw", "ReceiverPort", "Server",
31
+ "ServerError", "Source", "Subscription", "connect", "default_profiles_dir",
32
+ "list_receivers", "load_profiles", "open_link", "open_receiver", "save_profile"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from pandora_sdk.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,253 @@
1
+ """`pandora calibrate`: calibrate one glove read straight from the receiver,
2
+ with no app, and write the profile open_receiver() reads.
3
+
4
+ link = open_link()
5
+ outcome = calibrate(link, hand="right")
6
+ print(outcome.path)
7
+
8
+ The run itself is pandora-core-rs's (CalibrationSession, driven over the glove's
9
+ packets by calibrate_packets): which steps there are, what each captures and
10
+ how it went. This adds what a person at a terminal needs around it: which
11
+ glove, each step and its countdown as text, a buzz on the back of the hand
12
+ when a step starts, and the file at the end.
13
+
14
+ The clock is the glove's own (its packets' timestamps), so a countdown stops
15
+ while the glove is out of range and nothing is captured from old data.
16
+
17
+ A profile is good for one Hall range, and open_receiver() sends a glove
18
+ nothing unless asked, so the range it will meet is the one a glove powers up
19
+ in. The run
20
+ therefore starts by resetting every Hall sensor to its power-up settings, and
21
+ the profile records that range. The glove does not report back, so this is
22
+ what it was told, not what it confirmed.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import time
28
+ from dataclasses import dataclass, replace
29
+ from pathlib import Path
30
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Tuple, Union
31
+
32
+ from pandora_core_rs import commands
33
+ from pandora_core_rs.calibration_session import CalibrationSession, Run, Step, calibrate_packets
34
+ from pandora_core_rs.glove import Glove, Profile
35
+ from pandora_core_rs.packet_decoder import GlovePacket
36
+ from pandora_core_rs.packet_layout import (
37
+ HALL_DEFAULT_FILTER, HALL_DEFAULT_ODR, HALL_DEFAULT_OSR, HALL_DEFAULT_RANGE, HALL_TARGET_ALL,
38
+ HAPTIC_BACKHAND_MOTOR, HAPTIC_MOTOR_COUNT,
39
+ )
40
+ from pandora_core_rs.tuning import Tuning
41
+
42
+ from pandora_sdk.local import (default_profiles_dir, link_failure, link_failure_code,
43
+ link_packets, load_profiles, save_profile, send_command)
44
+ from pandora_sdk.source import Disconnected, PandoraError
45
+
46
+ # The cue: the back-of-hand motor at full drive (pandora_core_rs commands.haptic_rtp
47
+ # takes 1 to 127), for this long when a step starts and when the run is over.
48
+ CUE_AMPLITUDE = 127
49
+ CUE_MS = 150
50
+ DONE_MS = 500
51
+
52
+ Packets = Iterator[Optional[GlovePacket]]
53
+
54
+
55
+ @dataclass
56
+ class Outcome:
57
+ """How a run went."""
58
+ device: str
59
+ hand: str
60
+ # The run's heading and one (good, text) line per result, as the session
61
+ # gives them.
62
+ title: str
63
+ lines: List[Tuple[bool, str]]
64
+ # The profile written; None when the run stored nothing.
65
+ path: Optional[Path]
66
+
67
+ @property
68
+ def ok(self) -> bool:
69
+ """A profile was written and no part of the run failed."""
70
+ return self.path is not None and all(good for good, _text in self.lines)
71
+
72
+
73
+ def calibrate(link: Any, *, hand: Optional[str] = None, device: Optional[str] = None,
74
+ out: Union[str, Path, None] = None, run: Run = Run.FULL, hold_s: float = 3.0,
75
+ ready_s: float = 3.0, haptics: bool = True, name: Optional[str] = None,
76
+ listen_s: float = 1.0, wait_s: float = 10.0,
77
+ say: Callable[[str], None] = print,
78
+ tuning: Union[Tuning, Mapping[str, Any], None] = None,
79
+ defaults: Optional[Mapping[str, Any]] = None) -> Outcome:
80
+ """Calibrate one glove sending on *link* (an open receiver port,
81
+ local.open_link) and write its profile into *out*, a folder
82
+ (default_profiles_dir() when left out).
83
+
84
+ The glove is *device*, or the only one heard in the first *listen_s*
85
+ seconds. *hand* is "left" or "right"; it can be left out for a glove that
86
+ already has a profile in the folder, which the run then starts from, so
87
+ what it does not measure is kept. *run* and *hold_s* are the session's:
88
+ what to calibrate and how long each still pose is held. The first step
89
+ starts *ready_s* seconds after the glove is picked. *name* is a label for
90
+ the glove, kept in the profile. With *haptics* the back of the hand
91
+ buzzes when a step starts and when the run is over.
92
+
93
+ Every line for the person goes to *say*. PandoraError when no run could
94
+ be made: no glove heard for *wait_s* seconds, two heard and none named, a
95
+ hand that is not known, a glove that stopped part way. Nothing is written
96
+ then.
97
+ """
98
+ folder = default_profiles_dir() if out is None else Path(out)
99
+ feed = _packets(link)
100
+ glove_id = _pick(feed, device, listen_s, wait_s)
101
+ profile = _starting_profile(folder, glove_id, hand, name, say)
102
+ say(f"Glove {glove_id}, {profile.hand} hand.")
103
+ send_command(link, commands.hall_config(
104
+ glove_id, reset=True, range_val=HALL_DEFAULT_RANGE, osr=HALL_DEFAULT_OSR,
105
+ odr=HALL_DEFAULT_ODR, filter_val=HALL_DEFAULT_FILTER, target=HALL_TARGET_ALL))
106
+ say(f"First step in {ready_s:g} s."
107
+ + (" The back of the hand buzzes when a step starts." if haptics else ""))
108
+
109
+ def cue(ms: int = CUE_MS) -> None:
110
+ if haptics:
111
+ _buzz(link, glove_id, ms)
112
+
113
+ mine = _from(feed, glove_id, wait_s)
114
+ with Glove(profile, tuning, defaults) as glove:
115
+ _settle(glove, mine, ready_s)
116
+ session = calibrate_packets(glove, mine, run, hold_s, watch=_Screen(say, cue))
117
+ path = (save_profile(folder, glove_id, glove.export_profile())
118
+ if session.changed else None)
119
+ if session.steps:
120
+ cue(DONE_MS)
121
+ say("")
122
+ say(session.title)
123
+ for good, text in session.lines:
124
+ say(f" {'ok ' if good else 'FAIL'} {text}")
125
+ say(f"Profile written: {path}" if path is not None else "No profile was written.")
126
+ return Outcome(glove_id, profile.hand, session.title, list(session.lines), path)
127
+
128
+
129
+ # ── which glove, and what it starts from ─────────────────────────────────────
130
+
131
+ def _pick(feed: Packets, device: Optional[str], listen_s: float, wait_s: float) -> str:
132
+ """The glove to calibrate: *device* once it is heard; with none named,
133
+ the only one heard by the time the first has sent for *listen_s* seconds
134
+ of its own clock."""
135
+ heard: Dict[str, int] = {}
136
+ started = time.monotonic()
137
+ for packet in feed:
138
+ if packet is not None:
139
+ first_ms = heard.setdefault(packet.device_id, packet.timestamp)
140
+ if packet.device_id == device:
141
+ return packet.device_id
142
+ if device is None and packet.device_id == next(iter(heard)) \
143
+ and packet.timestamp - first_ms >= listen_s * 1000:
144
+ break
145
+ if time.monotonic() - started > wait_s:
146
+ break
147
+ sending = ", ".join(sorted(heard))
148
+ if device is not None:
149
+ raise PandoraError(f"{device} was not heard in {wait_s:g} s. "
150
+ + (f"Sending now: {sending}." if heard else "No glove is sending."))
151
+ if not heard:
152
+ raise PandoraError(f"no glove is sending (waited {wait_s:g} s). Switch the glove on "
153
+ "and keep it near the receiver.")
154
+ if len(heard) > 1:
155
+ raise PandoraError(f"more than one glove is sending ({sending}); "
156
+ "say which with --device")
157
+ return next(iter(heard))
158
+
159
+
160
+ def _starting_profile(folder: Path, device: str, hand: Optional[str], name: Optional[str],
161
+ say: Callable[[str], None]) -> Profile:
162
+ """What the run starts from: the glove's profile in *folder* when it has
163
+ one for this hand, else an empty one. On the power-up Hall range either
164
+ way, so a calibration kept from another range is rescaled to it."""
165
+ try:
166
+ earlier = load_profiles(folder).get(device)
167
+ except (ValueError, TypeError, OSError) as unreadable:
168
+ raise PandoraError(f"a profile in {folder} cannot be read ({unreadable}). "
169
+ "Move it away, or name another folder with --out.") from None
170
+ if earlier is not None and hand not in (None, earlier.hand):
171
+ say(f"{device} was calibrated as the {earlier.hand} hand; starting again as the "
172
+ f"{hand} hand.")
173
+ earlier = None
174
+ if earlier is None:
175
+ if hand is None:
176
+ raise PandoraError(f"{device} has no profile in {folder} yet: say which hand it "
177
+ "is worn on with --hand left or --hand right")
178
+ earlier = Profile(hand)
179
+ return replace(earlier, range=HALL_DEFAULT_RANGE, sensor_ranges={},
180
+ name=earlier.name if name is None else name)
181
+
182
+
183
+ # ── the glove's packets ──────────────────────────────────────────────────────
184
+
185
+ def _packets(link: Any) -> Packets:
186
+ """link_packets, with a port that fails reported as the SDK reports it."""
187
+ try:
188
+ yield from link_packets(link)
189
+ except OSError as failed:
190
+ raise Disconnected(link_failure(failed), link_failure_code(failed)) from failed
191
+
192
+
193
+ def _from(feed: Packets, device: str, wait_s: float) -> Iterator[GlovePacket]:
194
+ """*device*'s packets out of *feed*, until it has sent none for *wait_s*
195
+ seconds."""
196
+ heard = time.monotonic()
197
+ for packet in feed:
198
+ if packet is not None and packet.device_id == device:
199
+ heard = time.monotonic()
200
+ yield packet
201
+ elif time.monotonic() - heard > wait_s:
202
+ raise PandoraError(f"{device} stopped sending: nothing from it for {wait_s:g} s. "
203
+ "No profile was written.")
204
+
205
+
206
+ def _settle(glove: Glove, packets: Iterator[GlovePacket], ready_s: float) -> None:
207
+ """Run *glove*'s pipeline for *ready_s* seconds of its own clock: time
208
+ for the wearer to get ready, for the sensors to settle on their range and
209
+ for the pipeline's filters to fill."""
210
+ if ready_s <= 0:
211
+ return
212
+ started_ms: Optional[int] = None
213
+ for packet in packets:
214
+ glove.push_decoded(packet)
215
+ started_ms = packet.timestamp if started_ms is None else started_ms
216
+ if packet.timestamp - started_ms >= ready_s * 1000:
217
+ return
218
+
219
+
220
+ # ── what the person sees and feels ───────────────────────────────────────────
221
+
222
+ class _Screen:
223
+ """calibrate_packets' watch: says each step when it starts, with a cue,
224
+ then a line for each second left, with the session's warning on it when
225
+ it has one. It is called for every packet; it speaks once a second."""
226
+
227
+ def __init__(self, say: Callable[[str], None], cue: Callable[[], None]) -> None:
228
+ self._say, self._cue = say, cue
229
+ self._step: Optional[Step] = None
230
+ self._left = 0
231
+
232
+ def __call__(self, session: CalibrationSession) -> None:
233
+ step = session.step
234
+ if step is None:
235
+ return
236
+ if step is not self._step:
237
+ self._step, self._left = step, 0
238
+ self._say("")
239
+ self._say(f"Step {session.index + 1} of {len(session.steps)}: {step.title}")
240
+ self._say(f" {step.detail}")
241
+ self._cue()
242
+ left = math.ceil(session.remaining_s - 1e-6)
243
+ if left != self._left and left > 0:
244
+ self._left = left
245
+ warned = [text for good, text in session.prompt if not good]
246
+ self._say(f" {left}" + (f" ! {'; '.join(warned)}" if warned else ""))
247
+
248
+
249
+ def _buzz(link: Any, device: str, ms: int) -> None:
250
+ amplitudes, durations = [0] * HAPTIC_MOTOR_COUNT, [0] * HAPTIC_MOTOR_COUNT
251
+ amplitudes[HAPTIC_BACKHAND_MOTOR], durations[HAPTIC_BACKHAND_MOTOR] = CUE_AMPLITUDE, ms
252
+ send_command(link, commands.haptic_rtp(device, amplitudes, durations))
253
+
pandora_sdk/cli.py ADDED
@@ -0,0 +1,246 @@
1
+ """The `pandora` command: look at hands, calibrate a glove and update
2
+ firmware, without writing code.
3
+
4
+ pandora devices the gloves the app knows
5
+ pandora watch --hand right hands as they arrive, ten a second
6
+ pandora watch --raw --rate 0 every raw packet
7
+ pandora watch --raw --cancelled raw counts after earth-field cancellation
8
+ pandora calibrate --hand right calibrate a glove with no app running
9
+ pandora watch --local hands straight from the receiver
10
+ pandora update-firmware glove IMAGE flash a glove (or the receiver)
11
+
12
+ `devices` and `watch` read through the running app. `calibrate`,
13
+ `watch --local` and `update-firmware` open the receiver themselves, so the app
14
+ must not be running.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import io
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import List, Optional, Sequence, Set
23
+
24
+ from pandora_core_rs.calibration_session import RUNS, Run
25
+ from pandora_core_rs.protocol import DEFAULT_PORT, HANDS, Frame, ProtocolError, Raw
26
+
27
+ from pandora_sdk import firmware
28
+ from pandora_sdk.calibrate import calibrate
29
+ from pandora_sdk.client import connect
30
+ from pandora_sdk.local import (Local, default_profiles_dir, load_profiles, open_link,
31
+ open_receiver)
32
+ from pandora_sdk.source import PandoraError, Source
33
+
34
+ # The hand `watch --local --raw` reads a glove with no profile as.
35
+ UNPROFILED_HAND = "right"
36
+
37
+
38
+ def _frame_line(hand: Frame) -> str:
39
+ curl = " ".join(f"{finger[0]:.2f}" for finger in hand.curl)
40
+ splay = " ".join(f"{value:+.2f}" for value in hand.splay)
41
+ wrist = "-" if hand.wrist_deg is None else " ".join(f"{a:.0f}" for a in hand.wrist_deg)
42
+ return (f"{hand.hand:<5} {hand.device} #{hand.seq} curl {curl} splay {splay} "
43
+ f"wrist {wrist}")
44
+
45
+
46
+ def _proximal(hall: Sequence[Sequence[Sequence[float]]]) -> str:
47
+ """Each finger's proximal sensor, x,y,z."""
48
+ return " ".join(",".join(f"{axis:.0f}" for axis in finger[0]) for finger in hall)
49
+
50
+
51
+ def _raw_line(reading: Raw, cancelled: bool = False) -> str:
52
+ if not cancelled:
53
+ counts = f"hall {_proximal(reading.hall)}"
54
+ elif reading.hall_cancelled is None:
55
+ counts = "cancelled -"
56
+ else:
57
+ what = "earth cancelled" if reading.earth_cancelled else "nothing cancelled"
58
+ counts = f"cancelled {_proximal(reading.hall_cancelled)} {what}"
59
+ return (f"{reading.hand:<5} {reading.device} #{reading.seq} {counts} "
60
+ f"battery {reading.battery_pct}%")
61
+
62
+
63
+ def _devices(gloves: Source) -> int:
64
+ for device in gloves.devices():
65
+ state = "connected" if device.connected else "silent"
66
+ print(f"{device.id} {device.hand:<5} {state} {device.name}".rstrip())
67
+ return 0
68
+
69
+
70
+ def _watch(gloves: Source, args: argparse.Namespace) -> int:
71
+ stream = "raw" if args.raw else "frames"
72
+ granted = gloves.subscribe(stream, devices=args.device, hands=args.hand or HANDS,
73
+ rate_hz=args.rate, bones=False, cancelled=args.cancelled)
74
+ if args.cancelled and not granted.cancelled:
75
+ print("pandora: the app sends no cancelled counts (it is older than them)",
76
+ file=sys.stderr)
77
+ printed = 0
78
+ named: Set[str] = set()
79
+ while not args.count or printed < args.count:
80
+ try:
81
+ message = gloves.read(stream, timeout=1.0)
82
+ except TimeoutError:
83
+ message = None
84
+ if isinstance(gloves, Local):
85
+ for device in sorted(gloves.unprofiled - named):
86
+ named.add(device)
87
+ print(f"pandora: {_unprofiled(device, args)}. Make one with: pandora calibrate "
88
+ f"--device {device} --hand left|right", file=sys.stderr)
89
+ if message is None:
90
+ continue
91
+ print(_raw_line(message, args.cancelled) if args.raw else _frame_line(message),
92
+ flush=True)
93
+ printed += 1
94
+ return 0
95
+
96
+
97
+ def _unprofiled(device: str, args: argparse.Namespace) -> str:
98
+ """What `watch --local` does with a glove that has no profile."""
99
+ if not args.raw:
100
+ # Left out, which looks like silence.
101
+ return (f"{device} is sending but is left out of the frames: a frame needs the "
102
+ "glove's profile, and it has none (--raw shows its raw readings without one)")
103
+ return (f"{device} has no profile, so its raw readings are shown as the {UNPROFILED_HAND} "
104
+ "hand's (a packet does not say which hand it is)"
105
+ + (", with no cancelled counts" if args.cancelled else ""))
106
+
107
+
108
+ def _local(args: argparse.Namespace) -> Local:
109
+ folder = args.profiles or default_profiles_dir()
110
+ profiles = load_profiles(folder)
111
+ if not profiles and not args.raw:
112
+ raise PandoraError(f"no profile in {folder}, and frames need one. Make one with: "
113
+ "pandora calibrate --hand left|right, or watch raw readings, which "
114
+ "need none, with --raw")
115
+ return open_receiver(profiles, port=args.receiver, default_hand=UNPROFILED_HAND)
116
+
117
+
118
+ def _calibrate(args: argparse.Namespace) -> int:
119
+ link = open_link(args.receiver)
120
+ try:
121
+ outcome = calibrate(
122
+ link, hand=args.hand, device=args.device, out=args.out,
123
+ run=getattr(Run, args.run.upper()), hold_s=args.hold, ready_s=args.ready,
124
+ haptics=not args.no_haptics, name=args.name,
125
+ say=lambda text: print(text, flush=True))
126
+ except KeyboardInterrupt:
127
+ print("\nStopped. No profile was written.", flush=True)
128
+ return 1
129
+ finally:
130
+ link.close()
131
+ if outcome.path is not None:
132
+ where = "" if args.out is None else f" --profiles {args.out}"
133
+ print(f"Look at the hand with: pandora watch --local{where}", flush=True)
134
+ return 0 if outcome.ok else 1
135
+
136
+
137
+ def main(argv: Optional[List[str]] = None) -> int:
138
+ app = argparse.ArgumentParser(add_help=False)
139
+ app.add_argument("--host", default="127.0.0.1")
140
+ app.add_argument("--port", type=int, default=DEFAULT_PORT)
141
+ app.add_argument("--timeout", type=float, default=5.0, help="seconds to wait for the app")
142
+ port = argparse.ArgumentParser(add_help=False)
143
+ port.add_argument("--receiver", metavar="PORT",
144
+ help="the receiver's serial port (COM7, /dev/ttyACM0) when more than "
145
+ "one is plugged in")
146
+
147
+ parser = argparse.ArgumentParser(
148
+ prog="pandora",
149
+ description="Look at the 01 Glove's hands, through the app or straight from the "
150
+ "receiver, and calibrate a glove with no app.")
151
+ commands = parser.add_subparsers(dest="command", required=True)
152
+ commands.add_parser("devices", parents=[app], help="list the gloves the app knows")
153
+
154
+ watch = commands.add_parser("watch", parents=[app, port],
155
+ help="print hands as they arrive")
156
+ watch.add_argument("--raw", action="store_true",
157
+ help="the gloves' raw readings instead of finished hands")
158
+ watch.add_argument("--cancelled", action="store_true",
159
+ help="with --raw: each finger's counts after earth-field cancellation "
160
+ "instead of as the glove sent them")
161
+ watch.add_argument("--hand", action="append", choices=HANDS, help="only this hand")
162
+ watch.add_argument("--device", action="append", help="only this glove id (repeatable)")
163
+ watch.add_argument("--rate", type=float, default=10.0,
164
+ help="lines a second per glove at most; 0 is every message (default 10)")
165
+ watch.add_argument("--count", type=int, default=0, help="stop after this many lines")
166
+ watch.add_argument("--local", action="store_true",
167
+ help="read the receiver itself, with no app, using the profiles "
168
+ "`pandora calibrate` wrote (--raw needs none)")
169
+ watch.add_argument("--profiles", type=Path, metavar="DIR",
170
+ help=f"with --local: the profiles folder (default "
171
+ f"{default_profiles_dir()})")
172
+
173
+ cal = commands.add_parser(
174
+ "calibrate", parents=[port],
175
+ help="calibrate one glove read from the receiver and write its profile",
176
+ description="Calibrate one glove read straight from the receiver, with no app "
177
+ "running, and write the profile open_receiver() and `pandora watch "
178
+ "--local` need. Wear the glove and follow the steps as they are printed.")
179
+ cal.add_argument("--hand", choices=HANDS,
180
+ help="the hand the glove is worn on; needed the first time, then taken "
181
+ "from the glove's profile")
182
+ cal.add_argument("--device", help="the glove's id, when more than one is sending")
183
+ cal.add_argument("--run", choices=[run.name.lower() for run in RUNS], default="full",
184
+ help="what to calibrate (default full: curl range, and the splay zero "
185
+ "on a glove with finger IMUs)")
186
+ cal.add_argument("--hold", type=float, default=3.0,
187
+ help="seconds each still pose is held (default 3)")
188
+ cal.add_argument("--ready", type=float, default=3.0,
189
+ help="seconds before the first step (default 3)")
190
+ cal.add_argument("--out", type=Path, metavar="DIR",
191
+ help=f"the profiles folder to write into (default {default_profiles_dir()})")
192
+ cal.add_argument("--name", help="a label for the glove, kept in its profile")
193
+ cal.add_argument("--no-haptics", action="store_true",
194
+ help="no buzz on the back of the hand when a step starts")
195
+
196
+ flash = commands.add_parser(
197
+ "update-firmware", parents=[port],
198
+ help="update the receiver's or a glove's firmware over the air, with no app",
199
+ description="Flash a signed firmware image onto the receiver or onto a glove "
200
+ "through it, with the app closed, and wait until the new image is "
201
+ "confirmed. The same update as the app's Firmware tab. "
202
+ "Asks before writing anything unless --yes is given.")
203
+ flash.add_argument("target", choices=list(firmware.TARGETS),
204
+ help="what to update (dongle is the receiver)")
205
+ flash.add_argument("image", help="dfu_application.zip, a signed .bin, or a build folder "
206
+ "holding dfu_application.zip")
207
+ flash.add_argument("--device", help="the glove's id (default: the only glove sending)")
208
+ flash.add_argument("--yes", "-y", action="store_true",
209
+ help="flash without asking first")
210
+ flash.add_argument("--verbose", action="store_true",
211
+ help="also print every line the receiver sends")
212
+ args = parser.parse_args(argv)
213
+ if getattr(args, "cancelled", False) and not args.raw:
214
+ watch.error("--cancelled goes with --raw")
215
+
216
+ try:
217
+ if args.command == "calibrate":
218
+ return _calibrate(args)
219
+ if args.command == "update-firmware":
220
+ _speak_utf8()
221
+ return firmware.update_firmware(
222
+ args.target, args.image, port=args.receiver, device=args.device, yes=args.yes,
223
+ verbose=args.verbose, say=lambda text: print(text, flush=True))
224
+ if getattr(args, "local", False):
225
+ with _local(args) as gloves:
226
+ return _watch(gloves, args)
227
+ with connect(args.host, args.port, name="pandora-cli", timeout=args.timeout) as gloves:
228
+ return _devices(gloves) if args.command == "devices" else _watch(gloves, args)
229
+ except KeyboardInterrupt:
230
+ return 0
231
+ except (PandoraError, ProtocolError, TimeoutError) as failed:
232
+ print(f"pandora: {failed}", file=sys.stderr)
233
+ return 1
234
+
235
+
236
+ def _speak_utf8() -> None:
237
+ """pandora-core-rs's firmware update prints emoji, from threads of its own,
238
+ which a Windows console's code page cannot always show: replace what it
239
+ cannot, rather than let the print fail."""
240
+ for stream in (sys.stdout, sys.stderr):
241
+ if isinstance(stream, io.TextIOWrapper):
242
+ stream.reconfigure(encoding="utf-8", errors="replace")
243
+
244
+
245
+ if __name__ == "__main__":
246
+ sys.exit(main())