Metadata-Version: 2.5
Name: edelkrone
Version: 0.1.0
Summary: Python SDK for edelkrone camera motion control hardware
Project-URL: Homepage, https://edelkrone.com
Author: edelkrone
License-Expression: MIT
License-File: src/edelkrone/LICENSE
Keywords: ble,bluetooth,camera,edelkrone,gimbal,motion-control,slider
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Scientific/Engineering
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: bleak>=0.22
Description-Content-Type: text/markdown

# edelkrone SDK User Guide

## 1 Getting Started

The edelkrone SDK controls edelkrone motion control equipment from Python over Bluetooth Low Energy. It supports heads, sliders, jibs, dollies and tripods, used on their own or combined in a layout, and the focus motor fitted to a head. Release 0.1 covers connecting, reading the device's identity and state, joystick control, stored poses, focus, settings and the tripod.

The SDK requires Python 3.11 or newer; with an older Python, `pip` reports that no matching distribution was found. Install it with:

```bash
pip install edelkrone
```

|   |   |
|---|---|
| Python | 3.11 or newer. |
| Platforms | macOS, Windows and Linux. Bluetooth must be enabled. On macOS, the terminal or application requires Bluetooth permission (System Settings → Privacy & Security → Bluetooth); without it, no units are found. |
| Dependencies | `bleak`, the Bluetooth package, is installed automatically. |
| Included in the package | This guide, in `edelkrone/doc/` inside the installed package, and two example programs: `python -m edelkrone.examples.read_device` and `python -m edelkrone.examples.store_and_return`. |
| Version | `edelkrone.__version__` |

A first program:

```python
import edelkrone

for unit in edelkrone.discover():                  # units in range
    print(unit.to_json())

with edelkrone.connect(name="HeadPLUS V3") as device:
    print(device.info.to_json())                   # identity
    print(device.status.to_json())                 # current state

    device.move.joystick(pan=0.3)                  # a speed, not a target; repeat while moving
    device.move.stop()

    device.poses.store(0)                          # store the current position in slot 0
    result = device.poses.move_to(0)               # return to it
    if not result.is_success:
        print(result.reason)                       # the reason the move did not complete
```

The SDK follows three principles:

- **Calls are synchronous.** Each call returns when its operation has completed. No event loop is required. In asynchronous code, run a call in a worker thread, for example `await asyncio.to_thread(device.poses.move_to, 0)`.
- **The SDK polls the device.** After `connect()`, the SDK requests a new reading four times per second, and `device.status` always holds the latest one.
- **Refusals are returned; faults are raised.** A request the device declines, such as a move to an empty slot, returns a result that states the reason. A failure of the connection or of a command raises an exception (section 9).

> **Not included in release 0.1.** Targets and point mode, driving to coordinates, creating a dolly path and reading the dolly position, and timelapse. These functions are planned for later releases.

## 2 Connecting

### 2.1 `edelkrone.connect()`

`connect()` finds the unit, connects to it, reads its identity and a first reading, joins its modules if required, starts polling and returns a `Device`. If any of these steps fails, it raises an exception that identifies the step (section 9).

```python
device = edelkrone.connect(name="HeadPLUS V3", mac="A4:C1:38:5E:7B:2D", timeout=20.0)
```

| Argument | Default | Description |
|---|---|---|
| `name` | `None` | The product name printed on the unit. Case and spaces are ignored: `"HeadPLUS V3"`, `"headplus v3"` and `"HEADPLUSV3"` are equivalent. |
| `mac` | `None` | The unit's Bluetooth address, in any common notation: twelve hexadecimal digits, with `:`, `-` or `.` between them or none. Anything else is refused with `bad_argument` before the search. The address does not change when the unit is restarted. |
| `with_modules` | `None` | For units that have not been used together before: the addresses of the modules to join to this unit (section 2.3). |
| `timeout` | 20 s | How long to search for the unit before raising `not_found`; a positive number of seconds. It limits the search only: connecting to a unit that has been found takes up to three attempts of 20 seconds each, three seconds apart. |
| `transport` | The desktop Bluetooth radio | A radio held by the program, from `edelkrone.open_radio()`, so that several calls share one radio. A radio passed here is not closed by `disconnect()`; close it with `dispose()`. A radio opened by `connect()` itself is closed by `disconnect()`. |
| `config` | Default values | A `SessionConfig` that sets timeouts and the polling interval. |
| `allow_narrowing` | `False` | Accepted, with no effect; a `DeprecationWarning` says so. A unit keeps the layout its modules form for as long as they are switched on (section 2.3). |

Specify a name, an address, or both; when both are given, both must match. When neither is given, the SDK connects to the only unit in range. If more than one unit is in range, the call is refused and the message lists the units found. The address of a module that belongs to a layout is refused at once with `not_found`, and the message names the unit to connect to instead; so is the address of a module of a unit this program has open, which is driven through that unit's `Device`. A second `connect()` to a unit this program already has open, by its address or by its name, is refused with `already_connected`; to reach another unit of the same name, give its address.

