bus

ElliptecBus owns the serial port for one multidrop connection: a single background worker thread services every request()/send()/read_reply() call through a priority queue (see Polling position without starving other commands), so replies are never interleaved and explicitly issued commands always jump ahead of background polling.

Serial communication with an Elliptec ELLx multidrop bus (a “hub”).

A single ELLx interface adapter (USB or TTL RS-232) exposes one serial port on which up to 16 devices (addresses 0-F) can be daisy-chained. This module owns the serial port, frames outgoing commands, reads and dispatches replies, and understands the two-phase reply pattern used by long-running commands (an interim “GS” busy status followed by a final data reply).

Device-level command objects (see tl_elliptec.devices) are built on top of an ElliptecBus instance and should not talk to serial directly.

Only one thread ever touches the serial port: a single background worker owned by ElliptecBus. Every public entry point (request, send, read_reply) submits a job to that worker through a priority queue (see RequestPriority) and blocks the calling thread until its own job completes. This is what lets several devices on a shared bus – and a background position poller per device – all issue commands concurrently from different threads without corrupting each other’s replies, while letting explicitly issued commands (moves, reads, …) always jump ahead of low-priority background polling that’s still waiting for its turn.

class tl_elliptec.bus.RequestPriority[source]

Bases: object

Ordering used by ElliptecBus’s broker (lower value = serviced first).

COMMAND is the default for every explicitly issued call (moves, status reads, addressing, …). POLL is for background/opportunistic traffic – e.g. poll_position() – so it never delays a command a caller is actively waiting on, even when several devices’ pollers share one bus.

COMMAND = 0
POLL = 10
class tl_elliptec.bus.Reply(address, command, data, raw)[source]

Bases: object

A parsed incoming frame: address (single hex digit the reply came from), command (2-character reply mnemonic, e.g. "GS" or "PO"), data (hex-ASCII data payload, may be empty), and raw (the raw frame bytes as received, terminator stripped).

Parameters:
address
command
data
raw
as_status_code()[source]

Interpret this reply’s data as a GS/BS status byte.

Returns:

The numeric status code (see StatusCode).

Return type:

int

raise_for_status()[source]

Raise ElliptecStatusError if this is a non-OK GS/BS reply.

Raises:

ElliptecStatusError – If command is "GS"/"BS" and the status code isn’t OK.

Return type:

None

class tl_elliptec.bus.ElliptecBus(port, baudrate=9600, timeout=2.0, serial_kwargs=None)[source]

Bases: object

Owns the serial port for one Elliptec multidrop (hub) connection.

Thread-safe: a single background worker owns the serial port and services requests from a priority queue (see RequestPriority), so replies from different addresses on the shared bus are never interleaved or misattributed, and commands a caller is actively waiting on always preempt queued background polling.

Parameters:
close()[source]

Stop the request broker’s worker thread and close the serial port.

Return type:

None

property is_open: bool

True if the underlying serial port is currently open.

read_reply(timeout=None, priority=0)[source]

Read and parse a single incoming frame (blocking).

Useful for passively listening for spontaneous BS/BO button-status messages, which aren’t solicited by a host command.

Parameters:
  • timeout (float | None) – Maximum time to wait, in seconds. Defaults to the bus’s own timeout.

  • priority (int) – Scheduling priority against other pending requests (see RequestPriority).

Returns:

The next parsed frame.

Raises:

ElliptecTimeoutError – If no frame arrives within timeout.

Return type:

Reply

send(address, command, data='', priority=0)[source]

Send a command with no expectation of a reply being awaited here.

Parameters:
  • address (str) – Single hex-digit device address.

  • command (str) – 2-character command mnemonic.

  • data (str) – Optional hex-ASCII data payload.

  • priority (int) – Scheduling priority against other pending requests (see RequestPriority).

Return type:

None

send_urgent(address, command, data='')[source]

Write directly to the wire, bypassing the request broker’s queue entirely.

Ordinary commands should go through request()/send() so they’re properly serialized and priority-ordered. This exists for the case where that’s the wrong tool: sending a command meant to affect a device while some other command for it is already executing – e.g. HOST_MOTIONSTOP “st” while continuous jog motion is running. That other job’s worker thread is blocked inside its own read loop; a normal request("st") would just sit in the queue until that job finishes on its own. Writing and reading are independent operations on a serial port, so this can safely interleave with an in-progress read on the broker’s worker thread – _write’s own lock only guards against two writes landing on the wire at once, not against a concurrent read.

Note: confirmed on real hardware that HOST_MOTIONSTOP “st” does not interrupt a bounded move_absolute/move_relative/home once issued – only continuous jog motion (jog step size 0) or an optimize/clean cycle. Sending “st” while a bounded move is in flight is a no-op; that move’s job keeps running until the physical move completes on its own, exactly as if “st” was never sent (see MotionMixin.stop).

No reply is read here – whatever job is already in flight for this address will see any response on its own next read. Call get_position()/get_status() afterward if you need to confirm the outcome.

