Update 2026-04-21 16:27:07

This commit is contained in:
kassam 2026-04-21 16:27:11 +04:00
parent e0f6acd5c7
commit af1d0c1b8a
9 changed files with 79 additions and 63 deletions

View File

@ -28,22 +28,27 @@ import json
import logging
import os
import subprocess
import sys
import threading
import time
import wave
import numpy as np
# ─── PATH CONFIG ─────────────────────────────────────────
from dotenv import load_dotenv
load_dotenv()
BASE_PATH = os.environ.get("PROJECT_BASE", "/home/unitree")
PROJECT_NAME = "Marcus"
PROJECT_ROOT = os.path.join(BASE_PATH, PROJECT_NAME)
# ─── PATH + CONFIG ───────────────────────────────────────
# Use the canonical loaders from Core/ so path + config logic lives in one place.
_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_DIR not in sys.path:
sys.path.insert(0, _PROJECT_DIR)
from Core.env_loader import PROJECT_ROOT
from Core.config_loader import load_config
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
os.makedirs(LOG_DIR, exist_ok=True)
# Note: logging.basicConfig() only takes effect on the first call per process.
# If the voice module already configured logging (common path via run_marcus.py),
# this call is a no-op. When audio_api is used standalone, it wires logs to
# logs/voice.log + stderr.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
@ -55,12 +60,6 @@ logging.basicConfig(
log = logging.getLogger("audio_api")
def load_config(name: str) -> dict:
path = os.path.join(PROJECT_ROOT, "Config", f"config_{name}.json")
with open(path, "r") as f:
return json.load(f)
# ─── AUDIO API CLASS ─────────────────────────────────────
class AudioAPI:
@ -135,11 +134,12 @@ class AudioAPI:
Speak `text` in English through the G1 built-in TTS (TtsMaker).
Mutes (flushes) the mic during playback so the voice loop doesn't
hear the robot's own voice and transcribe itself. The `lang`
argument is accepted for API compatibility but only "en" plays
non-ASCII text (Arabic) is rejected by BuiltinTTS.
hear the robot's own voice and transcribe itself. `lang` is kept
in the signature for API compatibility but only `"en"` is accepted
non-ASCII text (Arabic) is rejected by BuiltinTTS because the
G1 firmware silently maps it to Chinese, which nobody wants.
"""
if lang and lang not in ("en", "auto"):
if lang and lang != "en":
log.warning("builtin_tts only supports English; got lang=%r — skipping", lang)
return
if self._tts_engine is None:

View File

@ -31,7 +31,7 @@ Files saved
summary.txt auto-generated LLaVA summary
frames/ camera captures at interesting points
Import in marcus_llava.py
Import in marcus_brain.py
-------------------------
from marcus_autonomous import AutonomousMode
auto = AutonomousMode(
@ -303,17 +303,20 @@ class AutonomousMode:
def _move_forward(self) -> bool:
"""
Move forward for FORWARD_DURATION seconds.
Returns True if blocked (no actual movement detected).
Returns True if blocked. Obstacle detection is not wired in here
LiDAR runs at the brain layer (API.lidar_api.obstacle_ahead) and
isn't passed into AutonomousMode today. Extend __init__ with an
optional `obstacle_fn=` hook if you want this loop to stop on
obstacles detected outside YOLO's view.
"""
# TODO: integrate LiDAR when available for true obstacle detection
# For now: move and assume not blocked
t0 = time.time()
while time.time() - t0 < FORWARD_DURATION and self._enabled:
self._send_vel(vx=0.25)
time.sleep(0.05)
self._send_vel(0, 0, 0)
time.sleep(0.1)
return False # Not blocked — no LiDAR yet
return False
def _turn(self, direction: str, duration: float):
"""Turn left or right for given duration."""

View File

@ -501,14 +501,18 @@ def run_terminal():
continue
if cmd.lower() in ("q", "quit", "exit"):
break
if cmd.lower() == "mute/":
import subprocess
subprocess.run(["pactl", "set-source-mute", "0", "1"], capture_output=True)
print(" Mic muted")
if cmd.lower() in ("mute/", "unmute/"):
# Route through the audio API so the action respects whichever
# mic backend is active (BuiltinMic flushes the UDP buffer;
# the legacy pactl path mutes PulseAudio source 3).
if _audio_api is None:
print(" Voice is not initialized")
continue
if cmd.lower() == "unmute/":
import subprocess
subprocess.run(["pactl", "set-source-mute", "0", "0"], capture_output=True)
if cmd.lower() == "mute/":
_audio_api._mute_mic()
print(" Mic muted")
else:
_audio_api._unmute_mic()
print(" Mic unmuted")
continue
result = process_command(cmd)

View File

@ -19,7 +19,7 @@ Folder structure
alerts.json [{time, type, detail}]
summary.txt auto-generated session summary
Import in marcus_llava.py
Import in marcus_brain.py
-------------------------
from marcus_memory import Memory
mem = Memory() call once at startup

View File

@ -14,8 +14,20 @@ import io
import numpy as np
import websockets
JETSON_IP = "192.168.123.164"
JETSON_PORT = 8765
# Load network defaults from Config/config_Network.json. Falls back to sane
# hardcoded values if the config isn't reachable (the GUI can run standalone
# from a workstation that isn't on the same filesystem as the project).
_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_DIR not in sys.path:
sys.path.insert(0, _PROJECT_DIR)
try:
from Core.config_loader import load_config
_net = load_config("Network")
JETSON_IP = _net.get("jetson_ip", "192.168.123.164")
JETSON_PORT = _net.get("websocket_port", 8765)
except Exception:
JETSON_IP = "192.168.123.164"
JETSON_PORT = 8765
# ─── SLAM (runs locally on workstation) ───────────────────────────────────────
SLAM_DIR = os.path.expanduser("~/Robotics_workspace/yslootahtech/G1_Lootah/Lidar")
@ -1000,7 +1012,10 @@ class MarcusGUI:
p2 = ImageTk.PhotoImage(img_resized)
self.cam_preview.configure(image=p2, text="")
self.cam_preview.image = p2
except Exception: pass
except Exception as e:
# Malformed base64 / torn frame / Tk disposed during shutdown —
# log it so UI freezes become debuggable rather than silent.
print(f" [Client] _update_frame dropped: {type(e).__name__}: {e}")
def _update_cam_config(self, d):
p, w, h, f = d.get("profile","?"), d.get("width","?"), d.get("height","?"), d.get("fps","?")

View File

@ -9,7 +9,7 @@ Two sources (priority order):
1. ROS2 /dog_odom joint encoder data ±2cm accuracy
2. Dead reckoning velocity × time integration ±10cm accuracy
Import in marcus_llava.py
Import in marcus_brain.py
--------------------------
from marcus_odometry import Odometry
odom = Odometry()
@ -78,7 +78,7 @@ class Odometry:
def __init__(self):
self._lock = threading.Lock()
self._alive = [False]
self._sock = None # ZMQ PUB socket (shared from marcus_llava)
self._sock = None # ZMQ PUB socket (shared from marcus_brain)
# Position state
self._x = 0.0 # meters from start
@ -105,7 +105,7 @@ class Odometry:
Start odometry tracking in background thread.
Args:
zmq_sock : existing ZMQ PUB socket from marcus_llava.py.
zmq_sock : existing ZMQ PUB socket from marcus_brain.py.
If None, creates its own socket on ZMQ_PORT.
Passing the shared socket avoids port conflicts.
@ -169,7 +169,7 @@ class Odometry:
except zmq.ZMQError as e:
if "Address already in use" in str(e):
print(f" [Odom] ⚠️ ZMQ port {ZMQ_PORT} already in use.")
print(" [Odom] Pass zmq_sock=sock from marcus_llava.py instead.")
print(" [Odom] Pass zmq_sock=sock from marcus_brain.py instead.")
else:
print(f" [Odom] ⚠️ ZMQ error: {e}")
self._sock = None

View File

@ -15,7 +15,7 @@ How it works
5. Optional: YOLO pre-filter speeds up search (find person class first,
then LLaVA verifies it's the right person)
Usage in marcus_llava.py
Usage in marcus_brain.py
------------------------
from marcus_imgsearch import ImageSearch
searcher = ImageSearch(get_frame_fn=get_frame, send_vel_fn=send_vel,
@ -420,11 +420,11 @@ class ImageSearch:
# ══════════════════════════════════════════════════════════════════════════════
# WIRE INTO marcus_llava.py — add to main loop
# WIRE INTO marcus_brain.py — add to main loop
# ══════════════════════════════════════════════════════════════════════════════
"""
Add to marcus_llava.py imports:
Add to marcus_brain.py imports:
from marcus_imgsearch import ImageSearch
Add after Memory init:
@ -504,7 +504,7 @@ if __name__ == "__main__":
elif args.image or args.hint:
# Real search — needs robot hardware
print("Real search requires robot hardware.")
print("Import ImageSearch into marcus_llava.py instead.")
print("Import ImageSearch into marcus_brain.py instead.")
print(f" image: {args.image}")
print(f" hint: {args.hint}")

View File

@ -3,7 +3,7 @@ marcus_yolo.py — Marcus Vision Module
=======================================
Project : Marcus | YS Lootah Technology
Purpose : YOLO-based person + object detection
Import this module in marcus_llava.py runs as background thread
Import this module in marcus_brain.py runs as background thread
Usage (imported):
from marcus_yolo import start_yolo, yolo_sees, yolo_count, yolo_closest, yolo_summary
@ -365,20 +365,20 @@ def _camera_loop(raw_frame_ref, frame_lock, cam_alive):
time.sleep(2.0)
# ── Start function — called by marcus_llava.py ────────────────────────────────
# ── Start function — called by marcus_brain.py ────────────────────────────────
def start_yolo(raw_frame_ref=None, frame_lock=None):
"""
Start YOLO inference in background thread.
Called automatically by marcus_llava.py during startup.
Shares the camera frame reference from marcus_llava's camera thread.
Called automatically by marcus_brain.py during startup.
Shares the camera frame reference from marcus_brain's camera thread.
Args:
raw_frame_ref : list[np.ndarray|None] shared raw BGR frame
frame_lock : threading.Lock protecting raw_frame_ref
Example (in marcus_llava.py):
Example (in marcus_brain.py):
from marcus_yolo import start_yolo, yolo_sees, yolo_summary
start_yolo(raw_frame_ref=_raw_frame, frame_lock=_raw_lock)
"""

View File

@ -22,23 +22,23 @@ Usage:
import logging
import os
import sys
import threading
import time
import numpy as np
# ─── PATH CONFIG ─────────────────────────────────────────
from dotenv import load_dotenv
load_dotenv()
BASE_PATH = os.environ.get("PROJECT_BASE", "/home/unitree")
PROJECT_NAME = "Marcus"
PROJECT_ROOT = os.path.join(BASE_PATH, PROJECT_NAME)
import json
# ─── PATH + CONFIG ───────────────────────────────────────
# Single source of truth lives in Core/; everyone else imports from there.
_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_DIR not in sys.path:
sys.path.insert(0, _PROJECT_DIR)
from Core.env_loader import PROJECT_ROOT
from Core.config_loader import load_config
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
os.makedirs(LOG_DIR, exist_ok=True)
# Idempotent — only the first call per process installs handlers.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
@ -50,12 +50,6 @@ logging.basicConfig(
log = logging.getLogger("marcus_voice")
def load_config(name: str) -> dict:
path = os.path.join(PROJECT_ROOT, "Config", f"config_{name}.json")
with open(path, "r") as f:
return json.load(f)
# ─── STATE ENUM ──────────────────────────────────────────
class State: