Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: Build and Publish to PyPi

on: push
on:
push:
branches: [main]

jobs:
build:
Expand Down
5 changes: 3 additions & 2 deletions examples/01_scan_servos.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import os

from python_st3215 import ST3215

controller = ST3215(os.environ.get("ST3215_PORT", "/dev/ttyUSB0"))
Expand All @@ -25,10 +26,10 @@
f" Firmware: v{servo.eeprom.read_firmware_major_version()}.{servo.eeprom.read_firmware_minor_version()}"
)
print(f" Position: {servo.sram.read_current_location()}")
print(f" Temperature: {servo.sram. read_current_temperature()}°C")
print(f" Temperature: {servo.sram.read_current_temperature()}°C")
print(f" Voltage: {servo.sram.read_current_voltage() / 10:.1f}V")
print(
f" Min/Max Angle: {servo.eeprom. read_min_angle_limit()} / {servo.eeprom.read_max_angle_limit()}"
f" Min/Max Angle: {servo.eeprom.read_min_angle_limit()} / {servo.eeprom.read_max_angle_limit()}"
)

mode = servo.eeprom.read_operating_mode()
Expand Down
1 change: 1 addition & 0 deletions examples/03_read_position.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os
import time

from python_st3215 import ST3215

controller = ST3215(os.environ.get("ST3215_PORT", "/dev/ttyUSB0"))
Expand Down
1 change: 1 addition & 0 deletions examples/07_temperature_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os
import time

from python_st3215 import ST3215

controller = ST3215(os.environ.get("ST3215_PORT", "/dev/ttyUSB0"))
Expand Down
46 changes: 25 additions & 21 deletions examples/09_registered_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,43 @@

import os
import time

from python_st3215 import ST3215

controller = ST3215(os.environ.get("ST3215_PORT", "/dev/ttyUSB0"))

try:
servos = controller.list_servos()

if len(servos) < 2:
print("This example requires at least 2 servos")
else:
servo_objects = [controller.wrap_servo(sid) for sid in servos[:2]]
servo_objects = [controller.wrap_servo(sid) for sid in servos]

for servo in servo_objects:
servo.sram.torque_enable()
servo.sram.write_acceleration(50)
for servo in servo_objects:
servo.sram.torque_enable()
servo.sram.write_acceleration(50)

print("Preparing movements with registered write...")
servo_objects[0].sram.write_target_location(1000, reg=True)
servo_objects[1].sram.write_target_location(3000, reg=True)
print("Preparing movements with registered write...")
for i, servo in enumerate(servo_objects):
if i % 2 == 0:
servo.sram.write_target_location(1000, reg=True)
else:
servo.sram.write_target_location(3000, reg=True)

print("Executing all movements simultaneously!")
controller.broadcast.action()
time.sleep(2)
print("Executing all movements simultaneously!")
controller.broadcast.action()
time.sleep(2)

print("Preparing opposite movements...")
servo_objects[0].sram.write_target_location(3000, reg=True)
servo_objects[1].sram.write_target_location(1000, reg=True)
print("Preparing opposite movements...")
for i, servo in enumerate(servo_objects):
if i % 2 == 0:
servo.sram.write_target_location(3000, reg=True)
else:
servo.sram.write_target_location(1000, reg=True)

print("Executing!")
controller.broadcast.action()
time.sleep(2)
print("Executing!")
controller.broadcast.action()
time.sleep(2)

for servo in servo_objects:
servo.sram.torque_disable()
for servo in servo_objects:
servo.sram.torque_disable()
finally:
controller.close()
1 change: 0 additions & 1 deletion examples/14_change_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import os
from python_st3215 import ST3215


controller = ST3215(os.environ.get("ST3215_PORT", "/dev/ttyUSB0"))

try:
Expand Down
1 change: 1 addition & 0 deletions examples/17_sync_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import os
import time

from python_st3215 import ST3215

