From d371661bf979f6c6b32a8e0c01da76c3cc86ac86 Mon Sep 17 00:00:00 2001 From: Bee Date: Sun, 12 Jul 2026 08:36:23 -0700 Subject: [PATCH 1/2] WIP: updates to allow servo step control --- CLAUDE.md => AGENTS.md | 0 src/basic_bot/commons/constants.py | 17 +++++++ src/basic_bot/commons/servo_config.py | 4 ++ .../commons/servo_config_file_schema.py | 12 +++++ src/basic_bot/commons/servo_pca9685.py | 49 ++++++++++++++----- src/basic_bot/created_files/restart.sh | 5 ++ src/basic_bot/services/central_hub.py | 22 +++++++-- src/basic_bot/services/servo_control.py | 10 +++- src/basic_bot/services/web_server.py | 16 ++++-- tests/integration_tests/test_servo_control.py | 33 +++++++++++-- 10 files changed, 141 insertions(+), 27 deletions(-) rename CLAUDE.md => AGENTS.md (100%) diff --git a/CLAUDE.md b/AGENTS.md similarity index 100% rename from CLAUDE.md rename to AGENTS.md diff --git a/src/basic_bot/commons/constants.py b/src/basic_bot/commons/constants.py index 1d3e13d..ac98573 100644 --- a/src/basic_bot/commons/constants.py +++ b/src/basic_bot/commons/constants.py @@ -261,3 +261,20 @@ The path to the directory containing the web application's static files. The web server will serve files from this directory. """ + +BB_WEB_PORT = env.env_int("BB_WEB_PORT", 5080) +""" +The port that the web server listens on for incoming HTTP requests. This is the +port to use when serving the web application to users. Some Linux distributions +require root privileges to bind to ports below 1024. + +To bind to port 80 while running as a non-root user, you can use a reverse proxy +or port forwarding. From bash, you can use the following command to forward port 80. + +```bash +sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 5080 +# also redirect for localhost access +iptables -t nat -I OUTPUT -p tcp -d 127.0.0.1 --dport 80 -j REDIRECT --to-ports 5080 +``` + +""" diff --git a/src/basic_bot/commons/servo_config.py b/src/basic_bot/commons/servo_config.py index 4d4a3ee..1569a6a 100644 --- a/src/basic_bot/commons/servo_config.py +++ b/src/basic_bot/commons/servo_config.py @@ -59,6 +59,8 @@ class ServoOptions(TypedDict): max_angle: NotRequired[int] min_pulse: NotRequired[int] max_pulse: NotRequired[int] + step_delay: NotRequired[float] + step_degrees: NotRequired[float] @dataclass @@ -68,3 +70,5 @@ class ServoOptionsDefaults: max_angle: int = 180 min_pulse: int = 500 max_pulse: int = 2500 + step_delay: float = 0.0001 + step_degrees: float = 1.0 diff --git a/src/basic_bot/commons/servo_config_file_schema.py b/src/basic_bot/commons/servo_config_file_schema.py index c09dddd..ea28ca6 100644 --- a/src/basic_bot/commons/servo_config_file_schema.py +++ b/src/basic_bot/commons/servo_config_file_schema.py @@ -39,6 +39,18 @@ # The maximum pulse width in microseconds that the servo will accept # as specified by the manufacturer. "max_pulse": {"type": "integer"}, + # + # Delay between servo movement steps in seconds. + "step_delay": { + "type": "number", + "exclusiveMinimum": 0, + }, + # + # Number of degrees to move per servo step. + "step_degrees": { + "type": "number", + "exclusiveMinimum": 0, + }, }, }, }, diff --git a/src/basic_bot/commons/servo_pca9685.py b/src/basic_bot/commons/servo_pca9685.py index 7583ea0..0baf09a 100644 --- a/src/basic_bot/commons/servo_pca9685.py +++ b/src/basic_bot/commons/servo_pca9685.py @@ -40,11 +40,6 @@ def __init__(self, channel: int, min_pulse: int = 500, max_pulse: int = 2500) -> # env var to turn on console debug output DEBUG_MOTORS = env.env_bool("DEBUG_MOTORS", False) -# how many deg to turn per step (abs) -STEP_DEGREES = 1 -# how long to wait between steps (default; use step_delay setter to change at run time) -DEFAULT_STEP_DELAY = 0.0001 - # the precision of the servo motors in degrees SERVO_PRECISION = 0.5 @@ -82,24 +77,50 @@ def __init__( self.pause_event = threading.Event() self.stopped_event = threading.Event() self.force_stop = False - self._step_delay = DEFAULT_STEP_DELAY self.name: str = servo_options["name"] self.channel: int = servo_options["channel"] + motor_range = servo_options.get("motor_range") self.motor_range: int = ( - servo_options.get("motor_range") or ServoOptionsDefaults.motor_range + ServoOptionsDefaults.motor_range + if motor_range is None + else motor_range ) + min_pulse = servo_options.get("min_pulse") self.min_pulse: int = ( - servo_options.get("min_pulse") or ServoOptionsDefaults.min_pulse + ServoOptionsDefaults.min_pulse + if min_pulse is None + else min_pulse ) + max_pulse = servo_options.get("max_pulse") self.max_pulse: int = ( - servo_options.get("max_pulse") or ServoOptionsDefaults.max_pulse + ServoOptionsDefaults.max_pulse + if max_pulse is None + else max_pulse ) + min_angle = servo_options.get("min_angle") self.min_angle: int = ( - servo_options.get("min_angle") or ServoOptionsDefaults.min_angle + ServoOptionsDefaults.min_angle + if min_angle is None + else min_angle ) + max_angle = servo_options.get("max_angle") self.max_angle: int = ( - servo_options.get("max_angle") or ServoOptionsDefaults.max_angle + ServoOptionsDefaults.max_angle + if max_angle is None + else max_angle + ) + step_delay = servo_options.get("step_delay") + self._step_delay: float = ( + ServoOptionsDefaults.step_delay + if step_delay is None + else step_delay + ) + step_degrees = servo_options.get("step_degrees") + self.step_degrees: float = ( + ServoOptionsDefaults.step_degrees + if step_degrees is None + else step_degrees ) self.mid_angle: float = ( float(self.min_angle) + float(self.max_angle - self.min_angle) / 2 @@ -237,7 +258,7 @@ def _step_move(self, direction: int) -> bool: # and then stop the movement loop return False - new_angle = self.current_angle + (STEP_DEGREES * direction) + new_angle = self.current_angle + (self.step_degrees * direction) self.servo.fraction = new_angle / self.motor_range time.sleep(self._step_delay) @@ -251,7 +272,7 @@ def _step_move(self, direction: int) -> bool: def _step_would_overshoot_dest(self, direction: int) -> bool: current_angle = self.current_angle - new_angle = current_angle + STEP_DEGREES * direction + new_angle = current_angle + self.step_degrees * direction return (direction == -1 and new_angle < self.destination_angle) or ( direction == 1 and new_angle > self.destination_angle ) @@ -278,6 +299,8 @@ def _step_would_overshoot_dest(self, direction: int) -> bool: "max_angle": ServoOptionsDefaults.max_angle, "min_pulse": ServoOptionsDefaults.min_pulse, "max_pulse": ServoOptionsDefaults.max_pulse, + "step_delay": ServoOptionsDefaults.step_delay, + "step_degrees": ServoOptionsDefaults.step_degrees, } ) diff --git a/src/basic_bot/created_files/restart.sh b/src/basic_bot/created_files/restart.sh index 02b1f12..fe59f23 100755 --- a/src/basic_bot/created_files/restart.sh +++ b/src/basic_bot/created_files/restart.sh @@ -7,5 +7,10 @@ bb_stop $@ echo "Sleeping for 5 seconds" sleep 5 +if [ $# -eq 0 ]; then + bb_killall + sleep 2 +fi + echo "Starting services..." bb_start $@ diff --git a/src/basic_bot/services/central_hub.py b/src/basic_bot/services/central_hub.py index 25fad66..e53aa53 100644 --- a/src/basic_bot/services/central_hub.py +++ b/src/basic_bot/services/central_hub.py @@ -99,6 +99,7 @@ """ + import json import asyncio import websockets @@ -111,7 +112,6 @@ from basic_bot.commons.hub_state import HubState from basic_bot.commons.outbound_clients import OutboundClients - log.info("Initializing hub state") hub_state = HubState( { @@ -263,15 +263,29 @@ async def unregister(websocket: WebSocketServerProtocol) -> None: ) try: connected_sockets.remove(websocket) + except KeyError: + pass + + try: star_subscribers.remove(websocket) + except KeyError: + pass + try: for key in subscribers: - subscribers[key].remove(websocket) + try: + subscribers[key].remove(websocket) + except KeyError: + pass subsystem_name = identities.pop(websocket, None) + log.info(f"{subsystem_name}: subscribers after unregister: {subscribers}") if subsystem_name: await update_online_status(subsystem_name, 0) - except: - pass + except Exception as e: + error_string = traceback.format_exc() + log.error( + f"error removing websocket from subscribers: {e}; error message: {error_string}" + ) async def handle_state_request( diff --git a/src/basic_bot/services/servo_control.py b/src/basic_bot/services/servo_control.py index 3e6348d..5d3138a 100644 --- a/src/basic_bot/services/servo_control.py +++ b/src/basic_bot/services/servo_control.py @@ -15,6 +15,8 @@ max_angle: 180 min_pulse: 500 max_pulse: 2500 + step_delay: 0.0001 + step_degrees: 1.0 ``` If you update the `servo_config.yml` file, you will need to restart the service. @@ -29,6 +31,8 @@ be constrained. - `min_pulse` and `max_pulse` are the minimum and maximum pulse widths in microseconds that the servo will accept as specified by the manufacturer. + - `step_delay` is the delay in seconds between motor movement steps. + - `step_degrees` is the degrees moved per motor movement step. The service listens for messages on the central_hub key: "servo_angles". The message `data` should be a dictionary with keys that are the servo `names` @@ -65,7 +69,9 @@ "min_angle": 0, "max_angle": 180, "min_pulse": 500, - "max_pulse": 2500 + "max_pulse": 2500, + "step_delay": 0.0001, + "step_degrees": 1.0 } ] } @@ -118,6 +124,8 @@ async def send_servo_config(websocket: WebSocketClientProtocol) -> None: "max_angle": servo.max_angle, "min_pulse": servo.min_pulse, "max_pulse": servo.max_pulse, + "step_delay": servo.step_delay, + "step_degrees": servo.step_degrees, } for servo in servos_by_name.values() ] diff --git a/src/basic_bot/services/web_server.py b/src/basic_bot/services/web_server.py index 897de3a..820a451 100755 --- a/src/basic_bot/services/web_server.py +++ b/src/basic_bot/services/web_server.py @@ -1,7 +1,15 @@ #!/usr/bin/env python3 """ - Simple http server for serving the react web app from build dir +Simple http server for serving the react web app from its build dir. + +See also, + +[BB_WEB_PUBLIC constant](https://littlebee.github.io/basic_bot/Api%20Docs/commons/constants/#bb_web_public) +for default and control of the directory that is served by this web server. + +[BB_WEB_PORT constant](https://littlebee.github.io/basic_bot/Api%20Docs/commons/constants/#bb_web_port) +for default and control of the port that this web server listens on. """ @@ -32,7 +40,7 @@ @app.route("/stats") def send_stats() -> Response: - (cpu_temp, *rest) = [ + cpu_temp, *rest = [ int(i) / 1000 for i in os.popen("cat /sys/devices/virtual/thermal/thermal_zone*/temp") .read() @@ -82,7 +90,7 @@ def __init__(self) -> None: pass def thread(self) -> None: - app.run(host="0.0.0.0", port=80, threaded=True) + app.run(host="0.0.0.0", port=c.BB_WEB_PORT, threaded=True) def start_thread(self) -> None: thread = threading.Thread(target=self.thread) @@ -93,7 +101,7 @@ def start_thread(self) -> None: def start_app() -> None: logger = logging.getLogger(__name__) - logger.info(f"webapp started. serving {dir_path}") + logger.info(f"webapp started. serving {dir_path} on port {c.BB_WEB_PORT}") flask_app = webapp() flask_app.start_thread() diff --git a/tests/integration_tests/test_servo_control.py b/tests/integration_tests/test_servo_control.py index 67453b8..207b43e 100644 --- a/tests/integration_tests/test_servo_control.py +++ b/tests/integration_tests/test_servo_control.py @@ -1,4 +1,5 @@ import time +from typing import Any import basic_bot.test_helpers.central_hub as hub import basic_bot.test_helpers.start_stop as sst @@ -7,16 +8,38 @@ # See also the servo_config.yml file in the root project directory. + +def value_or_default(servo: dict[str, Any], key: str, default: Any) -> Any: + value = servo.get(key) + return default if value is None else value + + EXPECTED_SERVO_CONFIG = { "servos": [ { "name": servo["name"], "channel": servo["channel"], - "motor_range": servo.get("motor_range") or ServoOptionsDefaults.motor_range, - "min_angle": servo.get("min_angle") or ServoOptionsDefaults.min_angle, - "max_angle": servo.get("max_angle") or ServoOptionsDefaults.max_angle, - "min_pulse": servo.get("min_pulse") or ServoOptionsDefaults.min_pulse, - "max_pulse": servo.get("max_pulse") or ServoOptionsDefaults.max_pulse, + "motor_range": value_or_default( + servo, "motor_range", ServoOptionsDefaults.motor_range + ), + "min_angle": value_or_default( + servo, "min_angle", ServoOptionsDefaults.min_angle + ), + "max_angle": value_or_default( + servo, "max_angle", ServoOptionsDefaults.max_angle + ), + "min_pulse": value_or_default( + servo, "min_pulse", ServoOptionsDefaults.min_pulse + ), + "max_pulse": value_or_default( + servo, "max_pulse", ServoOptionsDefaults.max_pulse + ), + "step_delay": value_or_default( + servo, "step_delay", ServoOptionsDefaults.step_delay + ), + "step_degrees": value_or_default( + servo, "step_degrees", ServoOptionsDefaults.step_degrees + ), } for servo in read_servo_config()["servos"] ] From cf33fa9f0c62ba154dfca8793fee9bf09100b933 Mon Sep 17 00:00:00 2001 From: Bee Date: Tue, 14 Jul 2026 08:08:16 -0700 Subject: [PATCH 2/2] more debug logging --- src/basic_bot/commons/hub_state_monitor.py | 3 ++- src/basic_bot/commons/vid_utils.py | 10 +++++++--- src/basic_bot/commons/vision_client.py | 13 +++++++++++++ src/basic_bot/commons/webrtc_server.py | 8 ++++++-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/basic_bot/commons/hub_state_monitor.py b/src/basic_bot/commons/hub_state_monitor.py index d5166ef..9f33121 100644 --- a/src/basic_bot/commons/hub_state_monitor.py +++ b/src/basic_bot/commons/hub_state_monitor.py @@ -11,7 +11,6 @@ from basic_bot.commons import constants as c, messages, log from basic_bot.commons.hub_state import HubState - # TODO: This class should maybe be a singleton. should_exit = False @@ -134,6 +133,8 @@ async def parse_next_message( if should_exit: return + log.info("hub_state_monitor websocket closed by central_hub") + async def monitor_state(self) -> None: while not should_exit: try: diff --git a/src/basic_bot/commons/vid_utils.py b/src/basic_bot/commons/vid_utils.py index b1944d6..32fb39d 100644 --- a/src/basic_bot/commons/vid_utils.py +++ b/src/basic_bot/commons/vid_utils.py @@ -155,18 +155,20 @@ def on_track_ended(): log.debug(f"{track.kind} track ended") try: - # Add transceivers for receiving video and audio + log.debug("Creating transceivers for video and audio") pc.addTransceiver("video", direction="recvonly") pc.addTransceiver("audio", direction="recvonly") - # Create offer + log.debug("Creating offer and setting local description") offer = await pc.createOffer() await pc.setLocalDescription(offer) + log.debug("Waiting for ICE gathering to complete") # Wait for ICE gathering to complete while pc.iceGatheringState != "complete": await asyncio.sleep(0.1) + log.debug("ICE gathering complete, sending offer to WebRTC endpoint") # Send offer to server async with aiohttp.ClientSession() as session: offer_data = { @@ -175,15 +177,17 @@ def on_track_ended(): } headers = {"Content-Type": "application/json"} + log.debug(f"Sending offer data: {offer_data} to {webrtc_endpoint}") async with session.post( webrtc_endpoint, json=offer_data, headers=headers ) as response: + log.debug("Answer received from WebRTC endpoint") if response.status != 200: raise RuntimeError( f"Failed to send offer to WebRTC endpoint: {response.status}" ) - answer_data = await response.json() + log.debug(f"Answer data: {answer_data}") # Set remote description answer = RTCSessionDescription( diff --git a/src/basic_bot/commons/vision_client.py b/src/basic_bot/commons/vision_client.py index e2d2c4b..c98d972 100644 --- a/src/basic_bot/commons/vision_client.py +++ b/src/basic_bot/commons/vision_client.py @@ -21,6 +21,19 @@ def send_record_video_request(duration: float) -> requests.Response: return response +def send_record_video_async(duration: float): + """ + Send a request to the vision service to start recording video asynchronously. + """ + if c.BB_LOG_ALL_MESSAGES: + log.info(f"Sending record video request for {duration} seconds") + requests.get( + f"{c.BB_VISION_URI}/record_video", + params={"duration": duration}, + timeout=0.001, + ) + + def fetch_recorded_videos() -> requests.Response: """ Send a request to the vision service to retrieve a list of recorded videos. diff --git a/src/basic_bot/commons/webrtc_server.py b/src/basic_bot/commons/webrtc_server.py index bcf6382..269e66b 100644 --- a/src/basic_bot/commons/webrtc_server.py +++ b/src/basic_bot/commons/webrtc_server.py @@ -94,6 +94,9 @@ def _initialize_audio(self) -> Optional[MediaStreamTrack]: stderr=subprocess.DEVNULL, bufsize=0, # Unbuffered stdout ) + log.debug( + "arecord process started for audio capture. Initializing microphone." + ) self.microphone = MediaPlayer( self.arecord_process.stdout, format="s16le", @@ -115,7 +118,7 @@ def _initialize_audio(self) -> Optional[MediaStreamTrack]: # Linux with PulseAudio self.microphone = MediaPlayer("default", format="pulse") - # Initialize audio relay for sharing between multiple peers + log.debug("Initializing MediaRelay for audio streaming") self.audio_relay = MediaRelay() log.info("Audio streaming initialized successfully") return ( @@ -199,10 +202,11 @@ async def on_connectionstatechange() -> None: # handle offer await pc.setRemoteDescription(offer) - # send answer + log.debug("Creating answer and setting local description") answer = await pc.createAnswer() await pc.setLocalDescription(answer) + log.debug(f"Sending answer to {request.remote} with client_id={client_id}") return web.Response( content_type="application/json", text=json.dumps(