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.
- pandora_sdk/__init__.py +32 -0
- pandora_sdk/__main__.py +5 -0
- pandora_sdk/calibrate.py +253 -0
- pandora_sdk/cli.py +246 -0
- pandora_sdk/client.py +290 -0
- pandora_sdk/firmware.py +148 -0
- pandora_sdk/local.py +697 -0
- pandora_sdk/source.py +362 -0
- pandora_sdk-0.4.1.dist-info/METADATA +804 -0
- pandora_sdk-0.4.1.dist-info/RECORD +14 -0
- pandora_sdk-0.4.1.dist-info/WHEEL +5 -0
- pandora_sdk-0.4.1.dist-info/entry_points.txt +2 -0
- pandora_sdk-0.4.1.dist-info/licenses/LICENSE.md +75 -0
- pandora_sdk-0.4.1.dist-info/top_level.txt +1 -0
pandora_sdk/__init__.py
ADDED
|
@@ -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"]
|
pandora_sdk/__main__.py
ADDED
pandora_sdk/calibrate.py
ADDED
|
@@ -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())
|