To share one radio between several calls, open it with `open_radio()`:

```python
radio = edelkrone.open_radio()
try:
    units = edelkrone.discover(transport=radio)
    with edelkrone.connect(mac=units[0].mac, transport=radio) as device:
        ...
finally:
    radio.dispose()
```

### 2.2 Finding Units in Range: `edelkrone.discover()`

`edelkrone.discover(seconds=8.0)` listens for the given time and returns every unit found as a list of `Seen`, ordered by signal strength. Opening and closing the radio add a few seconds to the call. It accepts the same `transport` argument as `connect()`. A unit this program has connected to is not listed, because a connected unit does not advertise, and neither are the modules of its layout. A module whose unit is connected elsewhere, for example to the edelkrone application, is listed on its own with the layout `group_member`; connect to its unit instead.

```python
for unit in edelkrone.discover():
    print(unit.to_json())

device = edelkrone.connect(mac=edelkrone.discover()[0].mac)
```

```json
{"name": "HeadPLUS V3", "mac": "A4:C1:38:5E:7B:2D", "signal": -51,
 "layout": "pan_tilt_and_slide", "modules": ["SliderPLUS V6"], "recognised": true}
```

| Field | Description |
|---|---|
| `name`, `mac` | The product name and the Bluetooth address, as accepted by `connect()`. |
| `signal` | The received signal strength in dBm, always below zero; values closer to zero indicate a stronger signal. The list is sorted by this field. |
| `layout`, `modules` | The layout the unit advertises and the product names of the modules in it. For a unit on its own, `layout` is `none` and `modules` is empty. |
| `recognised` | Whether the SDK supports this combination of units. An unrecognised combination is listed but cannot be connected to; so is a focus module, which works only through the head it is fitted to. |

> **Connected units.** A unit connected to this program does not advertise and is not listed. A unit held by the edelkrone application on a phone may still be listed, but refuses the connection: `connect()` then raises `timed_out`. Close the other connection and try again.

### 2.3 Layouts

Modules mounted on a head, such as a slider, a jib or a dolly, are controlled through the head. The program connects to the head, which relays commands to its modules. `device.info.modules` lists the modules and `device.info.layout` names the layout, for example `PAN_TILT_AND_SLIDE`.

The two three-piece products are also layouts, and in both the program connects to the head:

- **Krone X**: a HeadPLUS V3 with a JibPLUS and a DollyPLUS (`PAN_TILT_DOLLY_AND_JIB_PLUS`).
- **Krone S**: a HeadPLUS V3 with a SliderPLUS and a DollyPLUS (`PAN_TILT_DOLLY_AND_SLIDER`).

Units that have been used together before advertise their layout, and `connect()` requires no further arguments. Units that have not been used together must be joined once. Pass the module addresses in `with_modules`; the SDK joins them by cable or by radio, as the hardware requires:

```python
device = edelkrone.connect(mac="A4:C1:38:5E:7B:2D", with_modules=["C8:F0:9E:11:22:33"])
```

A combination of units that the SDK does not support is refused with `NotSupported`, and the message names the units. If the unit does not take the modules named into its layout — for example a module that already belongs to another layout — `connect()` raises `layout_refused` rather than returning the unit in its old layout. The same holds when the unit takes the layout but its modules do not answer within 6 seconds: such a layout dissolves on its own. `connect()` returns when the modules answer the unit, so `status.modules_responding` and the module axes in `status.pose` are present from the start.

> **A layout lasts while its modules are switched on.** The unit keeps the layout its modules form; it cannot be undone from the SDK. When a module is switched off, the device dissolves the layout — the head and its modules report `none` — and deletes the poses stored on the head; switching the module on again does not restore either. A refused move then names the change. Connect again with `with_modules` to form the layout again. Switching all units off and on returns each to its own. **Before forming a different layout, switch all units off and on**: a module that was part of another layout since it was switched on does not join a new one, even when every unit advertises `none`. `connect()` then raises `layout_refused`, including when the new layout dissolves while the connection is being made.
>
> **The positions of a Krone S.** In a Krone S the head reports the slider position from its own zero, which differs from the position the slider reports on its own. A pose stored on a Krone S includes the slider, and `move_to()` returns it with the head.

### 2.4 Ending the Connection

`device.disconnect()` stops polling and closes the connection. If `connect()` opened the radio, it closes the radio as well. Calling it more than once has no further effect. A `Device` is a context manager, so a `with` block closes the connection in all cases:

```python
with edelkrone.connect(name="SliderPLUS V6") as device:
    device.move.joystick(slide=0.2)
    time.sleep(1.0)
    device.move.stop()
```

> **Stop the equipment before disconnecting.** Disconnecting does not stop moving equipment. Call `device.move.stop()` first, and place both calls in a `finally` block so that the equipment stops if the program fails.

## 3 The Device

