feetech-cli 0.1.2__tar.gz → 0.1.3__tar.gz

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.
Files changed (26) hide show
  1. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/PKG-INFO +88 -1
  2. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/README.md +87 -0
  3. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli/__init__.py +1 -1
  4. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli/cli.py +269 -20
  5. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli/controller.py +251 -9
  6. feetech_cli-0.1.3/feetech_cli/devices.py +590 -0
  7. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli/protocol.py +38 -0
  8. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli/registers.py +8 -1
  9. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli/simulator.py +110 -3
  10. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli/tui.py +16 -2
  11. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli.egg-info/PKG-INFO +88 -1
  12. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli.egg-info/SOURCES.txt +2 -0
  13. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/pyproject.toml +1 -1
  14. feetech_cli-0.1.3/tests/test_devices.py +429 -0
  15. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/LICENSE +0 -0
  16. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli.egg-info/dependency_links.txt +0 -0
  17. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli.egg-info/entry_points.txt +0 -0
  18. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli.egg-info/requires.txt +0 -0
  19. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/feetech_cli.egg-info/top_level.txt +0 -0
  20. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/setup.cfg +0 -0
  21. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/tests/test_cli.py +0 -0
  22. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/tests/test_controller.py +0 -0
  23. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/tests/test_protocol.py +0 -0
  24. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/tests/test_registers.py +0 -0
  25. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/tests/test_terminal.py +0 -0
  26. {feetech_cli-0.1.2 → feetech_cli-0.1.3}/tests/test_tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: feetech-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Command line tool to inspect and configure FEETECH STS/SMS/SCS serial bus servos
5
5
  Author-email: Iori Yanokura <ab.ioryz@gmail.com>
6
6
  License-Expression: MIT
@@ -466,3 +466,90 @@ Sources:
466
466
  ## License
467
467
 
468
468
  MIT