Parameters:
  • address (str) – Single hex-digit device address.

  • command (str) – 2-character command mnemonic, e.g. "st".

  • data (str) – Optional hex-ASCII data payload.

Return type:

None

request(address, command, data='', expect=None, timeout=None, poll_timeout=None, raise_on_error=True, priority=0, reply_address=None)[source]

Send a command and wait for its reply.

Several commands (moves, homing, frequency search, cleaning…) reply with one or more interim “GS busy” frames before the final reply arrives. This loops, discarding interim busy frames, until either the expected reply mnemonic is seen, a non-busy status/error frame is seen, or the overall timeout elapses.

expect is the reply mnemonic to wait for (e.g. “PO” for a move). If omitted, the first non-busy reply is returned regardless of its mnemonic.

reply_address overrides which address incoming replies must carry to be accepted as the answer to this request; defaults to address. Needed for HOSTREQ_CHANGEADDRESS (“ca”), the one command where the device replies from its new address rather than the one the command was sent to.

priority controls scheduling against other pending requests on this bus (see RequestPriority); leave it at the default unless this call is opportunistic background polling.

Parameters:
  • address (str) – Single hex-digit device address to send the command to.

  • command (str) – 2-character command mnemonic, e.g. "in".

  • data (str) – Optional hex-ASCII data payload.

  • expect (str | None) – Reply mnemonic to wait for, e.g. "PO". If omitted, the first non-busy reply is accepted regardless of mnemonic.

  • timeout (float | None) – Overall deadline for this request, in seconds. Defaults to the bus’s own timeout.

  • poll_timeout (float | None) – Maximum time to wait for each individual interim frame (e.g. a busy status) before re-checking the overall deadline. Defaults to the remaining overall timeout.

  • raise_on_error (bool) – If True (default), a non-OK GS/BS status reply raises ElliptecStatusError.

  • priority (int) – Scheduling priority against other pending requests (see RequestPriority).

  • reply_address (str | None) – Address incoming replies must carry to be accepted; defaults to address.

Returns:

The reply that satisfied expect (or the first non-busy reply, if expect was omitted).

Raises:
Return type:

Reply

request_status(address, command, data='', **kwargs)[source]

Convenience wrapper for commands whose only reply is GS/BS.

Parameters:
  • address (str) – Single hex-digit device address.

  • command (str) – 2-character command mnemonic.

  • data (str) – Optional hex-ASCII data payload.

  • **kwargs – Forwarded to request().

Returns:

The GS/BS status reply.

Return type:

Reply

scan(addresses='0123456789ABCDEF', timeout=0.3)[source]

Probe each address with an “in” (get info) request; return the ones that answer.

Parameters:
  • addresses (str) – Iterable of single-hex-digit addresses to probe. Defaults to all 16 ("0"-"F").

  • timeout (float) – Per-address reply timeout, in seconds.

Returns:

The addresses that answered, in the order probed.

Return type:

list[str]

refresh_devices(addresses='0123456789ABCDEF', timeout=0.3)[source]

Re-scan the bus and rebuild this bus’s known-device registry.

Returns a plain, JSON-safe dict: {address: {ell_type, serial_number, travel, pulses_per_unit}}. pulses_per_unit is the already-corrected value (handles the rotary pulses-per-revolution correction internally – see ElliptecDevice.PULSES_FIELD_IS_PER_REVOLUTION), so callers never need to reimplement that calibration themselves.

The registry is mutated in place (clear()``+``update(), never rebound to a new dict), so an already-running stream_positions() generator picks up newly discovered devices on its next tick without needing to be restarted.

Parameters:
  • addresses (str) – Iterable of single-hex-digit addresses to probe. Defaults to all 16 ("0"-"F").

  • timeout (float) – Per-address reply timeout, in seconds.

Returns:

A dict keyed by address, each value a dict with keys ell_type (int), serial_number (str), travel (int), and pulses_per_unit (float) – one entry per device found.

Return type:

dict

stream_positions(interval=0.2, tolerance=0.0)[source]

Yield {address: {"pulses": int, "units": float}} for whichever devices in this bus’s registry (see refresh_devices()) changed by more than tolerance since the last tick.

One thread, one loop: every device is read sequentially through the same request broker each tick, at RequestPriority.POLL, so this never adds any concurrency on the serial link beyond what request()/send() already handle safely, and never delays an explicitly issued command. Meant to be driven by a caller that wants continuous position updates (e.g. a WebSocket “subscribe”) rather than polled directly.

Reads the registry fresh (via list(...)) each outer-loop pass, so devices added by a concurrent refresh_devices() call show up automatically on the next tick – no need to restart this generator.

Parameters:
  • interval (float) – Delay between polling ticks, in seconds.

  • tolerance (float) – Minimum change (in raw pulses) required to report a device’s position again; 0 reports on any change.

Yields:

{address: {"pulses": int, "units": float}} for whichever devices changed by more than tolerance since the last tick. Never yields an empty dict – if nothing changed, the tick is skipped entirely.