`connect()` returns a `Device`. Its `info` and `status` describe the unit, and four groups provide the operations: `move`, `poses`, `focus` and `settings`. The `Device` itself manages the connection:

| Member | Description |
|---|---|
| `info` | The unit's identity, read when connecting (section 3.1). |
| `status` | The latest reading (section 3.2). |
| `status_updates` | A stream that delivers each new reading; see `subscribe()` and `tap()` in section 3.2. |
| `refresh()` | Requests the identity, position and state from the device immediately. |
| `poll_position(every=…)` | Keeps `status.pose` up to date. The periodic reading reports the battery and the activity, but not the position; without position polling, `status.pose` holds the value from the last `refresh()`, except during joystick control, when the position is requested in any case. `poll_position()` requests the position every 250 ms, `poll_position(every=1.0)` once per second, and `poll_position(every=None)` stops. Disabled by default, because it doubles the radio traffic. |
| `is_connected` | Whether the connection is currently open. |
| `connection_changes` | A stream of `True` and `False` values as the connection opens and closes, with the same `subscribe()` and `tap()` as `status_updates`. A lost connection is reported here at once; the SDK does not reconnect by itself. |
| `disconnect()` | Closes the connection. A `with` block calls it on exit. |

### 3.1 Identity: `device.info`

An `Info` object, read when connecting and unchanged until the connection is closed. `to_dict()` returns it as a dictionary and `to_json()` as a JSON object; the field names match the Python attributes. `DeviceStatus`, `Seen` and `MoveOutcome` provide the same two methods; a `RigPose` is exported as part of the reading that contains it.

```json
{
  "name": "HeadPLUS V3",
  "mac": "A4:C1:38:5E:7B:2D",
  "firmware": "0.31",
  "radio_firmware": "0.34",
  "firmware_ok": true,
  "layout": "pan_tilt_and_slide",
  "modules": ["SliderPLUS V6"],
  "axes": ["pan", "slide", "tilt"],
  "capabilities": ["focus", "manual_drive", "position", "settings", "stored_pose"]
}
```

| Field | Description |
|---|---|
| `name`, `mac` | The product name printed on the unit and its Bluetooth address. |
| `firmware`, `radio_firmware`, `firmware_ok` | The firmware versions of the device and of its radio module, and whether both meet the minimum versions this SDK supports. If `firmware_ok` is `false`, update the firmware with the edelkrone application before continuing. |
| `layout` | The modules the unit drives and their arrangement. `null` if the unit reports no layout. A head on its own reports `none`. A slider, jib or dolly on its own reports the one layout its model has (`slide_only`, `jib_plus_only`, `dolly_only` and so on), while `discover()` shows the `none` the unit advertises. |
| `modules` | The product names of the other units in the layout. |
| `axes` | The axes the layout can drive or read, as `RigAxis` values: `PAN`, `TILT`, `SLIDE`, `JIB_PAN` and `JIB_TILT`, shown in lower case in JSON. A head on its own has two axes; a head with a slider has three. A layout with a JibPLUS drives the arm through `jib_pan` and `jib_tilt` and has no `slide`; a layout with a JibONE drives its boom through `slide`. `RigAxis` also has `FOCUS`, the axis of `RigPose.focus_cm`; it is never listed here, because the focus motor is driven through `device.focus`. |
| `capabilities` | The functions the SDK supports on this model, as `Capability` values: `POSITION`, `STORED_POSE`, `MANUAL_DRIVE`, `FOCUS`, `SETTINGS` and `TRIPOD_LEVEL`, shown as `position`, `stored_pose`, `manual_drive`, `focus`, `settings` and `tripod_level` in JSON. A function not listed here is refused with `NotSupported`. To check before calling, use for example `Capability.FOCUS in device.info.capabilities`. |

### 3.2 State: `device.status`

A `DeviceStatus` object holding the latest reading, in degrees, centimetres, percent and seconds. It is held in memory, so reading it does not communicate with the device. Polling updates it four times per second, and `device.refresh()` requests a reading immediately. Any field may be `null`, which always means that the device has not reported the value: for example, a slider has no pan angle, a tripod reports no battery level, and before the first reading every field except `model` is `null`.

```json
{
  "model": "HeadPLUS V3",
  "at": 1789822822.031,
  "activity": "idle",
  "pose": {"pan_deg": 89.96, "tilt_deg": 0.0, "slide_cm": 12.4, "focus_cm": null, "jib_pan_deg": null, "jib_tilt_deg": null},
  "slide_fraction": 0.31,
  "battery_percent": 82.0,
  "is_calibrated": true,
  "has_focus_motor": false,
  "layout": "pan_tilt_and_slide",
  "stored_pose_slots": [0, 3],
  "modules_responding": true,
  "move_progress": null,
  "position_is_trusted": true,
  "has_status_frame": true,
  "tilt_deg": 1.2,
  "is_level": true,
  "orientation_warning": false,
  "accel_x_ms2": null,
  "accel_y_ms2": null,
  "accel_z_ms2": null,
  "dolly_x_cm": null,
  "dolly_y_cm": null,
  "dolly_heading_deg": null
}
```

