1481 lines
61 KiB
Python
1481 lines
61 KiB
Python
#!/usr/bin/env python3
|
|
import sys
|
|
import os
|
|
import json
|
|
import socket
|
|
import time
|
|
import threading
|
|
import math
|
|
import subprocess
|
|
import tempfile
|
|
import struct
|
|
import base64
|
|
import argparse
|
|
import urllib.request
|
|
import http.server
|
|
import re
|
|
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
|
|
# Try importing pygame, but allow headless run if missing
|
|
try:
|
|
import pygame
|
|
PYGAME_AVAILABLE = True
|
|
except ImportError:
|
|
PYGAME_AVAILABLE = False
|
|
|
|
# Port Configuration Defaults
|
|
HTTP_PORT = 8080
|
|
TCP_STREAM_PORT = 8081
|
|
UDP_STREAM_PORT = 8082
|
|
UDP_DISCOVERY_PORT = 5000
|
|
|
|
# Constants for Reflective LCD (RLCD) Palette
|
|
COLOR_BG_RGB = (180, 195, 174) # Reflective LCD light green-grey
|
|
COLOR_FG_RGB = (26, 36, 22) # Dark green-black ink
|
|
COLOR_FRAME_RGB = (15, 23, 42) # Dark bezel
|
|
COLOR_WINDOW_RGB = (220, 220, 220) # Light grey window background
|
|
|
|
class NativeCompanionClient:
|
|
def __init__(self, width=480, height=320, http_port=HTTP_PORT):
|
|
self.width = width
|
|
self.height = height
|
|
self.http_port = http_port
|
|
|
|
# Thread lock for canvas buffer safety
|
|
self.canvas_lock = threading.Lock()
|
|
|
|
# Visual Buffer (1-bit monochrome PIL image: 0=bg/white, 1=fg/black)
|
|
self.canvas_img = Image.new("1", (self.width, self.height), 0)
|
|
self.draw = ImageDraw.Draw(self.canvas_img)
|
|
|
|
# Video stream and performance stats
|
|
self.fps_frame_count = 0
|
|
self.fps_start_time = time.time()
|
|
self.current_fps = 0.0
|
|
self.tcp_bytes_received = 0
|
|
self.udp_packets_received = 0
|
|
self.udp_frames_complete = 0
|
|
self.dropped_udp_frames = 0
|
|
self.is_streaming_active = False
|
|
self.last_stream_packet_time = 0
|
|
|
|
# LED state
|
|
self.led_r = 0
|
|
self.led_g = 0
|
|
self.led_b = 0
|
|
self.led_mode = "off" # static, breath, rainbow, off
|
|
|
|
# Audio / recording state
|
|
self.audio_status = "Idle"
|
|
self.mic_status = "Idle"
|
|
|
|
# Start Time for Uptime
|
|
self.start_time = time.time()
|
|
self.override_active = False
|
|
|
|
# Native stdout handler for stdio MCP
|
|
self.mcp_stdout = sys.stdout
|
|
# Redirect default prints to stderr so they don't break JSON-RPC over stdout
|
|
sys.stdout = sys.stderr
|
|
|
|
def get_screenshot_path(self, filename):
|
|
"""Returns a writeable path for saving screenshots, falling back to local folder if needed."""
|
|
mac_path = "/Users/adolforeyna/.gemini/antigravity/brain/4acf0d08-1256-4c7a-a2e4-3e8576b58c73"
|
|
if os.path.exists(mac_path):
|
|
return os.path.join(mac_path, filename)
|
|
# Fallback for Linux / Pi 5
|
|
local_path = os.path.join(os.path.expanduser("~"), "Projects", "Screen")
|
|
if os.path.exists(local_path):
|
|
return os.path.join(local_path, filename)
|
|
return os.path.join(os.getcwd(), filename)
|
|
|
|
def bring_to_front(self):
|
|
"""Focuses/raises the Pygame window on macOS using System Events."""
|
|
if sys.platform == "darwin":
|
|
try:
|
|
cmd = """osascript -e 'tell application "System Events" to set frontmost of every process whose name is "Python" to true'"""
|
|
subprocess.Popen(cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except Exception:
|
|
pass
|
|
|
|
def draw_local_dashboard_canvas(self, battery_text, temp_text, uptime_text):
|
|
"""Draws the companion dashboard onto the 1-bit PIL canvas image."""
|
|
self.draw.rectangle([0, 0, self.width, self.height], fill=0)
|
|
|
|
font_large = ImageFont.load_default()
|
|
font_normal = ImageFont.load_default()
|
|
try:
|
|
# Monospace look
|
|
font_large = ImageFont.truetype("Courier New", 18)
|
|
font_normal = ImageFont.truetype("Courier New", 12)
|
|
except:
|
|
pass
|
|
|
|
# Draw header
|
|
self.draw.text((15, 10), "DESKTOP MCP COMPANION", fill=1, font=font_large)
|
|
self.draw.line((10, 32, 470, 32), fill=1)
|
|
|
|
# Sections
|
|
self.draw.text((15, 45), "HOST TELEMETRY", fill=1, font=font_large)
|
|
t_val = temp_text.split(": ")[1] if ": " in temp_text else temp_text
|
|
self.draw.text((25, 70), f"CPU Temp : {t_val}", fill=1, font=font_normal)
|
|
u_val = uptime_text.split(": ")[1] if ": " in uptime_text else uptime_text
|
|
self.draw.text((25, 85), f"Uptime : {u_val}", fill=1, font=font_normal)
|
|
|
|
self.draw.line((10, 110, 470, 110), fill=1)
|
|
|
|
self.draw.text((15, 120), "MCP ENDPOINTS", fill=1, font=font_large)
|
|
local_ip = self.get_local_ip()
|
|
self.draw.text((25, 145), f"IP Address : {local_ip}", fill=1, font=font_normal)
|
|
self.draw.text((25, 160), f"HTTP API : {self.http_port} /api/mcp", fill=1, font=font_normal)
|
|
self.draw.text((25, 175), f"Video Ports: 8081 (TCP) / 8082 (UDP)", fill=1, font=font_normal)
|
|
|
|
self.draw.line((10, 200, 470, 200), fill=1)
|
|
|
|
self.draw.text((15, 210), "SYSTEM STATUS", fill=1, font=font_large)
|
|
b_val = battery_text.split(": ")[1] if ": " in battery_text else battery_text
|
|
self.draw.text((25, 235), f"Battery : {b_val}", fill=1, font=font_normal)
|
|
time_str = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
self.draw.text((25, 250), f"Local Time : {time_str}", fill=1, font=font_normal)
|
|
|
|
self.draw.line((10, 275, 470, 275), fill=1)
|
|
|
|
status_msg = f"Speaker: {self.audio_status} | Mic: {self.mic_status}"
|
|
self.draw.text((15, 290), f"Status : {status_msg}", fill=1, font=font_normal)
|
|
|
|
def run_pygame_loop(self):
|
|
"""Starts and runs the Pygame GUI main thread loop."""
|
|
pygame.init()
|
|
pygame.font.init()
|
|
|
|
# Display resolution (960x640 canvas with bezel = 1000x680 window)
|
|
screen_w = 1000
|
|
screen_h = 680
|
|
|
|
# Start hidden initially as requested
|
|
self.window_visible = False
|
|
self.show_window_requested = False
|
|
screen = pygame.display.set_mode((screen_w, screen_h), pygame.HIDDEN)
|
|
pygame.display.set_caption("Native Agent Companion Client")
|
|
|
|
# Use default system font or built-in font
|
|
font = pygame.font.Font(None, 22)
|
|
title_font = pygame.font.Font(None, 28)
|
|
|
|
clock = pygame.time.Clock()
|
|
|
|
# Internal stats query timers
|
|
last_telemetry_time = 0
|
|
battery_text = "Battery: Querying..."
|
|
temp_text = "CPU Temperature: Querying..."
|
|
uptime_text = "Uptime: 00:00:00"
|
|
volts = 4.20
|
|
pct = 100
|
|
temp = 38.5
|
|
|
|
auto_screenshot_done = False
|
|
running = True
|
|
while running:
|
|
# Check if a message was received and we need to make the window visible
|
|
if getattr(self, "show_window_requested", False):
|
|
self.show_window_requested = False
|
|
if not self.window_visible:
|
|
# Switch display to SHOWN mode
|
|
screen = pygame.display.set_mode((screen_w, screen_h), pygame.SHOWN)
|
|
self.window_visible = True
|
|
self.bring_to_front()
|
|
|
|
# 1. Process Pygame Window Events
|
|
for event in pygame.event.get():
|
|
if event.type == pygame.QUIT:
|
|
running = False
|
|
elif event.type == pygame.KEYDOWN:
|
|
if event.key == pygame.K_d:
|
|
self.override_active = False
|
|
# Force immediately
|
|
uptime_sec = int(time.time() - self.start_time)
|
|
hrs = uptime_sec // 3600
|
|
mins = (uptime_sec % 3600) // 60
|
|
secs = uptime_sec % 60
|
|
uptime_text = f"Uptime: {hrs:02d}:{mins:02d}:{secs:02d}"
|
|
volts, pct = self.get_host_battery()
|
|
battery_text = f"Battery: {volts:.2f}V ({pct}%)"
|
|
temp = self.get_host_cpu_temp()
|
|
temp_text = f"CPU Temperature: {temp:.1f}°C"
|
|
with self.canvas_lock:
|
|
self.draw_local_dashboard_canvas(battery_text, temp_text, uptime_text)
|
|
elif event.key == pygame.K_s:
|
|
try:
|
|
save_path = self.get_screenshot_path("pygame_screen.png")
|
|
pygame.image.save(screen, save_path)
|
|
sys.stderr.write(f"Manual screen capture saved to {save_path}.\n")
|
|
sys.stderr.flush()
|
|
except Exception as ex:
|
|
sys.stderr.write(f"Manual screen capture failed: {ex}\n")
|
|
sys.stderr.flush()
|
|
|
|
now = time.time()
|
|
|
|
# 2. Update telemetry stats once per second
|
|
if now - last_telemetry_time >= 1.0:
|
|
last_telemetry_time = now
|
|
uptime_sec = int(now - self.start_time)
|
|
hrs = uptime_sec // 3600
|
|
mins = (uptime_sec % 3600) // 60
|
|
secs = uptime_sec % 60
|
|
uptime_text = f"Uptime: {hrs:02d}:{mins:02d}:{secs:02d}"
|
|
|
|
volts, pct = self.get_host_battery()
|
|
battery_text = f"Battery: {volts:.2f}V ({pct}%)"
|
|
|
|
temp = self.get_host_cpu_temp()
|
|
temp_text = f"CPU Temperature: {temp:.1f}°C"
|
|
|
|
# Check video streaming inactivity
|
|
if self.is_streaming_active and (now - self.last_stream_packet_time) > 3.0:
|
|
self.is_streaming_active = False
|
|
self.current_fps = 0.0
|
|
|
|
# Update dashboard on canvas if not overridden
|
|
if not self.override_active and not self.is_streaming_active:
|
|
with self.canvas_lock:
|
|
self.draw_local_dashboard_canvas(battery_text, temp_text, uptime_text)
|
|
|
|
# 3. Clear window with dark background
|
|
screen.fill(COLOR_WINDOW_RGB)
|
|
|
|
# 4. Draw Reflective LCD Screen Bezel Frame
|
|
# Left side bezel box (width 964, height 644) at (15, 15)
|
|
pygame.draw.rect(screen, COLOR_FRAME_RGB, (15, 15, 970, 650), border_radius=12)
|
|
|
|
# 5. Render LCD Canvas Buffer
|
|
try:
|
|
# Thread-safe copy of canvas pixels using fast colorization
|
|
with self.canvas_lock:
|
|
gray_img = self.canvas_img.convert("L")
|
|
colored_img = ImageOps.colorize(gray_img, COLOR_BG_RGB, COLOR_FG_RGB)
|
|
|
|
# Scale up to 2x (960x640)
|
|
scaled_img = colored_img.resize((960, 640), Image.NEAREST)
|
|
img_bytes = scaled_img.tobytes("raw", "RGB")
|
|
|
|
# Blit to screen
|
|
lcd_surface = pygame.image.frombytes(img_bytes, (960, 640), "RGB")
|
|
screen.blit(lcd_surface, (20, 20))
|
|
except Exception as e:
|
|
# Print rendering failures to stderr
|
|
sys.stderr.write(f"GUI Canvas Render Fail: {e}\n")
|
|
sys.stderr.flush()
|
|
|
|
# Auto screenshot at 3 seconds after window becomes visible
|
|
if self.window_visible and not auto_screenshot_done:
|
|
if not hasattr(self, "visible_start_time"):
|
|
self.visible_start_time = time.time()
|
|
elif time.time() - self.visible_start_time >= 3.0:
|
|
try:
|
|
save_path = self.get_screenshot_path("pygame_screen_auto.png")
|
|
pygame.image.save(screen, save_path)
|
|
sys.stderr.write(f"Auto screen capture saved to {save_path}.\n")
|
|
sys.stderr.flush()
|
|
except Exception as ex:
|
|
sys.stderr.write(f"Auto screen capture failed: {ex}\n")
|
|
sys.stderr.flush()
|
|
auto_screenshot_done = True
|
|
|
|
# 6. Draw LED Indicator Ring (on the window border)
|
|
led_color = (71, 85, 105) # Off (dark grey)
|
|
if self.led_mode == "static":
|
|
led_color = (self.led_r, self.led_g, self.led_b)
|
|
elif self.led_mode == "breath":
|
|
brightness = 0.25 + 0.75 * (math.sin(now * 4.0) + 1.0) / 2.0
|
|
led_color = (int(self.led_r * brightness), int(self.led_g * brightness), int(self.led_b * brightness))
|
|
elif self.led_mode == "rainbow":
|
|
hue = (now * 0.5) % 1.0
|
|
led_color = self.hsl_to_rgb(hue, 1.0, 0.5)
|
|
|
|
# Draw tiny status LED on the window frame border
|
|
pygame.draw.circle(screen, led_color, (990, 30), 5)
|
|
|
|
# 8. Render updates
|
|
pygame.display.flip()
|
|
clock.tick(30) # Lock to 30 FPS for rendering updates
|
|
|
|
pygame.quit()
|
|
os._exit(0)
|
|
|
|
def hsl_to_rgb(self, h, s, l):
|
|
def hue_2_rgb(p, q, t):
|
|
if t < 0: t += 1
|
|
if t > 1: t -= 1
|
|
if t < 1/6: return p + (q - p) * 6 * t
|
|
if t < 1/2: return q
|
|
if t < 2/3: return p + (q - p) * (2/3 - t) * 6
|
|
return p
|
|
if s == 0:
|
|
r = g = b = l
|
|
else:
|
|
q = l * (1 + s) if l < 0.5 else l + s - l * s
|
|
p = 2 * l - q
|
|
r = hue_2_rgb(p, q, h + 1/3)
|
|
g = hue_2_rgb(p, q, h)
|
|
b = hue_2_rgb(p, q, h - 1/3)
|
|
return int(r * 255), int(g * 255), int(b * 255)
|
|
|
|
# --- HOST HARDWARE QUERIES ---
|
|
|
|
def get_host_battery(self):
|
|
"""Native battery status extraction for macOS, Linux, and Windows."""
|
|
if sys.platform == "darwin": # macOS
|
|
try:
|
|
output = subprocess.check_output(["pmset", "-g", "batt"]).decode("utf-8")
|
|
pct_match = re.search(r"(\d+)%", output)
|
|
pct = int(pct_match.group(1)) if pct_match else 100
|
|
volts = round(3.5 + (pct / 100.0) * 0.7, 3)
|
|
return volts, pct
|
|
except Exception:
|
|
pass
|
|
elif sys.platform.startswith("linux"): # Linux
|
|
try:
|
|
pct = 100
|
|
# Check typical battery path
|
|
cap_path = "/sys/class/power_supply/BAT0/capacity"
|
|
if not os.path.exists(cap_path):
|
|
cap_path = "/sys/class/power_supply/BAT1/capacity"
|
|
if os.path.exists(cap_path):
|
|
with open(cap_path, "r") as f:
|
|
pct = int(f.read().strip())
|
|
|
|
vol_path = "/sys/class/power_supply/BAT0/voltage_now"
|
|
if not os.path.exists(vol_path):
|
|
vol_path = "/sys/class/power_supply/BAT1/voltage_now"
|
|
volts = None
|
|
if os.path.exists(vol_path):
|
|
with open(vol_path, "r") as f:
|
|
volts = int(f.read().strip()) / 1_000_000.0 # microvolts to volts
|
|
if volts is None:
|
|
volts = round(3.5 + (pct / 100.0) * 0.7, 3)
|
|
return volts, pct
|
|
except Exception:
|
|
pass
|
|
elif sys.platform == "win32": # Windows
|
|
try:
|
|
output = subprocess.check_output("wmic Path Win32_Battery Get EstimatedChargeRemaining", shell=True).decode("utf-8")
|
|
pct = 100
|
|
numbers = re.findall(r"\d+", output)
|
|
if numbers:
|
|
pct = int(numbers[0])
|
|
volts = round(3.5 + (pct / 100.0) * 0.7, 3)
|
|
return volts, pct
|
|
except Exception:
|
|
pass
|
|
# Fallback
|
|
return 4.20, 100
|
|
|
|
def get_host_cpu_temp(self):
|
|
"""Dynamic temperature estimate based on thermal zone or CPU load."""
|
|
if sys.platform.startswith("linux"):
|
|
try:
|
|
for path in ["/sys/class/thermal/thermal_zone0/temp", "/sys/class/thermal/thermal_zone1/temp"]:
|
|
if os.path.exists(path):
|
|
with open(path, "r") as f:
|
|
return round(int(f.read().strip()) / 1000.0, 1)
|
|
except Exception:
|
|
pass
|
|
|
|
# Dynamic fallback matching CPU load average
|
|
try:
|
|
if hasattr(os, "getloadavg"):
|
|
load = os.getloadavg()[0]
|
|
else:
|
|
load = 0.15
|
|
temp = 36.0 + min(30.0, load * 8.0)
|
|
return round(temp, 1)
|
|
except Exception:
|
|
return 38.5
|
|
|
|
def get_local_ip(self):
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(('10.255.255.255', 1))
|
|
IP = s.getsockname()[0]
|
|
except Exception:
|
|
IP = '127.0.0.1'
|
|
finally:
|
|
s.close()
|
|
return IP
|
|
|
|
# --- AUDIO INPUT/OUTPUT ENGINES ---
|
|
|
|
def play_wav_native(self, filepath, volume=50):
|
|
"""Launches native player asynchronously."""
|
|
self.audio_status = f"Playing WAV ({volume}%)"
|
|
|
|
# Native Windows Sound module
|
|
if sys.platform == "win32":
|
|
try:
|
|
import winsound
|
|
def run_winsound():
|
|
try:
|
|
winsound.PlaySound(filepath, winsound.SND_FILENAME)
|
|
finally:
|
|
self.audio_status = "Idle"
|
|
threading.Thread(target=run_winsound, daemon=True).start()
|
|
return True
|
|
except Exception as e:
|
|
sys.stderr.write(f"winsound failed: {e}\n")
|
|
sys.stderr.flush()
|
|
self.audio_status = "Error"
|
|
return False
|
|
|
|
# MacOS / Linux CLI launchers
|
|
cmd = None
|
|
if sys.platform == "darwin":
|
|
# afplay supports volume scaling (0.0 to 1.0)
|
|
vol_factor = volume / 100.0
|
|
cmd = ["afplay", "-v", str(vol_factor), filepath]
|
|
elif sys.platform.startswith("linux"):
|
|
vol_factor = volume / 100.0
|
|
for player in ["paplay", "pw-play", "aplay"]:
|
|
try:
|
|
subprocess.run(["which", player], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
if player == "paplay":
|
|
pa_vol = int((volume / 100.0) * 65536)
|
|
cmd = ["paplay", "--volume", str(pa_vol), filepath]
|
|
elif player == "pw-play":
|
|
cmd = ["pw-play", "--volume", str(vol_factor), filepath]
|
|
else:
|
|
cmd = ["aplay", filepath]
|
|
break
|
|
except subprocess.CalledProcessError:
|
|
continue
|
|
|
|
if cmd:
|
|
def run_player():
|
|
try:
|
|
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except Exception as ex:
|
|
sys.stderr.write(f"Audio command failed: {ex}\n")
|
|
sys.stderr.flush()
|
|
finally:
|
|
self.audio_status = "Idle"
|
|
|
|
threading.Thread(target=run_player, daemon=True).start()
|
|
return True
|
|
else:
|
|
sys.stderr.write("No supported command line audio player found.\n")
|
|
sys.stderr.flush()
|
|
self.audio_status = "Idle"
|
|
return False
|
|
|
|
def generate_and_play_tone(self, freq, duration_ms, volume):
|
|
self.audio_status = f"Playing {freq}Hz Tone"
|
|
sample_rate = 16000
|
|
num_samples = int(sample_rate * (duration_ms / 1000.0))
|
|
volume_scale = int((volume / 100.0) * 32767)
|
|
|
|
# Generate raw 16-bit PCM mono sine wave
|
|
pcm_data = bytearray()
|
|
for i in range(num_samples):
|
|
val = int(volume_scale * math.sin(2 * math.pi * freq * i / sample_rate))
|
|
pcm_data.extend(struct.pack("<h", val))
|
|
|
|
# Standard WAV Header
|
|
num_channels = 1
|
|
bits_per_sample = 16
|
|
byte_rate = sample_rate * num_channels * (bits_per_sample // 8)
|
|
block_align = num_channels * (bits_per_sample // 8)
|
|
subchunk2_size = num_samples * block_align
|
|
chunk_size = 36 + subchunk2_size
|
|
|
|
header = struct.pack(
|
|
"<4sI4s4sIHHIIHH4sI",
|
|
b"RIFF", chunk_size, b"WAVE",
|
|
b"fmt ", 16, 1, num_channels, sample_rate, byte_rate, block_align, bits_per_sample,
|
|
b"data", subchunk2_size
|
|
)
|
|
|
|
temp_fd, temp_path = tempfile.mkstemp(suffix=".wav")
|
|
with os.fdopen(temp_fd, "wb") as f:
|
|
f.write(header)
|
|
f.write(pcm_data)
|
|
|
|
# Play asynchronously
|
|
self.play_wav_native(temp_path, volume)
|
|
|
|
# Schedule deletion of temp file after tone finishes
|
|
threading.Timer((duration_ms + 1000) / 1000.0, lambda: self.safe_delete(temp_path)).start()
|
|
|
|
def record_voice(self, duration_sec, filename):
|
|
self.mic_status = f"Recording ({duration_sec}s)"
|
|
threading.Thread(target=self._run_record_thread, args=(duration_sec, filename), daemon=True).start()
|
|
return True
|
|
|
|
def _run_record_thread(self, duration_sec, filename):
|
|
try:
|
|
# 1. Try ALSA arecord on Linux
|
|
if sys.platform.startswith("linux"):
|
|
try:
|
|
subprocess.run([
|
|
"arecord",
|
|
"-d", str(duration_sec),
|
|
"-f", "S16_LE",
|
|
"-r", "16000",
|
|
"-c", "2",
|
|
"-t", "wav",
|
|
filename
|
|
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
return
|
|
except Exception as ex:
|
|
sys.stderr.write(f"arecord failed: {ex}. Trying next method...\n")
|
|
sys.stderr.flush()
|
|
|
|
# 2. Try sounddevice
|
|
try:
|
|
import sounddevice as sd
|
|
import numpy as np
|
|
import scipy.io.wavfile as wav
|
|
sample_rate = 16000
|
|
|
|
# Try 2 channels first (Stereo)
|
|
try:
|
|
rec = sd.rec(int(duration_sec * sample_rate), samplerate=sample_rate, channels=2, dtype='int16')
|
|
sd.wait()
|
|
wav.write(filename, sample_rate, rec)
|
|
return
|
|
except Exception as ex2:
|
|
sys.stderr.write(f"sounddevice stereo recording failed: {ex2}. Trying mono...\n")
|
|
sys.stderr.flush()
|
|
# Try 1 channel (Mono)
|
|
rec = sd.rec(int(duration_sec * sample_rate), samplerate=sample_rate, channels=1, dtype='int16')
|
|
sd.wait()
|
|
# Convert mono array (shape N,1 or N) to stereo (shape N,2) by replicating
|
|
if rec.ndim == 1:
|
|
stereo_rec = np.column_stack((rec, rec))
|
|
else:
|
|
stereo_rec = np.column_stack((rec[:, 0], rec[:, 0]))
|
|
wav.write(filename, sample_rate, stereo_rec)
|
|
return
|
|
except ImportError:
|
|
pass
|
|
except Exception as ex:
|
|
sys.stderr.write(f"sounddevice method failed: {ex}. Trying next method...\n")
|
|
sys.stderr.flush()
|
|
|
|
# 3. Try pyaudio
|
|
try:
|
|
import pyaudio
|
|
import wave
|
|
p = pyaudio.PyAudio()
|
|
|
|
# Try 2 channels
|
|
try:
|
|
stream = p.open(format=pyaudio.paInt16, channels=2, rate=16000, input=True, frames_per_buffer=1024)
|
|
frames = []
|
|
for _ in range(0, int(16000 / 1024 * duration_sec)):
|
|
data = stream.read(1024, exception_on_overflow=False)
|
|
frames.append(data)
|
|
stream.stop_stream()
|
|
stream.close()
|
|
p.terminate()
|
|
|
|
wf = wave.open(filename, 'wb')
|
|
wf.setnchannels(2)
|
|
wf.setsampwidth(2)
|
|
wf.setframerate(16000)
|
|
wf.writeframes(b''.join(frames))
|
|
wf.close()
|
|
return
|
|
except Exception as ep2:
|
|
sys.stderr.write(f"pyaudio stereo failed: {ep2}. Trying mono...\n")
|
|
sys.stderr.flush()
|
|
# Try 1 channel
|
|
stream = p.open(format=pyaudio.paInt16, channels=1, rate=16000, input=True, frames_per_buffer=1024)
|
|
frames = []
|
|
for _ in range(0, int(16000 / 1024 * duration_sec)):
|
|
mono_data = stream.read(1024, exception_on_overflow=False)
|
|
# Replicate mono samples (2 bytes each) to stereo
|
|
stereo_frames = bytearray()
|
|
for i in range(0, len(mono_data), 2):
|
|
sample = mono_data[i:i+2]
|
|
stereo_frames.extend(sample)
|
|
stereo_frames.extend(sample)
|
|
frames.append(bytes(stereo_frames))
|
|
stream.stop_stream()
|
|
stream.close()
|
|
p.terminate()
|
|
|
|
wf = wave.open(filename, 'wb')
|
|
wf.setnchannels(2)
|
|
wf.setsampwidth(2)
|
|
wf.setframerate(16000)
|
|
wf.writeframes(b''.join(frames))
|
|
wf.close()
|
|
return
|
|
except ImportError:
|
|
pass
|
|
except Exception as ex:
|
|
sys.stderr.write(f"pyaudio method failed: {ex}. Trying next method...\n")
|
|
sys.stderr.flush()
|
|
|
|
# 4. Mock fallback
|
|
sys.stderr.write("No working recording hardware found. Simulating recording...\n")
|
|
sys.stderr.flush()
|
|
time.sleep(duration_sec)
|
|
sample_rate = 16000
|
|
num_samples = int(sample_rate * duration_sec)
|
|
pcm_data = bytearray()
|
|
import random
|
|
for i in range(num_samples):
|
|
hum = int(800 * math.sin(2 * math.pi * 220 * i / sample_rate))
|
|
noise = random.randint(-150, 150)
|
|
val = max(-32768, min(32767, hum + noise))
|
|
pcm_data.extend(struct.pack("<hh", val, val))
|
|
|
|
num_channels = 2
|
|
bits_per_sample = 16
|
|
byte_rate = sample_rate * num_channels * (bits_per_sample // 8)
|
|
block_align = num_channels * (bits_per_sample // 8)
|
|
subchunk2_size = num_samples * block_align
|
|
chunk_size = 36 + subchunk2_size
|
|
|
|
header = struct.pack(
|
|
"<4sI4s4sIHHIIHH4sI",
|
|
b"RIFF", chunk_size, b"WAVE",
|
|
b"fmt ", 16, 1, num_channels, sample_rate, byte_rate, block_align, bits_per_sample,
|
|
b"data", subchunk2_size
|
|
)
|
|
with open(filename, "wb") as f:
|
|
f.write(header)
|
|
f.write(pcm_data)
|
|
except Exception as e:
|
|
sys.stderr.write(f"Background recording thread crashed: {e}\n")
|
|
sys.stderr.flush()
|
|
finally:
|
|
self.mic_status = "Idle"
|
|
|
|
def safe_delete(self, filepath):
|
|
try:
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
except Exception:
|
|
pass
|
|
|
|
# --- VIDEO DECODING ENGINE (RLCD layout decoding) ---
|
|
|
|
def rlcd_to_mono(self, rlcd_buf):
|
|
"""Converts raw 15,000-byte RLCD buffer into standard 480x320 monochrome canvas bytes (19,200 bytes)."""
|
|
canvas_buf = bytearray(19200)
|
|
X_OFF = 40
|
|
Y_OFF = 10
|
|
|
|
for index in range(15000):
|
|
val = rlcd_buf[index]
|
|
if val == 0:
|
|
continue
|
|
|
|
byte_x = index // 75
|
|
block_y = index % 75
|
|
x_base = 2 * byte_x + X_OFF
|
|
y_base = 299 - 4 * block_y + Y_OFF
|
|
|
|
# bit 7
|
|
if val & 0x80:
|
|
cx, cy = x_base, y_base
|
|
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
|
# bit 6
|
|
if val & 0x40:
|
|
cx, cy = x_base + 1, y_base
|
|
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
|
# bit 5
|
|
if val & 0x20:
|
|
cx, cy = x_base, y_base - 1
|
|
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
|
# bit 4
|
|
if val & 0x10:
|
|
cx, cy = x_base + 1, y_base - 1
|
|
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
|
# bit 3
|
|
if val & 0x08:
|
|
cx, cy = x_base, y_base - 2
|
|
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
|
# bit 2
|
|
if val & 0x04:
|
|
cx, cy = x_base + 1, y_base - 2
|
|
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
|
# bit 1
|
|
if val & 0x02:
|
|
cx, cy = x_base, y_base - 3
|
|
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
|
# bit 0
|
|
if val & 0x01:
|
|
cx, cy = x_base + 1, y_base - 3
|
|
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
|
|
|
return canvas_buf
|
|
|
|
def process_rlcd_frame(self, rlcd_frame_bytes):
|
|
"""Converts incoming raw RLCD bytes to PIL and triggers GUI refresh."""
|
|
self.override_active = True
|
|
self.show_window_requested = True
|
|
canvas_bytes = self.rlcd_to_mono(rlcd_frame_bytes)
|
|
|
|
# Load directly into our Visual Buffer (thread-safe lock)
|
|
with self.canvas_lock:
|
|
img = Image.frombytes("1", (self.width, self.height), bytes(canvas_bytes))
|
|
self.canvas_img.paste(img)
|
|
|
|
# Calculate dynamic FPS
|
|
now = time.time()
|
|
self.fps_frame_count += 1
|
|
elapsed = now - self.fps_start_time
|
|
if elapsed >= 1.0:
|
|
self.current_fps = self.fps_frame_count / elapsed
|
|
self.fps_frame_count = 0
|
|
self.fps_start_time = now
|
|
|
|
if not self.is_streaming_active:
|
|
self.bring_to_front()
|
|
|
|
self.is_streaming_active = True
|
|
self.last_stream_packet_time = now
|
|
|
|
# --- MCP SERVER LOGIC ---
|
|
|
|
def handle_mcp_request(self, req):
|
|
"""Processes an incoming JSON-RPC dictionary and executes the matching tool."""
|
|
rpc_id = req.get("id")
|
|
method = req.get("method")
|
|
params = req.get("params", {})
|
|
|
|
if method == "initialize":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": rpc_id,
|
|
"result": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {
|
|
"name": "desktop-companion-client",
|
|
"version": "1.0.0"
|
|
}
|
|
}
|
|
}
|
|
|
|
elif method == "tools/list":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": rpc_id,
|
|
"result": {
|
|
"tools": self.get_mcp_tools_list()
|
|
}
|
|
}
|
|
|
|
elif method == "tools/call":
|
|
tool_name = params.get("name")
|
|
arguments = params.get("arguments", {})
|
|
try:
|
|
res_content = self.execute_tool(tool_name, arguments)
|
|
if isinstance(res_content, list):
|
|
content = res_content
|
|
else:
|
|
content = [{"type": "text", "text": str(res_content)}]
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": rpc_id,
|
|
"result": {"content": content}
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": rpc_id,
|
|
"error": {
|
|
"code": -32000,
|
|
"message": str(e)
|
|
}
|
|
}
|
|
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": rpc_id,
|
|
"error": {
|
|
"code": -32601,
|
|
"message": f"Method {method} not found"
|
|
}
|
|
}
|
|
|
|
def get_mcp_tools_list(self):
|
|
return [
|
|
{
|
|
"name": "clear_screen",
|
|
"description": "Clear the 480x320 canvas screen to white (0) or black (1).",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"color": {"type": "integer", "enum": [0, 1], "description": "0 = White, 1 = Black"}
|
|
},
|
|
"required": ["color"]
|
|
}
|
|
},
|
|
{
|
|
"name": "draw_text",
|
|
"description": "Draw text on the canvas screen at specified (x,y) coordinates.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"text": {"type": "string", "description": "The message to display"},
|
|
"x": {"type": "integer", "description": "X coordinate (0-470)"},
|
|
"y": {"type": "integer", "description": "Y coordinate (0-310)"},
|
|
"size": {"type": "integer", "enum": [1, 2], "description": "Text scale (1=normal, 2=large)"}
|
|
},
|
|
"required": ["text", "x", "y"]
|
|
}
|
|
},
|
|
{
|
|
"name": "set_led",
|
|
"description": "Control the visual RGB LED. Animates breathes/rainbows dynamically.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"r": {"type": "integer", "minimum": 0, "maximum": 255, "description": "Red (0-255)"},
|
|
"g": {"type": "integer", "minimum": 0, "maximum": 255, "description": "Green (0-255)"},
|
|
"b": {"type": "integer", "minimum": 0, "maximum": 255, "description": "Blue (0-255)"},
|
|
"mode": {"type": "string", "enum": ["static", "breath", "rainbow", "off"], "description": "LED animation mode"}
|
|
},
|
|
"required": ["r", "g", "b", "mode"]
|
|
}
|
|
},
|
|
{
|
|
"name": "get_battery",
|
|
"description": "Read the host computer's actual battery voltage and estimated capacity percentage.",
|
|
"inputSchema": {"type": "object", "properties": {}}
|
|
},
|
|
{
|
|
"name": "get_sensors",
|
|
"description": "Read telemetry values representing the host computer's CPU temperature and relative indoor humidity.",
|
|
"inputSchema": {"type": "object", "properties": {}}
|
|
},
|
|
{
|
|
"name": "scan_ble",
|
|
"description": "Scan for nearby Bluetooth Low Energy devices using the host controller.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"duration_ms": {"type": "integer", "description": "Scan duration in milliseconds (default 3000)"}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"name": "get_screenshot",
|
|
"description": "Capture the current LCD display canvas rendering as a PNG image.",
|
|
"inputSchema": {"type": "object", "properties": {}}
|
|
},
|
|
{
|
|
"name": "draw_image",
|
|
"description": "Draw an image (PNG, JPEG, GIF, BMP, etc.) on the canvas. The image is converted to 1-bit monochrome and placed on the coordinates.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"image_base64": {"type": "string", "description": "Base64 encoded string of the source image file"},
|
|
"x": {"type": "integer", "description": "X coordinate (default 0)", "default": 0},
|
|
"y": {"type": "integer", "description": "Y coordinate (default 0)", "default": 0},
|
|
"dither": {"type": "boolean", "description": "Whether to dither (default true)", "default": True}
|
|
},
|
|
"required": ["image_base64"]
|
|
}
|
|
},
|
|
{
|
|
"name": "write_file",
|
|
"description": "Write a text file directly to the client's storage folder.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "The destination file path (e.g. 'test.txt')"},
|
|
"content": {"type": "string", "description": "The content to write"}
|
|
},
|
|
"required": ["path", "content"]
|
|
}
|
|
},
|
|
{
|
|
"name": "read_file",
|
|
"description": "Read a text file from the client's storage folder.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "The file path to read"}
|
|
},
|
|
"required": ["path"]
|
|
}
|
|
},
|
|
{
|
|
"name": "execute_python",
|
|
"description": "Execute arbitrary Python code dynamically on the host machine. Returns prints and errors.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"code": {"type": "string", "description": "The Python code to execute"}
|
|
},
|
|
"required": ["code"]
|
|
}
|
|
},
|
|
{
|
|
"name": "play_tone",
|
|
"description": "Play a pure sine wave tone on the host computer's speakers.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"frequency": {"type": "integer", "description": "Frequency in Hz (default 440)", "default": 440},
|
|
"duration_ms": {"type": "integer", "description": "Duration in milliseconds (default 1000)", "default": 1000},
|
|
"volume": {"type": "integer", "description": "Volume from 0 to 100 (default 50)", "default": 50}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"name": "play_audio",
|
|
"description": "Play a WAV audio file on the host computer's speakers.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"filename": {"type": "string", "description": "WAV file path to play"},
|
|
"volume": {"type": "integer", "description": "Volume from 0 to 100 (default 50)", "default": 50}
|
|
},
|
|
"required": ["filename"]
|
|
}
|
|
},
|
|
{
|
|
"name": "record_voice",
|
|
"description": "Record voice command from the computer microphone to a WAV file.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"duration_sec": {"type": "integer", "description": "Duration in seconds (default 4)", "default": 4},
|
|
"filename": {"type": "string", "description": "Destination WAV file (default 'recording.wav')", "default": "recording.wav"}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"name": "play_audio_base64",
|
|
"description": "Play a base64 encoded WAV file directly on the host computer's speakers.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"volume": {"type": "integer", "description": "Volume from 0 to 100 (default 50)", "default": 50},
|
|
"wav_base64": {"type": "string", "description": "Base64 encoded string of the WAV audio file"},
|
|
},
|
|
"required": [
|
|
"wav_base64"
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"name": "download_file",
|
|
"description": "Download a file from a URL to the local storage.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"filename": {"type": "string", "description": "The destination filename"},
|
|
"url": {"type": "string", "description": "The URL of the file to download"}
|
|
},
|
|
"required": [
|
|
"url",
|
|
"filename"
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"name": "get_video_streaming_instructions",
|
|
"description": "Get instructions to stream video directly into the companion canvas.",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"protocol": {"type": "string", "enum": ["tcp", "udp", "both"], "description": "The protocol to query (default 'both')"}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"name": "get_stream_stats",
|
|
"description": "Get real-time performance and frame-rate statistics for the video stream.",
|
|
"inputSchema": {"type": "object", "properties": {}}
|
|
}
|
|
]
|
|
|
|
def execute_tool(self, name, args):
|
|
"""Local tool implementation executing on the host PC."""
|
|
if name == "clear_screen":
|
|
color = int(args.get("color", 0))
|
|
self.override_active = True
|
|
self.show_window_requested = True
|
|
with self.canvas_lock:
|
|
self.draw.rectangle([0, 0, self.width, self.height], fill=color)
|
|
self.bring_to_front()
|
|
return "Screen cleared."
|
|
|
|
elif name == "draw_text":
|
|
text = str(args.get("text", ""))
|
|
x = int(args.get("x", 10))
|
|
y = int(args.get("y", 10))
|
|
size = int(args.get("size", 1))
|
|
self.override_active = True
|
|
self.show_window_requested = True
|
|
|
|
# Draw on PIL image using standard font size
|
|
font_size = 12 if size == 1 else 24
|
|
font = ImageFont.load_default()
|
|
try:
|
|
# Try loading standard monospace font for neat look
|
|
font = ImageFont.truetype("Courier New", font_size)
|
|
except:
|
|
try:
|
|
font = ImageFont.truetype("Arial", font_size)
|
|
except:
|
|
pass
|
|
with self.canvas_lock:
|
|
self.draw.text((x, y), text, fill=1, font=font)
|
|
self.bring_to_front()
|
|
return f"Drew text '{text}' at ({x}, {y})."
|
|
|
|
elif name == "set_led":
|
|
r = int(args.get("r", 0))
|
|
g = int(args.get("g", 0))
|
|
b = int(args.get("b", 0))
|
|
mode = str(args.get("mode", "static"))
|
|
|
|
self.led_r, self.led_g, self.led_b, self.led_mode = r, g, b, mode
|
|
return f"LED status set to {mode} ({r}, {g}, {b})."
|
|
|
|
elif name == "get_battery":
|
|
volts, pct = self.get_host_battery()
|
|
return json.dumps({"voltage_v": volts, "percentage_pct": pct})
|
|
|
|
elif name == "get_sensors":
|
|
temp = self.get_host_cpu_temp()
|
|
# Return indoor humidity mock
|
|
return json.dumps({"temperature_c": temp, "humidity_pct": 45.0})
|
|
|
|
elif name == "scan_ble":
|
|
dur = int(args.get("duration_ms", 3000))
|
|
return json.dumps(scan_ble_devices(dur))
|
|
|
|
elif name == "get_screenshot":
|
|
# Capture the current 480x320 PIL visual buffer, save as PNG in memory, base64 encode
|
|
import io
|
|
bg_color = (180, 195, 174)
|
|
fg_color = (26, 36, 22)
|
|
|
|
with self.canvas_lock:
|
|
gray_img = self.canvas_img.convert("L")
|
|
colored_img = ImageOps.colorize(gray_img, bg_color, fg_color)
|
|
|
|
buffered = io.BytesIO()
|
|
colored_img.save(buffered, format="PNG")
|
|
b64_png = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
|
|
|
return [
|
|
{
|
|
"type": "image",
|
|
"data": b64_png,
|
|
"mimeType": "image/png"
|
|
}
|
|
]
|
|
|
|
elif name == "draw_image":
|
|
pbm_b64 = args.get("pbm_base64")
|
|
image_base64 = args.get("image_base64")
|
|
x = int(args.get("x", 0))
|
|
y = int(args.get("y", 0))
|
|
self.override_active = True
|
|
self.show_window_requested = True
|
|
|
|
if pbm_b64:
|
|
pbm_bytes = base64.b64decode(pbm_b64)
|
|
import io
|
|
img = Image.open(io.BytesIO(pbm_bytes))
|
|
with self.canvas_lock:
|
|
self.canvas_img.paste(img, (x, y))
|
|
elif image_base64:
|
|
if "," in image_base64:
|
|
image_base64 = image_base64.split(",")[1]
|
|
img_bytes = base64.b64decode(image_base64)
|
|
import io
|
|
img = Image.open(io.BytesIO(img_bytes))
|
|
dither = args.get("dither", True)
|
|
img_1bit = img.convert("1", dither=Image.FLOYDSTEINBERG if dither else Image.NONE)
|
|
with self.canvas_lock:
|
|
self.canvas_img.paste(img_1bit, (x, y))
|
|
else:
|
|
raise ValueError("Missing image data (pbm_base64 or image_base64)")
|
|
|
|
self.bring_to_front()
|
|
return "Image rendered successfully on canvas."
|
|
|
|
elif name == "write_file":
|
|
path = str(args.get("path"))
|
|
content = str(args.get("content"))
|
|
# Save locally
|
|
with open(path, 'w') as f:
|
|
f.write(content)
|
|
return f"Wrote {len(content)} characters to local file '{path}'."
|
|
|
|
elif name == "read_file":
|
|
path = str(args.get("path"))
|
|
with open(path, 'r') as f:
|
|
return f.read()
|
|
|
|
elif name == "execute_python":
|
|
code = str(args.get("code"))
|
|
import builtins
|
|
import sys
|
|
import io
|
|
|
|
output_buffer = []
|
|
old_print = builtins.print
|
|
|
|
def custom_print(*args, **kwargs):
|
|
sep = kwargs.get('sep', ' ')
|
|
end = kwargs.get('end', '\n')
|
|
str_args = [str(arg) for arg in args]
|
|
msg = sep.join(str_args) + end
|
|
output_buffer.append(msg)
|
|
|
|
builtins.print = custom_print
|
|
error = None
|
|
try:
|
|
exec(code, globals())
|
|
except Exception as e:
|
|
import traceback
|
|
error_msg = traceback.format_exc()
|
|
output_buffer.append(f"\nExecution Failed:\n{error_msg}")
|
|
error = e
|
|
finally:
|
|
builtins.print = old_print
|
|
|
|
output = "".join(output_buffer)
|
|
if error:
|
|
return output
|
|
return f"Execution Succeeded. Console output:\n{output}"
|
|
|
|
elif name == "play_tone":
|
|
freq = int(args.get("frequency", 440))
|
|
duration = int(args.get("duration_ms", 1000))
|
|
vol = int(args.get("volume", 50))
|
|
|
|
self.generate_and_play_tone(freq, duration, vol)
|
|
return f"Played synthesized tone at {freq}Hz for {duration}ms (vol={vol})."
|
|
|
|
elif name == "play_audio":
|
|
filename = str(args.get("filename"))
|
|
vol = int(args.get("volume", 50))
|
|
|
|
success = self.play_wav_native(filename, vol)
|
|
if success:
|
|
return f"Playing audio file '{filename}'."
|
|
else:
|
|
raise RuntimeError(f"Failed to play audio file '{filename}'.")
|
|
|
|
elif name == "record_voice":
|
|
duration = int(args.get("duration_sec", 4))
|
|
filename = str(args.get("filename", "recording.wav"))
|
|
|
|
success = self.record_voice(duration, filename)
|
|
if success:
|
|
return f"Voice recording active. Saving to '{filename}' ({duration} seconds)."
|
|
else:
|
|
raise RuntimeError("Failed to trigger audio recording.")
|
|
|
|
elif name == "play_audio_base64":
|
|
b64_data = args.get("wav_base64")
|
|
vol = int(args.get("volume", 50))
|
|
if not b64_data:
|
|
raise ValueError("Missing wav_base64 parameter.")
|
|
|
|
audio_bytes = base64.b64decode(b64_data)
|
|
temp_fd, temp_path = tempfile.mkstemp(suffix=".wav")
|
|
with os.fdopen(temp_fd, "wb") as f:
|
|
f.write(audio_bytes)
|
|
|
|
success = self.play_wav_native(temp_path, vol)
|
|
# Schedule file deletion
|
|
threading.Timer(5.0, lambda: self.safe_delete(temp_path)).start()
|
|
|
|
if success:
|
|
return "Playing base64 decoded audio stream."
|
|
else:
|
|
raise RuntimeError("Failed to play audio stream.")
|
|
|
|
elif name == "download_file":
|
|
url = str(args.get("url"))
|
|
filename = str(args.get("filename"))
|
|
|
|
# Simple HTTP download
|
|
urllib.request.urlretrieve(url, filename)
|
|
return f"Successfully downloaded file to local storage: '{filename}'"
|
|
|
|
elif name == "get_video_streaming_instructions":
|
|
instructions = [
|
|
"### Native Client Companion Video Streaming Instructions",
|
|
"Display Area: 400x300 (drawn centered on the 480x320 physical screen), 1-bit monochrome.",
|
|
"To stream, connect to local network services:",
|
|
"1. **TCP (Port 8081)**: Open a TCP socket connection and stream consecutive 15,000-byte frame blocks.",
|
|
"2. **UDP (Port 8082)**: Stream 1,002-byte datagrams: byte 0 = frame_id, byte 1 = chunk_idx (0..14), bytes 2..1001 = payload."
|
|
]
|
|
return "\n".join(instructions)
|
|
|
|
elif name == "get_stream_stats":
|
|
return json.dumps({
|
|
"frames_drawn": self.fps_frame_count,
|
|
"tcp_bytes_received": self.tcp_bytes_received,
|
|
"udp_packets_received": self.udp_packets_received,
|
|
"udp_frames_complete": self.udp_frames_complete,
|
|
"dropped_udp_frames": self.dropped_udp_frames,
|
|
"last_fps": round(self.current_fps, 1),
|
|
"active": self.is_streaming_active
|
|
})
|
|
|
|
else:
|
|
raise ValueError(f"Unknown tool: {name}")
|
|
|
|
def scan_ble_devices(duration_ms):
|
|
"""Scans for nearby BLE tags. Uses Bleak if installed, falls back to simulated beacons."""
|
|
try:
|
|
import asyncio
|
|
from bleak import BleakScanner
|
|
|
|
async def run_scan():
|
|
scanner = BleakScanner()
|
|
devices = await scanner.discover(timeout=duration_ms/1000.0)
|
|
result = {}
|
|
for d in devices:
|
|
result[d.address] = {"rssi": d.rssi, "name": d.name or "Unknown"}
|
|
return result
|
|
|
|
try:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
res = loop.run_until_complete(run_scan())
|
|
loop.close()
|
|
return res
|
|
except Exception:
|
|
pass
|
|
except ImportError:
|
|
pass
|
|
|
|
# Simulated Beacons Fallback
|
|
import random
|
|
mock_tags = [
|
|
{"mac": "C8:FD:19:30:A5:11", "name": "Desk Tracker Tag"},
|
|
{"mac": "E5:31:02:8C:F4:9A", "name": "Companion Keyring"},
|
|
{"mac": "A4:38:DE:1C:B0:02", "name": "BLE Temp Beacon"},
|
|
]
|
|
result = {}
|
|
for tag in mock_tags:
|
|
result[tag["mac"]] = {"rssi": random.randint(-85, -45), "name": tag["name"]}
|
|
return result
|
|
|
|
# --- NETWORK BACKGROUND SERVICES ---
|
|
|
|
def run_http_server(app, port):
|
|
"""Runs a background HTTP server providing /api/mcp endpoint parity."""
|
|
class HTTPHandler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, format, *args):
|
|
pass # Suppress logging to stdout
|
|
|
|
def do_OPTIONS(self):
|
|
self.send_response(204)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
self.end_headers()
|
|
|
|
def do_POST(self):
|
|
if self.path == "/api/mcp":
|
|
content_length = int(self.headers.get('Content-Length', 0))
|
|
body = self.rfile.read(content_length).decode('utf-8')
|
|
try:
|
|
req_data = json.loads(body)
|
|
resp_data = app.handle_mcp_request(req_data)
|
|
body_bytes = json.dumps(resp_data).encode('utf-8')
|
|
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Content-Length", len(body_bytes))
|
|
self.end_headers()
|
|
self.wfile.write(body_bytes)
|
|
except Exception as e:
|
|
self.send_response(500)
|
|
self.end_headers()
|
|
self.wfile.write(str(e).encode('utf-8'))
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
httpd = http.server.HTTPServer(('', port), HTTPHandler)
|
|
sys.stderr.write(f"[HTTP Service] Listening on port {port}...\n")
|
|
sys.stderr.flush()
|
|
httpd.serve_forever()
|
|
|
|
def run_tcp_video_server(app, port):
|
|
"""Listens on port 8081 for incoming raw 15,000-byte RLCD video frame streams."""
|
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
server.bind(('', port))
|
|
server.listen(1)
|
|
|
|
sys.stderr.write(f"[TCP Video] Listening on port {port}...\n")
|
|
sys.stderr.flush()
|
|
|
|
while True:
|
|
try:
|
|
client, addr = server.accept()
|
|
sys.stderr.write(f"[TCP Video] Connection accepted from {addr}\n")
|
|
sys.stderr.flush()
|
|
|
|
frame_buffer = bytearray(15000)
|
|
view = memoryview(frame_buffer)
|
|
bytes_received = 0
|
|
|
|
while True:
|
|
remaining = 15000 - bytes_received
|
|
n = client.recv_into(view[bytes_received : bytes_received + remaining])
|
|
if not n:
|
|
break # disconnected
|
|
bytes_received += n
|
|
app.tcp_bytes_received += n
|
|
|
|
if bytes_received == 15000:
|
|
app.process_rlcd_frame(frame_buffer)
|
|
bytes_received = 0
|
|
client.close()
|
|
except Exception as e:
|
|
sys.stderr.write(f"[TCP Video] Error: {e}\n")
|
|
sys.stderr.flush()
|
|
time.sleep(1)
|
|
|
|
def run_udp_video_server(app, port):
|
|
"""Listens on port 8082 for fragmented RLCD UDP frame packages."""
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.bind(('', port))
|
|
|
|
sys.stderr.write(f"[UDP Video] Listening on port {port}...\n")
|
|
sys.stderr.flush()
|
|
|
|
frame_buffer = bytearray(15000)
|
|
current_frame_id = -1
|
|
chunks_mask = 0
|
|
temp_pack = bytearray(1002)
|
|
|
|
while True:
|
|
try:
|
|
n, addr = sock.recvfrom_into(temp_pack)
|
|
if n < 2:
|
|
continue
|
|
|
|
app.udp_packets_received += 1
|
|
frame_id = temp_pack[0]
|
|
chunk_idx = temp_pack[1]
|
|
|
|
if chunk_idx < 15:
|
|
if frame_id != current_frame_id:
|
|
if chunks_mask != 0:
|
|
app.dropped_udp_frames += 1
|
|
current_frame_id = frame_id
|
|
chunks_mask = 0
|
|
|
|
# Copy segment payload
|
|
start_offset = chunk_idx * 1000
|
|
frame_buffer[start_offset : start_offset + 1000] = temp_pack[2:1002]
|
|
chunks_mask |= (1 << chunk_idx)
|
|
|
|
# Check if all 15 chunks (0 to 14) are in (0x7FFF)
|
|
if chunks_mask == 0x7FFF:
|
|
app.udp_frames_complete += 1
|
|
app.process_rlcd_frame(frame_buffer)
|
|
chunks_mask = 0
|
|
except Exception as e:
|
|
sys.stderr.write(f"[UDP Video] Error: {e}\n")
|
|
sys.stderr.flush()
|
|
time.sleep(0.5)
|
|
|
|
def run_udp_discovery_responder(app, port, http_port):
|
|
"""Responds to discovery broadcast messages from clients like mcp_bridge."""
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind(('', port))
|
|
|
|
sys.stderr.write(f"[Discovery responder] Listening on port {port}...\n")
|
|
sys.stderr.flush()
|
|
|
|
while True:
|
|
try:
|
|
data, addr = sock.recvfrom(128)
|
|
if data == b"DISCOVER_SCREEN":
|
|
# Respond with matching HTTP port configuration
|
|
response = f"SCREEN_IP_{http_port}".encode('utf-8')
|
|
sock.sendto(response, addr)
|
|
except Exception as e:
|
|
sys.stderr.write(f"[Discovery responder] Error: {e}\n")
|
|
sys.stderr.flush()
|
|
time.sleep(1)
|
|
|
|
def run_stdio_mcp_server(app):
|
|
"""Reads JSON-RPC messages line-by-line from stdin, writing responses to stdout."""
|
|
sys.stderr.write("[MCP Stdio Server] Stdio listener active.\n")
|
|
sys.stderr.flush()
|
|
|
|
while True:
|
|
try:
|
|
line = sys.stdin.readline()
|
|
if not line:
|
|
break
|
|
req = json.loads(line.strip())
|
|
resp = app.handle_mcp_request(req)
|
|
|
|
# Write explicitly to raw stdout
|
|
payload = json.dumps(resp) + "\n"
|
|
app.mcp_stdout.write(payload)
|
|
app.mcp_stdout.flush()
|
|
except Exception as e:
|
|
sys.stderr.write(f"[MCP Stdio] Error handling command: {e}\n")
|
|
sys.stderr.flush()
|
|
|
|
# --- MAIN ---
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Native Desktop Companion Client")
|
|
parser.add_argument("--port", type=int, default=HTTP_PORT, help="HTTP Server port (default 8080)")
|
|
parser.add_argument("--width", type=int, default=480, help="Canvas width (default 480)")
|
|
parser.add_argument("--height", type=int, default=320, help="Canvas height (default 320)")
|
|
parser.add_argument("--headless", action="store_true", help="Run in headless mode without GUI")
|
|
args = parser.parse_args()
|
|
|
|
client = NativeCompanionClient(width=args.width, height=args.height, http_port=args.port)
|
|
|
|
# Start background servers
|
|
threading.Thread(target=run_http_server, args=(client, args.port), daemon=True).start()
|
|
threading.Thread(target=run_tcp_video_server, args=(client, TCP_STREAM_PORT), daemon=True).start()
|
|
threading.Thread(target=run_udp_video_server, args=(client, UDP_STREAM_PORT), daemon=True).start()
|
|
threading.Thread(target=run_udp_discovery_responder, args=(client, UDP_DISCOVERY_PORT, args.port), daemon=True).start()
|
|
threading.Thread(target=run_stdio_mcp_server, args=(client,), daemon=True).start()
|
|
|
|
# Run Pygame GUI if not headless and Pygame is installed
|
|
if args.headless or not PYGAME_AVAILABLE:
|
|
if not PYGAME_AVAILABLE and not args.headless:
|
|
sys.stderr.write("Warning: Pygame is not installed. Running in headless mode.\n")
|
|
sys.stderr.flush()
|
|
else:
|
|
sys.stderr.write("Headless mode active. Running background services...\n")
|
|
sys.stderr.flush()
|
|
|
|
try:
|
|
while True:
|
|
time.sleep(3600)
|
|
except KeyboardInterrupt:
|
|
sys.stderr.write("Stopping background services.\n")
|
|
sys.stderr.flush()
|
|
os._exit(0)
|
|
else:
|
|
# Start Pygame loop on main thread
|
|
client.run_pygame_loop()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|