controller = ST3215(os.environ.get("ST3215_PORT", "/dev/ttyUSB0"))
Expand Down
70 changes: 70 additions & 0 deletions examples/18_network_port.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
Scan for all servos on the bus and display their information.
Supports either a direct serial device (e.g. /dev/ttyUSB0) or a network
socket URL (e.g. socket://<IP>:<PORT>).

Env vars:
- ST3215_URL : full pyserial URL (takes precedence)
- ST3215_HOST : IP/hostname for socket connection (default: 100.122.96.71)
- ST3215_PORT : TCP port for socket connection (default: 2000)
- ST3215_DEV : fallback serial device (default: /dev/ttyUSB0)
Comment on lines +3 to +10
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring documents ST3215_HOST default as "100.122.96.71" and mentions an ST3215_DEV serial fallback, but the script defaults host to "st3215-host" and never uses ST3215_DEV (it always builds a socket:// URL when host/port defaults are present). Align the documented defaults with the code and either implement the ST3215_DEV fallback or remove it from the docstring.

Suggested change
Supports either a direct serial device (e.g. /dev/ttyUSB0) or a network
socket URL (e.g. socket://<IP>:<PORT>).
Env vars:
- ST3215_URL : full pyserial URL (takes precedence)
- ST3215_HOST : IP/hostname for socket connection (default: 100.122.96.71)
- ST3215_PORT : TCP port for socket connection (default: 2000)
- ST3215_DEV : fallback serial device (default: /dev/ttyUSB0)
Connect using either a full pyserial URL or a network socket URL
constructed from the host and port settings.
Env vars:
- ST3215_URL : full pyserial URL (takes precedence)
- ST3215_HOST : IP/hostname for socket connection (default: st3215-host)
- ST3215_PORT : TCP port for socket connection (default: 2000)

Copilot uses AI. Check for mistakes.

Run this on the host connected to the ST3215 driver board (make sure you have socat installed and set up udev rules for /dev/ttyACM0):
`stty -F /dev/ttyACM0 1000000 raw -echo && socat -d -d TCP4-LISTEN:2000,bind=0.0.0.0,reuseaddr,fork,nodelay FILE:/dev/ttyACM0,b1000000,raw,echo=0`
"""

import os
import sys

import serial

from python_st3215 import ST3215

url_env = os.environ.get("ST3215_URL")
host = os.environ.get("ST3215_HOST", "st3215-host")
port = os.environ.get("ST3215_PORT", "2000")

if url_env:
TARGET_URL = url_env
elif host and port:
TARGET_URL = f"socket://{host}:{port}"
else:
sys.exit(1)

print(f"Connecting to ST3215 via: {TARGET_URL}")

ser = serial.serial_for_url(TARGET_URL, timeout=0.02)
controller = ST3215(ser=ser, read_timeout=0.02)

try:
print("Scanning for servos...\n")
servos = controller.list_servos(timeout=0.02)

if not servos:
print("No servos found!")
else:
print(f"Found {len(servos)} servo(s)\n")
print("=" * 80)

mode_names = {0: "Position", 1: "Constant Speed", 2: "PWM", 3: "Stepper"}

for servo_id in servos:
servo = controller.wrap_servo(servo_id)

print(f"\nServo ID: {servo_id}")
print(
f" Firmware: v{servo.eeprom.read_firmware_major_version()}.{servo.eeprom.read_firmware_minor_version()}"
)
print(f" Position: {servo.sram.read_current_location()}")
print(f" Temperature: {servo.sram.read_current_temperature()}°C")
print(f" Voltage: {servo.sram.read_current_voltage() / 10:.1f}V")
print(
f" Min/Max Angle: {servo.eeprom.read_min_angle_limit()} / {servo.eeprom.read_max_angle_limit()}"
)

mode = servo.eeprom.read_operating_mode()
print(f" Operating Mode: {mode_names.get(mode, 'Unknown')}")

print("\n" + "=" * 80)
finally:
controller.close()
28 changes: 24 additions & 4 deletions src/python_st3215/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
from .st3215 import ST3215
from .errors import ST3215Error, ServoNotRespondingError, InvalidInstructionError
from .errors import (
BroadcastOperationError,
ChecksumError,
CommunicationTimeoutError,
InvalidIDError,
InvalidInstructionError,
InvalidParameterError,
ServoAngleLimitError,
ServoLockedError,
ServoNotRespondingError,
ServoStatusError,
ST3215Error,
)
from .servo import Servo
from .st3215 import ST3215

__version__ = "1.1.0"
__version__ = "1.2.0"

__all__ = [
"__version__",
"ST3215",
"ST3215Error",
"ServoNotRespondingError",
"InvalidInstructionError",
"ChecksumError",
"InvalidParameterError",
"ServoAngleLimitError",
"CommunicationTimeoutError",
"ServoLockedError",
"BroadcastOperationError",
"InvalidIDError",
"ServoStatusError",
"Servo",
"__version__",
]
50 changes: 16 additions & 34 deletions src/python_st3215/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,22 @@ def validate_servo_id(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
def wrapper(self: Any, servo_id: int, *args: Any, **kwargs: Any) -> Any:
if servo_id == 254:
from .errors import ST3215Error
from .errors import BroadcastOperationError

raise ST3215Error(f"Cannot {func.__name__} broadcast servo ID 254.")
return func(self, servo_id, *args, **kwargs)

return wrapper


def validate_broadcast_only(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Decorator to ensure operation is only used with broadcast servo (ID 254).
"""

@wraps(func)
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
if self.servo.id != 254:
from .errors import ST3215Error

raise ST3215Error(
f"{func.__name__} can only be used with broadcast servo (ID 254)."
raise BroadcastOperationError(
f"{func.__name__} cannot be used with broadcast ID 254."
)
return func(self, *args, **kwargs)
return func(self, servo_id, *args, **kwargs)

return wrapper


def validate_value_range(
min_val: int, max_val: int
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def validate_value_range(min_val: int, max_val: int) -> Callable[[F], F]:
"""
Decorator to validate that a value parameter is within the specified range.
"""

def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
def decorator(func: F) -> F:
@wraps(func)
def wrapper(self: Any, value: int, *args: Any, **kwargs: Any) -> Any:
if not (min_val <= value <= max_val):
Expand All @@ -54,27 +36,27 @@ def wrapper(self: Any, value: int, *args: Any, **kwargs: Any) -> Any:
)
return func(self, value, *args, **kwargs)

return wrapper
return wrapper # type: ignore[return-value]

return decorator


def encode_signed_word(value: int) -> tuple[int, int]:
"""
Encode a signed 16-bit value to low and high bytes.
Encoding uses most-significant bit as sign bit.

Args:
value: Signed integer (-32768 to 32767)
value: Signed integer (-32767 to 32767)

Returns:
Tuple of (low_byte, high_byte)
"""
if value < -32767 or value > 32767:
raise ValueError
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

encode_signed_word raises a bare ValueError with no message when out of range. Since the library now defines InvalidParameterError, consider raising that (or at least providing a descriptive ValueError message) so callers can diagnose invalid ranges more easily.

Suggested change
raise ValueError
from .errors import InvalidParameterError
raise InvalidParameterError(
f"encode_signed_word: value {value} out of range [-32767, 32767]"
)

Copilot uses AI. Check for mistakes.
low, high = abs(value) & 0xFF, (abs(value) >> 8) & 0x7F
if value < 0:
raw = 65536 + value
else:
raw = value
low = raw & 0xFF
high = (raw >> 8) & 0xFF
high |= 0x80
return low, high


Expand All @@ -83,13 +65,13 @@ def decode_signed_word(raw: int) -> int:
Decode a 16-bit raw value to signed integer.

Args:
raw: Raw 16-bit value (0-65535)
raw: Raw 16-bit value (0-65535) with dedicated sign bit.

Returns:
Signed integer (-32768 to 32767)
Signed integer (-32767 to 32767)
"""
if raw & 0x8000:
return raw - 65536
return -(raw & 0x7FFF)
return raw


Expand Down
Loading
Loading