| Field | Description |
|---|---|
| `model`, `at` | The product name of the unit that sent the reading, the same text as `info.name`, and the time the reading arrived as Unix time in seconds, as returned by `time.time()`; `time.time() - status.at` is its age. |
| `activity` | An `ActivityState`: `IDLE`, `MOVING`, `TRANSITIONING` or `TRACKING_TARGET`, shown as `idle`, `moving`, `transitioning` and `tracking_target` in JSON. The last two occur only while the edelkrone application is tracking a target with the head. `null` before the first reading. `is_moving` provides the same information as a Boolean. |
| `pose`, `position_is_trusted` | The position of the equipment as a `RigPose`: pan and tilt in degrees, the slider position in centimetres and the jib axes in degrees. Axes not present in the layout are `null`. The position always comes from a live reading; `position_is_trusted` is `true` once one has arrived. `focus_cm` is `null` unless the lens has been learned with the edelkrone application; the focus position as a percentage is `device.focus.percent()`. |
| `slide_fraction` | The slider or jib position as a fraction of its travel, from 0 to 1. Unlike the value in centimetres, it is included in every reading. Reliable only once the slider has been referenced (`is_calibrated` is `true`); before that it may be `null` or a value that does not match `slide_cm`. A slider may run a movement of its own for a while after it is switched on, and reports `moving` meanwhile; wait for `idle` before moving it. |
| `battery_percent` | The battery level. A tripod is powered from the mains and reports none. |
| `is_calibrated` | For a slider, whether it has been referenced; for a head, whether its level reference is set. `null` for models that require neither, and before the first reading. Information only: the SDK refuses no move on it, and a slider that is not referenced performs planned moves. |
| `has_focus_motor` | Whether a focus motor is fitted, as reported by the device. `null` before the first reading. |
| `layout` | The layout most recently reported by the device. |
| `stored_pose_slots` | The slots that contain a stored pose. |
| `modules_responding` | Whether the modules in the layout respond to the unit. A module that advertises is powered, but not necessarily responding. `null` for a layout without modules. |
| `move_progress` | The progress of a planned move, from 0 to 1. Reported by heads; `null` on other units. |
| `has_status_frame` | Whether a complete reading has been received. Until then, the other fields are `null`. The property `is_known` returns the same value. |
| `tilt_deg`, `is_level`, `orientation_warning` | The unit's inclination from level in degrees, whether it is within 15°, and the device's orientation warning. Reported by PRO heads and by sliders; `null` on units without an inclination sensor. |
| `accel_x_ms2`, `accel_y_ms2`, `accel_z_ms2` | The unit's accelerometer along its X, Y and Z axes, in m/s². At rest the three make up gravity. Reported by heads, sliders, jibs, dollies and tripods whose firmware includes the accelerometer; `null` on older firmware and on a focus module. In a layout the reading is the head's; a module's own is read by connecting to it on its own. On a head the sensor is in its body, so the pan and tilt motors do not change it. The sensors are not calibrated: at rest the magnitude may differ from 9.81 m/s² by up to 0.5 m/s². Read it at rest over a few seconds: just after a layout forms or a module moves it can read lower. |
| `dolly_x_cm`, `dolly_y_cm`, `dolly_heading_deg` | A dolly's place on the floor, X and Y in centimetres, and its heading — the direction it points — in degrees. Reported by a dolly whose firmware includes them, connected on its own; `null` on older firmware and on every other unit. In a layout the unit connected to is the head, which does not report them. |

To receive each reading as it arrives, subscribe to `status_updates`:

```python
for status in device.status_updates.subscribe(max_pending=8):
    print(status.pose.pan_deg, status.battery_percent)
```

A subscription is a queue that the program reads at its own pace. `max_pending` sets how many readings it holds (256 by default), a whole number of 1 or more, or `None` for no limit; any other value is refused with `bad_argument`. When it is full, the oldest reading is discarded. `next_event(timeout)` waits for one reading, and `cancel()` or a `with` block ends the subscription.

`tap(callback)` calls `callback(reading)` for each reading instead, on the SDK's delivery thread, and `untap(callback)` removes it. Keep a callback short and send no command from it: the reply could only be delivered on the thread that would be waiting for it, so the SDK refuses such a command with `CommandError` · `from_callback`. Hand the work to a thread of your own, or read `device.status`.

## 4 Moving

### 4.1 Joystick Control: `device.move.joystick()`

`joystick()` sets a speed for each axis, from −1 to 1 as a fraction of the maximum speed. It does not set a target position. **The command must be repeated** approximately every 100 ms for as long as the equipment is to move; when commands stop arriving, the device goes on at the last speed for 1.5 seconds and then stops. Setting an axis back to zero stops it at once: a call in which every axis is zero, such as a stick returning to the centre, stops the axes that were moving. Call `stop()` to end the movement.