469
+
470
+ ## Other devices on the bus
471
+
472
+ A FEETECH bus can carry more than servos: any board that speaks the same
473
+ framing can sit next to the joints it works with. Its control table is its
474
+ own, though, and reading it through the servo table gives nonsense (an id of
475
+ 48, a 250 kbps speed) while writing through it corrupts the board.
476
+
477
+ feetech-cli itself only knows FEETECH servos. Any other device is described by
478
+ a JSON **device file**, normally shipped by whoever makes the device, and
479
+ loaded from
480
+
481
+ 1. `--device-file PATH` (a file or a directory of them, repeatable),
482
+ 2. `$FEETECH_CLI_DEVICES` (files or directories, separated like `PATH`),
483
+ 3. `~/.config/feetech-cli/devices/*.json` (or `$XDG_CONFIG_HOME/...`).
484
+
485
+ With a device file loaded, every id is identified once by reading the bytes its
486
+ `match` names; a match gets the file's control table, everything else is a
487
+ servo as before. With none loaded nothing extra is sent on the bus.
488
+
489
+ Two boards that share a model number -- the same firmware on two board
490
+ revisions, say -- are told apart by listing more than one place to look:
491
+
492
+ ```json
493
+ "match": [{"address": 0, "bytes": "3412455842"}, {"address": 40, "bytes": "03"}]
494
+ ```
495
+
496
+ ```json
497
+ {
498
+ "name": "Example sensor board",
499
+ "match": {"address": 0, "bytes": "3412455842"},
500
+ "settle_time": 1.0,
501
+ "registers": {
502
+ "model_number": {"address": 0, "size": 2},
503
+ "id": {"address": 7, "size": 1, "writable": true},
504
+ "baud_rate": {"address": 8, "size": 1, "writable": true},
505
+ "mode": {"address": 20, "size": 1, "writable": true,
506
+ "values": {"0": "off", "1": "on"}},
507
+ "pressure": {"address": 24, "size": 2, "signed": true,
508
+ "scale": 0.01, "unit": "kPa", "digits": 2},
509
+ "acc_range_g": {"address": 12, "size": 2},
510
+ "acc_z": {"address": 26, "size": 2, "signed": true, "unit": "g",
511
+ "scale_by": {"register": "acc_range_g", "divisor": 32768}},
512
+ "error": {"address": 30, "size": 1, "bits": {"0": "imu", "3": "adc"}}
513
+ },
514
+ "info": ["id", "mode", "pressure", "acc_z", "error"],
515
+ "summary": ["mode", "pressure"]
516
+ }
517
+ ```
518
+
519
+ | key | meaning |
520
+ |---|---|
521
+ | `name` | what the device is called in messages |
522
+ | `match` | `address` and hex `bytes` found there on this device and no other; or a list of such objects, all of which must match (checked in order, so a servo is ruled out by the first READ) |
523
+ | `registers` | name to `address`, `size` (1 or 2), `writable`, `signed` (two's complement), and optionally how to show it: `values` (number to label), `bits` (bit number to label), `scale` or `scale_by` (value × another register ÷ `divisor`), `unit`, `digits`; a writable register may add `settle_time`, the seconds the device stays silent after a write to it (while it saves the value to flash, say), and the write waits for it to answer before reading back |
524
+ | `settle_time` | seconds the device may stay silent after an id or speed change (default 1) |
525
+ | `baudrate_table` | `"sts"` (default) or an object of `baud_rate` value to bits per second |
526
+ | `eeprom_lock` | whether EEPROM writes need the servo-style `lock` register (default false) |
527
+ | `info`, `summary` | registers `feetech info` and the `scan` line show |
528
+ | `description` | free text |
529
+
530
+ A file with a misspelt key, an unknown register in a list, or bytes that are
531
+ not hex is rejected with the file and key named. A broken file is never
532
+ skipped: that would identify the device as a servo and write servo registers
533
+ into it.
534
+
535
+ ```console
536
+ $ feetech --device-file my_board.json --max-id 20 scan
537
+ Found 2 device(s) on /dev/ttyUSB0 at 1.00Mbps:
538
+ id 1 model 777 position 2048 (+0.0 deg) 12.1V 35C
539
+ id 10 Example sensor board (model 0x1234, fw 2) mode 0 (off) pressure -4210 (-42.10 kPa)
540
+ $ feetech --device-file my_board.json info 10
541
+ $ feetech --device-file my_board.json registers --device my_board
542
+ $ feetech --device-file my_board.json --simulate scan # try a file without hardware
543
+ $ feetech --device-file my_board.json watch 10 acc_z pressure # live, Ctrl-C to stop
544
+ ```
545
+
546
+ `watch ID [REGISTER ...]` redraws the named registers (default: what `info`
547
+ shows) every `--interval` seconds, reading neighbouring registers in one READ
548
+ so six IMU axes cost one round trip. Off a terminal it prints one line per
549
+ refresh, for logging; `--count` stops after that many. It works for servos too.
550
+
551
+ `set-id` and `set-baud` write the device's own `id` and `baud_rate` registers
552
+ directly: no torque, no EEPROM lock, then poll for up to `settle_time` while it
553
+ commits them. Servo-only commands (`move`, `torque`, `mode`, `set-zero`,
554
+ `selftest`, `factory-reset`) refuse such a device, and the status screen leaves
555
+ it out.
@@ -441,3 +441,90 @@ Sources:
441
441
  ## License
442
442
 
443
443
  MIT
444
+
445
+ ## Other devices on the bus
446
+
447
+ A FEETECH bus can carry more than servos: any board that speaks the same
448
+ framing can sit next to the joints it works with. Its control table is its
449
+ own, though, and reading it through the servo table gives nonsense (an id of
450
+ 48, a 250 kbps speed) while writing through it corrupts the board.
451
+
452
+ feetech-cli itself only knows FEETECH servos. Any other device is described by
453
+ a JSON **device file**, normally shipped by whoever makes the device, and
454
+ loaded from
455
+
456
+ 1. `--device-file PATH` (a file or a directory of them, repeatable),
457
+ 2. `$FEETECH_CLI_DEVICES` (files or directories, separated like `PATH`),
458
+ 3. `~/.config/feetech-cli/devices/*.json` (or `$XDG_CONFIG_HOME/...`).
459
+
460
+ With a device file loaded, every id is identified once by reading the bytes its
461
+ `match` names; a match gets the file's control table, everything else is a
462
+ servo as before. With none loaded nothing extra is sent on the bus.
463
+
464
+ Two boards that share a model number -- the same firmware on two board
465
+ revisions, say -- are told apart by listing more than one place to look:
466
+
467
+ ```json
468
+ "match": [{"address": 0, "bytes": "3412455842"}, {"address": 40, "bytes": "03"}]
469
+ ```
470
+
471
+ ```json
472
+ {
473
+ "name": "Example sensor board",
474
+ "match": {"address": 0, "bytes": "3412455842"},
475
+ "settle_time": 1.0,
476
+ "registers": {
477
+ "model_number": {"address": 0, "size": 2},
478
+ "id": {"address": 7, "size": 1, "writable": true},
479
+ "baud_rate": {"address": 8, "size": 1, "writable": true},
480
+ "mode": {"address": 20, "size": 1, "writable": true,
481
+ "values": {"0": "off", "1": "on"}},
482
+ "pressure": {"address": 24, "size": 2, "signed": true,
483
+ "scale": 0.01, "unit": "kPa", "digits": 2},
484
+ "acc_range_g": {"address": 12, "size": 2},
485
+ "acc_z": {"address": 26, "size": 2, "signed": true, "unit": "g",
486
+ "scale_by": {"register": "acc_range_g", "divisor": 32768}},
487
+ "error": {"address": 30, "size": 1, "bits": {"0": "imu", "3": "adc"}}
488
+ },
489
+ "info": ["id", "mode", "pressure", "acc_z", "error"],
490
+ "summary": ["mode", "pressure"]
491
+ }
492
+ ```
493
+
494
+ | key | meaning |
495
+ |---|---|
496
+ | `name` | what the device is called in messages |
497
+ | `match` | `address` and hex `bytes` found there on this device and no other; or a list of such objects, all of which must match (checked in order, so a servo is ruled out by the first READ) |
498
+ | `registers` | name to `address`, `size` (1 or 2), `writable`, `signed` (two's complement), and optionally how to show it: `values` (number to label), `bits` (bit number to label), `scale` or `scale_by` (value × another register ÷ `divisor`), `unit`, `digits`; a writable register may add `settle_time`, the seconds the device stays silent after a write to it (while it saves the value to flash, say), and the write waits for it to answer before reading back |
499
+ | `settle_time` | seconds the device may stay silent after an id or speed change (default 1) |
500
+ | `baudrate_table` | `"sts"` (default) or an object of `baud_rate` value to bits per second |
501
+ | `eeprom_lock` | whether EEPROM writes need the servo-style `lock` register (default false) |
502
+ | `info`, `summary` | registers `feetech info` and the `scan` line show |
503
+ | `description` | free text |
504
+
505
+ A file with a misspelt key, an unknown register in a list, or bytes that are
506
+ not hex is rejected with the file and key named. A broken file is never
507
+ skipped: that would identify the device as a servo and write servo registers
508
+ into it.
509
+
510
+ ```console
511
+ $ feetech --device-file my_board.json --max-id 20 scan
512
+ Found 2 device(s) on /dev/ttyUSB0 at 1.00Mbps:
513
+ id 1 model 777 position 2048 (+0.0 deg) 12.1V 35C
514
+ id 10 Example sensor board (model 0x1234, fw 2) mode 0 (off) pressure -4210 (-42.10 kPa)
515
+ $ feetech --device-file my_board.json info 10
516
+ $ feetech --device-file my_board.json registers --device my_board
517
+ $ feetech --device-file my_board.json --simulate scan # try a file without hardware
518
+ $ feetech --device-file my_board.json watch 10 acc_z pressure # live, Ctrl-C to stop
519
+ ```
520
+
521
+ `watch ID [REGISTER ...]` redraws the named registers (default: what `info`
522
+ shows) every `--interval` seconds, reading neighbouring registers in one READ
523
+ so six IMU axes cost one round trip. Off a terminal it prints one line per
524
+ refresh, for logging; `--count` stops after that many. It works for servos too.
525
+
526
+ `set-id` and `set-baud` write the device's own `id` and `baud_rate` registers
527
+ directly: no torque, no EEPROM lock, then poll for up to `settle_time` while it
528
+ commits them. Servo-only commands (`move`, `torque`, `mode`, `set-zero`,
529
+ `selftest`, `factory-reset`) refuse such a device, and the status screen leaves
530
+ it out.
@@ -7,7 +7,7 @@ from feetech_cli.protocol import FeetechStatusError
7
7
  from feetech_cli.protocol import FeetechTimeoutError
8
8
  from feetech_cli.protocol import PacketHandler
9
9
 
10
- __version__ = "0.1.2"
10
+ __version__ = "0.1.3"
11
11
 
12
12
  __all__ = [
13
13
  "FeetechServoController",
@@ -2,6 +2,7 @@
2
2
 
3
3
  import argparse
4
4
  import sys
5
+ import time
5
6
 
6
7
  import colorama
7
8
  from colorama import Fore
@@ -9,11 +10,15 @@ from colorama import Style
9
10
 
10
11
  from feetech_cli.controller import FeetechServoController
11
12
  from feetech_cli.controller import list_candidate_ports
13
+ from feetech_cli.devices import SERVO
14
+ from feetech_cli.devices import DeviceFileError
15
+ from feetech_cli.devices import default_device_dir
16
+ from feetech_cli.devices import format_value
17
+ from feetech_cli.devices import load_profiles
12
18
  from feetech_cli.protocol import FeetechError
13
19
  from feetech_cli.registers import BAUDRATE_INDEX
14
20
  from feetech_cli.registers import BAUDRATE_TABLE
15
21
  from feetech_cli.registers import CENTER_POSITION
16
- from feetech_cli.registers import CONTROL_TABLE
17
22
  from feetech_cli.registers import DEFAULT_MAX_ID
18
23
  from feetech_cli.registers import DEFAULT_MIN_ID
19
24
  from feetech_cli.registers import INFO_REGISTERS
@@ -68,6 +73,14 @@ def build_parser():
68
73
  "--scs", action="store_true",
69
74
  help="talk to the older big endian SCS series instead of STS/SMS",
70
75
  )
76
+ parser.add_argument(
77
+ "--device-file", action="append", default=[], metavar="PATH",
78
+ help=(
79
+ "JSON file (or directory of them) describing a non-servo device that "
80
+ "may share the bus; repeatable. Also read from $FEETECH_CLI_DEVICES "
81
+ "and ~/.config/feetech-cli/devices/*.json."
82
+ ),
83
+ )
71
84
  parser.add_argument(
72
85
  "--simulate", action="store_true",
73
86
  help=(
@@ -108,6 +121,23 @@ def build_parser():
108
121
  )
109
122
  set_baud.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
110
123
 
124
+ watch = sub.add_parser(
125
+ "watch", help="show registers of one device live (IMU, pressure, position, ...)"
126
+ )
127
+ watch.add_argument("id", type=int, help="device id")
128
+ watch.add_argument(
129
+ "registers", nargs="*",
130
+ help="register names (default: what 'feetech info' shows for that device)",
131
+ )
132
+ watch.add_argument(
133
+ "-i", "--interval", type=float, default=0.1,
134
+ help="seconds between refreshes (default: %(default)s)",
135
+ )
136
+ watch.add_argument(
137
+ "-n", "--count", type=int, default=None,
138
+ help="stop after this many refreshes (default: until Ctrl-C)",
139
+ )
140
+
111
141
  read = sub.add_parser("read", help="read one register")
112
142
  read.add_argument("id", type=int, help="servo id")
113
143
  read.add_argument("register", help="register name, see 'feetech registers'")
@@ -117,7 +147,11 @@ def build_parser():
117
147
  write.add_argument("register", help="register name, see 'feetech registers'")
118
148
  write.add_argument("value", type=int, help="value to write")
119
149
 
120
- sub.add_parser("registers", help="list the register names of the control table")
150
+ registers = sub.add_parser("registers", help="list the register names of the control table")
151
+ registers.add_argument(
152
+ "--device", default="servo",
153
+ help="whose control table to list: 'servo' (default) or a loaded device file's name",
154
+ )
121
155
 
122
156
  move = sub.add_parser("move", help="command a servo to a position")
123
157
  move.add_argument("id", type=int, help="servo id")
@@ -232,6 +266,7 @@ def make_controller(args):
232
266
  little_endian=not args.scs,
233
267
  min_id=args.min_id,
234
268
  max_id=args.max_id,
269
+ device_profiles=args.device_profiles,
235
270
  )
236
271
 
237
272
 
@@ -250,10 +285,18 @@ def attach_simulator(controller, args):
250
285
  Parsed arguments.
251
286
  """
252
287
  from feetech_cli.protocol import PacketHandler
288
+ from feetech_cli.simulator import SimulatedBoard
253
289
  from feetech_cli.simulator import build_demo_bus
254
290
 
255
291
  baudrate = args.baudrate or 1000000
256
292
  bus = build_demo_bus(servo_ids=(1, 2, 3), baudrate=baudrate)
293
+ # One simulated device per loaded device file, so a new file can be tried
294
+ # before the hardware exists.
295
+ boards = []
296
+ for offset, profile in enumerate(args.device_profiles):
297
+ if "id" in profile.control_table and "baud_rate" in profile.control_table:
298
+ boards.append(SimulatedBoard(profile, board_id=10 + offset))
299
+ bus.servos.extend(boards)
257
300
  controller.requested_port = bus.port
258
301
  controller.requested_baudrate = baudrate
259
302
 
@@ -272,8 +315,9 @@ def attach_simulator(controller, args):
272
315
 
273
316
  controller._open_port = open_port
274
317
  controller.is_simulated = True
318
+ extra = "".join(f", {b.profile.name} at id {b.servo_id}" for b in boards)
275
319
  print(
276
- f"{Fore.YELLOW}Simulated bus: three servos at ids 1, 2 and 3. "
320
+ f"{Fore.YELLOW}Simulated bus: three servos at ids 1, 2 and 3{extra}. "
277
321
  f"No hardware is involved.{Style.RESET_ALL}"
278
322
  )
279
323
 
@@ -335,11 +379,16 @@ def command_scan(args, controller):
335
379
  if not found:
336
380
  _print_empty_bus(controller)
337
381
  return 1
382
+ profiles = {servo_id: controller.device_profile(servo_id) for servo_id in found}
383
+ noun = "servo(s)" if all(p.is_servo for p in profiles.values()) else "device(s)"
338
384
  print(
339
- f"Found {len(found)} servo(s) on {controller.serial.port} "
385
+ f"Found {len(found)} {noun} on {controller.serial.port} "
340
386
  f"at {format_baud(controller.baudrate)}bps:"
341
387
  )
342
388
  for servo_id in found:
389
+ if not profiles[servo_id].is_servo:
390
+ print(_device_summary(controller, servo_id, profiles[servo_id]))
391
+ continue
343
392
  model = controller.read_register(servo_id, "model_number")
344
393
  position = controller.read_register(servo_id, "present_position")
345
394
  voltage = controller.read_register(servo_id, "present_voltage")
@@ -353,6 +402,70 @@ def command_scan(args, controller):
353
402
  return 0
354
403
 
355
404
 
405
+ def _device_summary(controller, servo_id, profile):
406
+ """One scan line for a device that is not a servo.
407
+
408
+ Parameters
409
+ ----------
410
+ controller : FeetechServoController
411
+ An open controller.
412
+ servo_id : int
413
+ Device id.
414
+ profile : DeviceProfile
415
+ What the device was identified as.
416
+
417
+ Returns
418
+ -------
419
+ str
420
+ The line to print: the device's name, its model and firmware when its
421
+ device file names them, then its ``summary`` registers.
422
+ """
423
+ table = profile.control_table
424
+ header = [name for name in ("model_number", "firmware_version") if name in table]
425
+ shown = list(profile.summary_registers)
426
+ values = controller.dump(servo_id, _with_references(profile, header + shown))
427
+ details = []
428
+ if "model_number" in table:
429
+ model = values["model_number"]
430
+ details.append("model " + ("?" if model is None else f"0x{model:04X}"))
431
+ if "firmware_version" in table:
432
+ details.append(f"fw {_plain_value(values['firmware_version'])}")
433
+ line = f" id {servo_id:>3} {profile.name}"
434
+ if details:
435
+ line += f" ({', '.join(details)})"
436
+ for name in shown:
437
+ line += f" {name} {_annotate(name, values[name], values, profile)}"
438
+ return line
439
+
440
+
441
+ def _with_references(profile, names):
442
+ """Add the registers that ``scale_by`` rules of ``names`` need.
443
+
444
+ Parameters
445
+ ----------
446
+ profile : DeviceProfile
447
+ The device's profile.
448
+ names : list of str
449
+ Registers about to be shown.
450
+
451
+ Returns
452
+ -------
453
+ list of str
454
+ ``names`` followed by any missing reference registers.
455
+ """
456
+ out = list(names)
457
+ for name in names:
458
+ rule = profile.formats.get(name, {})
459
+ if "scale_by" in rule and rule["scale_by"][0] not in out:
460
+ out.append(rule["scale_by"][0])
461
+ return out
462
+
463
+
464
+ def _plain_value(value):
465
+ """Render an optional register value."""
466
+ return "?" if value is None else str(value)
467
+
468
+
356
469
  def resolve_servo_id(controller, servo_id):
357
470
  """Pick the servo to act on when the user did not name one.
358
471
 
@@ -403,25 +516,31 @@ def command_info(args, controller):
403
516
  Process exit status.
404
517
  """
405
518
  servo_id = resolve_servo_id(controller, args.id)
406
- names = list(CONTROL_TABLE) if args.all else INFO_REGISTERS
407
- values = controller.dump(servo_id, names)
519
+ profile = controller.device_profile(servo_id)
520
+ table = profile.control_table
521
+ if args.all:
522
+ names = list(table)
523
+ else:
524
+ names = list(profile.info_registers or INFO_REGISTERS)
525
+ values = controller.dump(servo_id, _with_references(profile, names))
408
526
  width = max(len(name) for name in names)
409
- print(f"Servo {servo_id} on {controller.serial.port} at {format_baud(controller.baudrate)}bps")
527
+ kind = "Servo" if profile.is_servo else profile.name[:1].upper() + profile.name[1:]
528
+ print(f"{kind} {servo_id} on {controller.serial.port} at {format_baud(controller.baudrate)}bps")
410
529
  for name in names:
411
- register = CONTROL_TABLE[name]
530
+ register = table[name]
412
531
  value = values[name]
413
532
  print(
414
533
  " {:<{width}} addr {:>3} {}".format(
415
534
  name,
416
535
  register.address,
417
- _annotate(name, value, values),
536
+ _annotate(name, value, values, profile),
418
537
  width=width,
419
538
  )
420
539
  )
421
540
  return 0
422
541
 
423
542
 
424
- def _annotate(name, value, context=None):
543
+ def _annotate(name, value, context=None, profile=SERVO):
425
544
  """Render a register value with a unit or label where one applies.
426
545
 
427
546
  Parameters
@@ -433,6 +552,9 @@ def _annotate(name, value, context=None):
433
552
  context : dict or None, optional
434
553
  Other register values of the same servo. ``present_current`` uses
435
554
  ``model_number`` and ``present_load`` from it.
555
+ profile : DeviceProfile, optional
556
+ The device's kind. For a device file, its own display rules apply and
557
+ the servo ones never do.
436
558
 
437
559
  Returns
438
560
  -------
@@ -441,6 +563,11 @@ def _annotate(name, value, context=None):
441
563
  """
442
564
  if value is None:
443
565
  return "no data"
566
+ if not profile.is_servo:
567
+ if name == "baud_rate" and value in profile.baudrate_table:
568
+ return f"{value} ({format_baud(profile.baudrate_table[value])}bps)"
569
+ rendered = format_value(profile, name, value, context)
570
+ return str(value) if rendered is None else rendered
444
571
  if name == "baud_rate":
445
572
  baud = BAUDRATE_TABLE.get(value)
446
573
  if baud is None:
@@ -483,19 +610,46 @@ def command_set_id(args, controller):
483
610
  int
484
611
  Process exit status.
485
612
  """
613
+ kind, memory = _kind_and_memory(controller, args.old_id)
486
614
  if not confirm(
487
- f"Change servo {args.old_id} to id {args.new_id}? This writes the servo EEPROM.",
615
+ f"Change {kind.lower()} {args.old_id} to id {args.new_id}? This writes the {memory}.",
488
616
  args.yes,
489
617
  ):
490
618
  print("Cancelled.")
491
619
  return 1
492
620
  confirmed = controller.set_id(args.old_id, args.new_id)
493
621
  print(
494
- f"{Fore.GREEN}Servo {args.old_id} is now id {confirmed}.{Style.RESET_ALL}"
622
+ f"{Fore.GREEN}{kind} {args.old_id} is now id {confirmed}.{Style.RESET_ALL}"
495
623
  )
496
624
  return 0
497
625
 
498
626
 
627
+ def _kind_and_memory(controller, servo_id):
628
+ """Name a device and the memory a setting change writes, for messages.
629
+
630
+ Parameters
631
+ ----------
632
+ controller : FeetechServoController
633
+ An open controller.
634
+ servo_id : int
635
+ Device id.
636
+
637
+ Returns
638
+ -------
639
+ tuple of (str, str)
640
+ ``("Servo", "servo EEPROM")`` or, for another kind of device, its
641
+ capitalised name and ``"board's flash"``.
642
+ """
643
+ try:
644
+ profile = controller.device_profile(servo_id)
645
+ except FeetechError:
646
+ # Absent ids are reported by set_id / set_baudrate themselves.
647
+ return "Servo", "servo EEPROM"
648
+ if profile.is_servo:
649
+ return "Servo", "servo EEPROM"
650
+ return profile.name[:1].upper() + profile.name[1:], "board's flash"
651
+
652
+
499
653
  def command_set_baud(args, controller):
500
654
  """Change the bus speed a servo uses.
501
655
 
@@ -511,16 +665,17 @@ def command_set_baud(args, controller):
511
665
  int
512
666
  Process exit status.
513
667
  """
668
+ kind, memory = _kind_and_memory(controller, args.id)
514
669
  if not confirm(
515
- f"Set servo {args.id} to {format_baud(args.new_baudrate)}bps? "
516
- f"This writes the servo EEPROM.",
670
+ f"Set {kind.lower()} {args.id} to {format_baud(args.new_baudrate)}bps? "
671
+ f"This writes the {memory}.",
517
672
  args.yes,
518
673
  ):
519
674
  print("Cancelled.")
520
675
  return 1
521
676
  confirmed = controller.set_baudrate(args.id, args.new_baudrate)
522
677
  print(
523
- f"{Fore.GREEN}Servo {args.id} now runs at {format_baud(confirmed)}bps.{Style.RESET_ALL}"
678
+ f"{Fore.GREEN}{kind} {args.id} now runs at {format_baud(confirmed)}bps.{Style.RESET_ALL}"
524
679
  )
525
680
  print(f"Pass --baudrate {confirmed} on later calls, or let it be auto detected.")
526
681
  return 0
@@ -542,10 +697,14 @@ def command_read(args, controller):
542
697
  Process exit status.
543
698
  """
544
699
  value = controller.read_register(args.id, args.register)
700
+ profile = controller.device_profile(args.id)
545
701
  context = None
546
- if args.register == "present_current":
702
+ if not profile.is_servo:
703
+ references = _with_references(profile, [args.register])[1:]
704
+ context = controller.dump(args.id, references) if references else None
705
+ elif args.register == "present_current":
547
706
  context = controller.dump(args.id, ["model_number", "present_load"])
548
- print(f"{args.register} = {_annotate(args.register, value, context)}")
707
+ print(f"{args.register} = {_annotate(args.register, value, context, profile)}")
549
708
  return 0
550
709
 
551
710
 
@@ -573,7 +732,8 @@ def command_write(args, controller):
573
732
  )
574
733
  return 1
575
734
  value = controller.write_register(args.id, args.register, args.value)
576
- print(f"{args.register} = {_annotate(args.register, value)}")
735
+ profile = controller.device_profile(args.id)
736
+ print(f"{args.register} = {_annotate(args.register, value, None, profile)}")
577
737
  if value != args.value:
578
738
  print(
579
739
  f"{Fore.RED}Read back {value} but {args.value} was written; "
@@ -583,6 +743,72 @@ def command_write(args, controller):
583
743
  return 0
584
744
 
585
745
 
746
+ def command_watch(args, controller):
747
+ """Show registers of one device, refreshed in place.
748
+
749
+ Each refresh reads every shown register (plus any a ``scale_by`` rule
750
+ needs) in as few READs as possible. On a terminal the block is redrawn in
751
+ place; otherwise one line per refresh is printed, which suits logging.
752
+
753
+ Parameters
754
+ ----------
755
+ args : argparse.Namespace
756
+ Parsed arguments.
757
+ controller : FeetechServoController
758
+ An open controller.
759
+
760
+ Returns
761
+ -------
762
+ int
763
+ Process exit status.
764
+ """
765
+ profile = controller.device_profile(args.id)
766
+ names = list(args.registers) or list(profile.info_registers or INFO_REGISTERS)
767
+ unknown = [name for name in names if name not in profile.control_table]
768
+ if unknown:
769
+ print(
770
+ f"{Fore.RED}Unknown register(s) {', '.join(unknown)} for a {profile.name}; "
771
+ f"see 'feetech registers --device {profile.key}'.{Style.RESET_ALL}"
772
+ )
773
+ return 1
774
+ to_read = _with_references(profile, names)
775
+ interactive = sys.stdout.isatty()
776
+ width = max(len(name) for name in names)
777
+ drawn = 0
778
+ refreshes = 0
779
+ started = time.monotonic()
780
+ try:
781
+ while args.count is None or refreshes < args.count:
782
+ tick = time.monotonic()
783
+ values = controller.read_registers(args.id, to_read)
784
+ refreshes += 1
785
+ rate = refreshes / max(time.monotonic() - started, 1e-6)
786
+ if interactive:
787
+ lines = [
788
+ f"{profile.name} {args.id} {rate:5.1f} Hz (Ctrl-C to stop)"
789
+ ] + [
790
+ " {:<{width}} {}".format(
791
+ name, _annotate(name, values[name], values, profile), width=width
792
+ )
793
+ for name in names
794
+ ]
795
+ if drawn:
796
+ sys.stdout.write(f"\x1b[{drawn}F")
797
+ sys.stdout.write("".join(f"\x1b[2K{line}\n" for line in lines))
798
+ sys.stdout.flush()
799
+ drawn = len(lines)
800
+ else:
801
+ print(" ".join(
802
+ f"{name}={_annotate(name, values[name], values, profile)}" for name in names
803
+ ))
804
+ remaining = args.interval - (time.monotonic() - tick)
805
+ if remaining > 0 and (args.count is None or refreshes < args.count):
806
+ time.sleep(remaining)
807
+ except KeyboardInterrupt:
808
+ print()
809
+ return 0
810
+
811
+
586
812
  def command_registers(args):
587
813
  """List the control table.
588
814
 
@@ -596,8 +822,18 @@ def command_registers(args):
596
822
  int
597
823
  Process exit status.
598
824
  """
599
- width = max(len(name) for name in CONTROL_TABLE)
600
- for name, register in sorted(CONTROL_TABLE.items(), key=lambda kv: kv[1].address):
825
+ known = {SERVO.key: SERVO}
826
+ known.update({profile.key: profile for profile in args.device_profiles})
827
+ if args.device not in known:
828
+ print(
829
+ f"{Fore.RED}No device '{args.device}'. Known: {', '.join(sorted(known))}. "
830
+ f"Load its file with --device-file or put it in {default_device_dir()}."
831
+ f"{Style.RESET_ALL}"
832
+ )
833
+ return 1
834
+ table = known[args.device].control_table
835
+ width = max(len(name) for name in table)
836
+ for name, register in sorted(table.items(), key=lambda kv: kv[1].address):
601
837
  print(
602
838
  " {:<{width}} addr {:>3} {} byte {:<6} {}".format(
603
839
  name, register.address, register.size, register.area,
@@ -728,6 +964,13 @@ def command_monitor(args, controller):
728
964
  Process exit status.
729
965
  """
730
966
  found = controller.scan()
967
+ others = [sid for sid in found if not controller.is_servo(sid)]
968
+ found = [sid for sid in found if sid not in others]
969
+ for sid in others:
970
+ print(
971
+ f"{Fore.YELLOW}id {sid} is a {controller.device_profile(sid).name}, not a "
972
+ f"servo; 'feetech info {sid}' shows it.{Style.RESET_ALL}"
973
+ )
731
974
  if not found:
732
975
  # Start anyway and wait. A servo plugged in later
733
976
  # is picked up without restarting the tool.
@@ -878,6 +1121,7 @@ BUS_COMMANDS = {
878
1121
  "set-baud": command_set_baud,
879
1122
  "read": command_read,
880
1123
  "write": command_write,
1124
+ "watch": command_watch,
881
1125
  "move": command_move,
882
1126
  "torque": command_torque,
883
1127
  "mode": command_mode,
@@ -904,6 +1148,11 @@ def main(argv=None):
904
1148
  colorama.init()
905
1149
  parser = build_parser()
906
1150
  args = parser.parse_args(argv)
1151
+ try:
1152
+ args.device_profiles = load_profiles(args.device_file)
1153
+ except DeviceFileError as error:
1154
+ print(f"{Fore.RED}Device file: {error}{Style.RESET_ALL}")
1155
+ return 2
907
1156
 
908
1157
  if args.command == "ports":
909
1158
  return command_ports(args)