```python
deadline = time.monotonic() + 2.0
try:
    while time.monotonic() < deadline:
        device.move.joystick(pan=0.3, tilt=-0.1)
        time.sleep(0.1)
finally:
    device.move.stop()
```

| Axis | Movement |
|---|---|
| `pan`, `tilt` | The head. |
| `slide` | The slider carriage. In a JibONE layout, the height of the jib arm. |
| `jib_pan`, `jib_tilt` | The rotation and the tilt of the jib arm. |
| `linear`, `angular` | A dolly: forward and backward movement, and rotation in place. A dolly on its own moves freely; a dolly in a layout moves only along an existing path, created with the edelkrone application. Along a path only `linear` applies; on a free path, as the edelkrone application calls it, `angular` applies as well. |

Axes not present in the layout (`info.axes`) are ignored, and a warning in the diagnostic log names the axis the first time; no other axis is moved in its place. `linear` and `angular` are not `RigAxis` values and never appear in `info.axes`: they apply whenever the layout has a dolly. Values outside ±1 are limited to ±1. A value that is not a finite number is refused with `bad_argument`. If `stop()` is never called, the SDK treats the joystick as released 1.6 seconds after the last call.

### 4.2 Stopping

| Member | Description |
|---|---|
| `stop(settle=2.0)` | Stops all movement: joystick control, a move to a stored pose and the tripod column alike. It goes ahead of every pending command: the axes the joystick is driving are set to zero first, the planned move is aborted, and every other axis is set to zero. The call then waits up to `settle` seconds for the device to report that it is stationary and reads the position, so `status.pose` is where the equipment stopped; it typically returns within a second. The stop commands and the position read take time of their own, about 0.6 s on a unit on its own and about 1.3 s in a layout with modules, so `settle=0` skips only the wait for the device to report that it is stationary. It does not raise exceptions, so it can be used in a `finally` block: if the connection was lost during the movement, no second exception conceals the first. The one exception is a `settle` that is not a number of zero or more: the stop is sent without waiting, and then `bad_argument` is raised. |
| `is_moving` | Whether the latest reading reports the device as moving. `None` before the first reading, which differs from `False`. |
| `wait_until_idle(timeout=60.0)` | Waits until the device reports that it is idle; returns `False` on timeout. `timeout` bounds the whole wait, and `0` asks once. After `joystick()` it waits until the equipment has come to rest, about 1.6 s after the last call. `poses.move_to()` already waits in this way. |

> **The joystick brings the reading.** The device answers every joystick command with a reading, and `joystick()` returns when that answer has arrived; the SDK sends no request before the previous one has been answered. During joystick control the SDK therefore requests no readings of its own: `status_updates` delivers the device's answers, and the position is requested four to five times per second, so `status.pose` follows the movement. Read `device.status` in the joystick loop; `refresh()` is not needed there. Regular polling resumes approximately 250 ms after the last joystick command.

## 5 Poses

A pose is the position of every axis in the layout, stored in a numbered slot on the device. `move_to()` returns the equipment to a stored pose; the device plans the motion so that all axes arrive at the same time.

```python
device.poses.store(0)
result = device.poses.move_to(0, style=MoveStyle(speed=0.15, acceleration=0.4))
print(result.to_json())        # {"accepted": true, "completed": true, "duration": 3.4, "reason": null}
```

| Member | Description |
|---|---|
| `store(slot)` | Stores the current pose in `slot` and confirms from the next reading that the device has stored it; a slot that holds a pose is emptied first, so the confirmation is of the new pose. Returns `False` if the device did not store it, for example for a slot the firmware does not provide: a HeadPLUS V3 provides slots 0 to 11. |
| `stored` | The slots that contain a pose, as last reported by the device. To test a slot: `slot in device.poses.stored`. |
| `clear(slot)`, `clear_all()` | Deletes the pose in one slot, or in all slots. |
| `move_to(slot, style=None, start=None, timeout=60.0, verify=True)` | Moves to the stored pose and waits until the equipment is stationary. With `verify` enabled, the SDK reads the stored pose and the position the equipment reached, and reports a move as successful only if every axis of the layout arrived: within 0.25° on the head, 0.4° on a jib and 0.25 cm on a slider. A move in which the equipment did not move at all is reported as such. `start` names a slot to plan the move from, instead of the current position. A refusal is returned as a result, not raised. A loop (`MoveStyle(loop=True)`) does not finish and is refused here; start it with `start_move_to()`. |
| `start_move_to(slot, style=None, start=None)` | Sends the move and returns immediately, for programs that control several devices and for loops: `start_move_to(1, start=0, style=MoveStyle(loop=True))` moves back and forth between the poses in slots 0 and 1 until `move.stop()`. `move.wait_until_idle()` waits for a move that ends. An empty slot is refused with `bad_argument`, since there is no result to carry the refusal. |

Slot numbers are whole numbers from 0 to 15 on a head and from 0 to 11 on a slider or a jib; any other value is refused with `bad_argument` before anything is sent. The protocol carries 16 slots on a head, but the firmware decides how many it stores: a HeadPLUS V3 stores 0 to 11, and `store()` returns `False` above that. A dolly and a tripod store no poses in slots, and every call of `device.poses` except `stored` raises `NotSupported` on them.

> **A head moves from pose to pose.** When the head stands at a stored pose, or between two of them, a move to another pose runs through every slot in between, in order, as on the edelkrone application's keypose screen: from the pose in slot 2 to the pose in slot 0 it passes the pose in slot 1. Two conditions follow. An empty slot on the way stops the move, so the SDK refuses such a move with a reason that names the empty slots; two consecutive poses stored in the same place stop it there too. **Store poses in consecutive slots, each in a different place**, as the edelkrone application does. Away from every pose the move is direct.

The result of a move is a `MoveOutcome`:

| Field | Description |
|---|---|
| `accepted` | The device accepted the command. |
| `completed` | The device reported the move as finished and the equipment as stationary. |
| `duration` | The duration of the move in seconds. |
| `reason` | If the move did not take place, the reason, for example an empty slot, a layout that dissolved since connecting, equipment that did not move, or an axis that stopped short. `None` on success. |
| `is_success` | `True` when both `accepted` and `completed` are true. |

A `MoveStyle` defines how a move is performed. Its two values are fractions of the device's maximum: `speed` above 0 and at most 1, `acceleration` from 0 to 1. A value outside that range, or not a finite number, is refused with `bad_argument` when the move is sent.

```python
MoveStyle(speed=0.15, acceleration=0.4)             # slow, with long acceleration and deceleration
MoveStyle(speed=1.0, acceleration=0.01)             # the fastest possible move
MoveStyle()                                         # speed 0.5, acceleration 0.25: the default of move_to()
MoveStyle(speed=0.3, acceleration=0.25, loop=True)  # with start_move_to(slot, start=...): back and forth until move.stop()
```

`speed` sets the speed. `acceleration` sets the share of the move used for acceleration at the start and deceleration at the end. `loop` repeats the move back and forth between the slot and `start`. For short and medium moves the duration depends mostly on `acceleration`; `speed` sets the top speed, which a short move may not reach.

> **Short moves.** If the equipment is within approximately 3° of the stored pose, or a slider within approximately 1 cm, the device may not start the planned move; how short a move a unit still makes differs by device (a head was measured making 1.5° moves, a jib not starting 1.8°). When the device does not start and the equipment is away from the pose, `move_to()` returns a result with `is_success` set to `False` and a reason stating that the equipment did not move. Equipment already at the pose is a success. This is a property of the device and not a fault. A jib recalled together with a focus move may stop up to 0.35° short on its tilt.
>
> **Axes stored in a pose.** A pose stores only the axes of the current layout. If a head is connected without its modules, its poses contain pan and tilt only. Check `device.info.layout` before storing poses.

## 6 Focus

A head with a focus motor sets focus as a **percentage of the lens travel**, from 0 at one end to 100 at the other. A focus module works only through the head it is fitted to; on its own it is refused at `connect()`. The motor must first learn the lens: it sets its reference, and then sweeps the lens once from one end to the other to find the travel. `setup()` performs these steps. The device keeps what it learned until it is switched off, so only the first `setup()` after switching on moves the lens, for 10 to 25 seconds depending on the lens; every later call returns at once. Keep the focus ring clear while it runs.

```python
device.focus.setup()                    # first, on every connection
reached = device.focus.move_to_percent(50)
print(device.focus.percent())           # current position of the focus ring
```

| Member | Description |
|---|---|
| `setup(timeout=120.0)` | Prepares the focus motor: the motor reference, then one sweep of the lens, each only if the device reports it missing. It must be called once on every connection, before anything else. Refused if no focus motor is fitted. The sweep is stopped after 45 seconds whatever `timeout` says, to protect the lens; if it found no end by then — no lens fitted, or a ring that turns freely — `setup()` raises `CommandError` · `focus_ends_not_found`. `timeout` limits the motor reference, and must be a positive number of seconds; any other value is refused with `bad_argument`. |
| `move_to_percent(percent, tolerance=1.0)` | Moves to a percentage of the travel, from 0 to 100, and returns the percentage the device reports when the ring has come to rest. It returns as soon as the ring is at rest within `tolerance` percentage points of the target, or wherever it came to rest. The device performs the movement at its own speed (about 35 seconds for the whole travel of a typical lens) and stops at the ends found by `setup()`. |
| `percent()` | The current position of the focus ring, as the percentage of the travel the device reports. |

> **`setup()` is required before other operations.** On a unit that reports a focus motor, the following calls raise `CommandError` · `needs_focus_setup` until `setup()` has been called on the current connection: the joystick, all stored-pose operations, the settings, the tripod operations, `focus.move_to_percent()` and `focus.percent()`. The following calls are always available: all reads (`info`, `status`, `refresh()`, `poses.stored`), `move.stop()` and `move.wait_until_idle()`, `disconnect()` and `setup()`.
>
> The reason is that a stored pose on such a unit includes the focus position, which is measured on the travel `setup()` has the device learn. A pose stored or recalled before it would place the focus ring at an unintended position.
>
> When `connect()` connects to a unit with a focus motor, it issues a `UserWarning` stating that `setup()` is required. To suppress it, use `warnings.filterwarnings("ignore", message=".*focus motor.*")`. Units without a focus motor are not affected.

Focus in centimetres, that is a distance to the subject, is not supported in this release; `status.pose.focus_cm` is `null` unless the lens has been learned with the edelkrone application.

## 7 Settings

| Member | Description |
|---|---|
| `buzzer(muted=…)` | Mutes or unmutes the buzzer, and returns once the device has taken the setting: within 0.1 s, or about 0.7 s when settings are written one after another. `muted` must be `True` or `False`. |

The device does not acknowledge settings. The call returns once the command has been sent.

## 8 Tripod

A motorized tripod levels itself and raises or lowers its column. The two operations are reached through `device.settings` and are refused on units other than tripods.

```python
device.settings.level_tripod()          # starts the automatic levelling

end = time.monotonic() + 3.0            # raises the column for 3 seconds
while time.monotonic() < end:
    device.settings.drive_tripod(40)
    time.sleep(0.2)
device.settings.drive_tripod(0)         # stops the column
```

| Member | Description |
|---|---|
| `level_tripod()` | Starts the automatic levelling of the tripod. |
| `drive_tripod(relative_speed)` | Moves the column at a speed from −100 to 100: positive values extend it, negative values retract it, and 0 stops it. Values outside the range are limited to −100 and 100, a fraction is rounded to the nearest whole number, and a value that is not a finite number is refused with `bad_argument`. **It must be repeated** every 100 to 200 ms for as long as the column is to move, in the same way as the joystick; a single call moves the column for about 1.5 s, after which it stops by itself. `move.stop()` stops the column as well. |

The tripod does not acknowledge these commands. The calls return once the command has been sent. `status.activity` reads `moving` while the column moves and while the tripod levels, as the device reports it. After `drive_tripod(0)` it may go on reading `moving` for 1 to 1.5 s; `move.stop()` ends it at once. A tripod that is already level finishes levelling within a second. The tripod reports no inclination, so `tilt_deg` and `is_level` are `null`.

## 9 Errors

The SDK distinguishes two kinds of failure. A **refusal**, such as an empty slot or a move that did not start, is returned as a result with `is_success` and a `reason`. A **fault** raises one of three exceptions, all subclasses of `edelkrone.Error`. Each carries a `reason` code, a `message` and a `cause` with technical details for support requests.

```python
try:
    device = edelkrone.connect(name="HeadPLUS V3")
except edelkrone.ConnectionError as e:
    print(f"{e.reason}: {e.message}")        # not_found: HeadPLUS V3 was not seen in 20.0 s. Seen: …
```

| Exception and reason | Cause | Action |
|---|---|---|
| `ConnectionError` · `not_found` | The unit was not found within the timeout: it is switched off, out of range, or connected to another program. Or the address given is that of a module in a layout, which is reached through its head; the message names the head. | Check the power and close other connections to the unit. `discover()` lists the units in range. |
| `ConnectionError` · `ambiguous` | No name or address was given, and more than one unit is in range. | Specify the unit. The message lists the units found and their addresses. |
| `ConnectionError` · `no_radio` | The computer has no Bluetooth adapter, or the Bluetooth package is not installed. | Check the adapter. Reinstall with `pip install edelkrone`. |
| `ConnectionError` · `no_permission` | The operating system has not granted Bluetooth access to the program. | macOS: System Settings → Privacy & Security → Bluetooth. |
| `ConnectionError` · `timed_out` | The unit was found but did not accept the connection (three attempts of up to 20 seconds, three seconds apart), or did not respond to the first identity request. A unit held by the edelkrone application on a phone does this. | Close any other connection to the unit; if none, restart the unit and reduce the distance. |
| `ConnectionError` · `dropped` | The connection was lost while `connect()` was reading the identity or joining the modules. A connection lost later is reported by `connection_changes`, and commands then raise `not_connected`. | Reconnect. |
| `ConnectionError` · `already_connected` | The unit is already connected in this program. | Use the `Device` that `connect()` returned, or `disconnect()` it first. |
| `ConnectionError` · `wrong_device` | The unit connected but does not provide the services expected for its model, usually because of outdated firmware; or the unit reports a firmware error (its main firmware is not running, for example after an interrupted update), in which case `discover()` lists it with `recognised` set to `false` and no connection is attempted. | Check `info.firmware_ok` and update the firmware with the edelkrone application; after a firmware error, update it and switch it off and on. |
| `ConnectionError` · `layout_refused` | The unit did not take the modules named in `with_modules` into its layout, or took them in but they did not answer, for example because it already forms a layout with other modules, or a module belongs to another layout. | Check that the modules are switched on. Switch all units off and on so that each advertises on its own, then name all modules in one `connect()`. |
| `CommandError` · `not_connected` | A command was sent after the connection was lost. | Reconnect. |
| `CommandError` · `closed` | A command was sent after `disconnect()`. | Connect again. A `Device` cannot be reused after it is closed. |
| `CommandError` · `no_reply` | The device did not respond to a command in time. | Retry once. If the error persists, restart the unit. |
| `CommandError` · `bad_reply` | The device sent an unexpected response. | Report the error with `info.firmware` and `cause`. |
| `CommandError` · `bad_argument` | An argument is invalid, for example not a number, a slot out of range, a speed of `nan` or above 1, a timeout that is not a positive number of seconds, a loop given to `move_to()`, or a move started to an empty slot. | Correct the argument. |
| `CommandError` · `from_callback` | A command was sent from inside a `tap()` callback, where its reply could never be delivered. | Hand the work to a thread of your own, or read `device.status`. |
| `CommandError` · `needs_focus_setup` | The unit reports a focus motor and `setup()` has not been called on this connection, or the device has dropped what it learned. Only calls that change the equipment and the focus calls raise it. | Call `device.focus.setup()` (section 6). |
| `CommandError` · `focus_ends_not_found` | `focus.setup()` swept the lens for 45 seconds without finding its ends, and stopped. | Check that a lens is fitted and the focus gear engages it. |
| `NotSupported` · `unsupported` | The model or layout does not provide the function, for example a pose on a tripod or a dolly, focus on a slider, or a focus module connected to on its own; or the units named in `with_modules` do not form a supported layout. | Check `device.info.capabilities`. |

> **`edelkrone.ConnectionError` is not Python's built-in `ConnectionError`.** Refer to it as `edelkrone.ConnectionError`; `from edelkrone import *` replaces the built-in name in the importing module.

> **Signal strength.** A unit at the edge of the radio range can be found by `discover()` but may fail after connecting, with `dropped`, `not_connected` or `no_reply`. Layouts with modules are affected first. If connections are unreliable, reduce the distance to the unit.

## 10 Diagnostic Log

The SDK writes diagnostic messages to `EdelkroneLog`. The log is disabled by default. To enable it, assign a function to `EdelkroneLog.sink` and, optionally, set `EdelkroneLog.minimum_level` to an `EdelkroneLogLevel`: `TRACE`, `DEBUG`, `INFO` (the default), `WARNING` or `ERROR`.

```python
from edelkrone import EdelkroneLog, EdelkroneLogLevel

EdelkroneLog.sink = lambda level, message: print(f"[{level.name}] {message}")
EdelkroneLog.minimum_level = EdelkroneLogLevel.DEBUG
```

A sink is an `EdelkroneLogSink`: a function that takes the level and the message. It may be called from several threads, must not raise exceptions and must return quickly. To forward messages to Python's `logging` module, call a `logging` logger from the sink.

## 11 API Summary

The following table lists every name exported by `import edelkrone` and the section that describes it.

| Name | Description | Section |
|---|---|---|
| `connect()`, `discover()`, `open_radio()` | Connect to a unit; list the units in range; open a radio to share between calls. | section 2 |
| `Device` | A connected unit, with its identity, state and four operation groups. | section 3 |
| `Info`, `DeviceStatus`, `Seen` | The identity, a reading, and a unit found in a scan. Each can be exported as JSON. | section 3.1, section 3.2, section 2.2 |
| `MoveControl`, `PoseController`, `FocusControl`, `SettingsControl` | The types of `device.move`, `device.poses`, `device.focus` and `device.settings`, for use in type hints. They are not constructed directly. | section 4, section 5, section 6, section 7, section 8 |
| `MoveOutcome`, `MoveStyle` | The result of a pose move; the speed and acceleration of a move. | section 5 |
| `ActivityState`, `Capability` | The activity of a device; a function a model supports. | section 3.2, section 3.1 |
| `RigPose`, `RigAxis`, `EdlSetup`, `DeviceFamily` | A position, an axis, a layout, and a device family (head, slider, jib, dolly, focus module or tripod; a focus module is reached through its head). | section 3.2, section 3.1, section 2.3 |
| `SessionConfig`, `Transport` | The types accepted by `connect(config=…)` and `connect(transport=…)`; `open_radio()` returns a `Transport`. | section 2.1 |
| `Error`, `ConnectionError`, `CommandError`, `NotSupported` | The base exception and the three fault types. | section 9 |
| `EdelkroneLog`, `EdelkroneLogLevel`, `EdelkroneLogSink` | The diagnostic log, its levels and the sink type. | section 10 |
| `__version__` | The installed version. | section 1 |

This content is subject to change.

If you have any questions about this document, please contact edelkrone.

Copyright © 2026 edelkrone.
