Desmond-Dong commited on
Commit
c99f503
·
1 Parent(s): 1409d3e

"feat-camera-add-mjpeg-streaming-server-for-home-assistant"

Browse files
.gitignore CHANGED
@@ -73,3 +73,4 @@ models/
73
  reachy_mini/
74
  reachy_mini_conversation_app/
75
  linux-voice-assistant/
 
 
73
  reachy_mini/
74
  reachy_mini_conversation_app/
75
  linux-voice-assistant/
76
+ reachy-mini-desktop-app/
PROJECT_PLAN.md CHANGED
@@ -8,6 +8,7 @@
8
  1. [linux-voice-assistant](linux-voice-assistant)
9
  2. [Reachy Mini SDK](reachy_mini)
10
  3. [reachy_mini_conversation_app](reachy_mini_conversation_app)
 
11
 
12
  ## 核心设计原则
13
 
 
8
  1. [linux-voice-assistant](linux-voice-assistant)
9
  2. [Reachy Mini SDK](reachy_mini)
10
  3. [reachy_mini_conversation_app](reachy_mini_conversation_app)
11
+ 4. [reachy-mini-desktop-app](reachy-mini-desktop-app)
12
 
13
  ## 核心设计原则
14
 
README.md CHANGED
@@ -19,6 +19,7 @@ A voice assistant application for **Reachy Mini robot** that integrates with Hom
19
 
20
  - **Local Wake Word Detection**: Uses microWakeWord for offline wake word detection
21
  - **ESPHome Integration**: Seamlessly connects to Home Assistant
 
22
  - **Motion Control**: Head movements and antenna animations during voice interaction
23
  - **Zero Configuration**: Install and run - all settings are managed in Home Assistant
24
  - **Full Robot Control**: Expose 30+ entities to Home Assistant for complete robot control
@@ -55,6 +56,20 @@ The app runs automatically when installed on Reachy Mini. After installation:
55
  4. Enter your Reachy Mini's IP address with port `6053`
56
  5. The voice assistant will be automatically discovered
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  ### Wake Words
59
 
60
  Default wake word: **"Okay Nabu"**
@@ -115,11 +130,14 @@ This application exposes 30+ entities to Home Assistant for complete robot contr
115
  |
116
  v
117
  [Head Motion & Antenna Animation]
 
 
118
  ```
119
 
120
  - **Wake word detection** runs locally on Reachy Mini
121
  - **Speech-to-Text (STT)** and **Text-to-Speech (TTS)** are handled by Home Assistant
122
  - **Motion feedback** provides visual response during voice interaction
 
123
 
124
  ## Project Structure
125
 
@@ -129,6 +147,7 @@ reachy_mini_ha_voice/
129
  │ ├── __init__.py
130
  │ ├── main.py # App entry point
131
  │ ├── voice_assistant.py # Voice assistant service
 
132
  │ ├── satellite.py # ESPHome protocol handler
133
  │ ├── audio_player.py # Audio playback
134
  │ ├── motion.py # Motion control
@@ -149,6 +168,7 @@ reachy_mini_ha_voice/
149
  - `reachy-mini` - Reachy Mini SDK
150
  - `aioesphomeapi` - ESPHome protocol
151
  - `pymicro-wakeword` - Wake word detection
 
152
  - `sounddevice` / `soundfile` - Audio processing
153
  - `zeroconf` - mDNS discovery
154
 
 
19
 
20
  - **Local Wake Word Detection**: Uses microWakeWord for offline wake word detection
21
  - **ESPHome Integration**: Seamlessly connects to Home Assistant
22
+ - **Camera Streaming**: MJPEG video stream for Home Assistant Generic Camera integration
23
  - **Motion Control**: Head movements and antenna animations during voice interaction
24
  - **Zero Configuration**: Install and run - all settings are managed in Home Assistant
25
  - **Full Robot Control**: Expose 30+ entities to Home Assistant for complete robot control
 
56
  4. Enter your Reachy Mini's IP address with port `6053`
57
  5. The voice assistant will be automatically discovered
58
 
59
+ ### Camera Setup
60
+
61
+ The camera stream is available at `http://<reachy-mini-ip>:8081/stream`. To add it to Home Assistant:
62
+
63
+ 1. Go to **Settings** -> **Devices & Services** -> **Add Integration**
64
+ 2. Search for **Generic Camera**
65
+ 3. Enter the stream URL: `http://<reachy-mini-ip>:8081/stream`
66
+ 4. Set content type to `image/jpeg`
67
+
68
+ You can also access:
69
+ - **Live Stream**: `http://<reachy-mini-ip>:8081/stream` - MJPEG video stream
70
+ - **Snapshot**: `http://<reachy-mini-ip>:8081/snapshot` - Single JPEG image
71
+ - **Status Page**: `http://<reachy-mini-ip>:8081/` - Web interface with stream preview
72
+
73
  ### Wake Words
74
 
75
  Default wake word: **"Okay Nabu"**
 
130
  |
131
  v
132
  [Head Motion & Antenna Animation]
133
+
134
+ [Reachy Mini Camera] -> [MJPEG Server :8081] -> [Home Assistant Generic Camera]
135
  ```
136
 
137
  - **Wake word detection** runs locally on Reachy Mini
138
  - **Speech-to-Text (STT)** and **Text-to-Speech (TTS)** are handled by Home Assistant
139
  - **Motion feedback** provides visual response during voice interaction
140
+ - **Camera streaming** provides real-time video feed to Home Assistant
141
 
142
  ## Project Structure
143
 
 
147
  │ ├── __init__.py
148
  │ ├── main.py # App entry point
149
  │ ├── voice_assistant.py # Voice assistant service
150
+ │ ├── camera_server.py # MJPEG camera streaming server
151
  │ ├── satellite.py # ESPHome protocol handler
152
  │ ├── audio_player.py # Audio playback
153
  │ ├── motion.py # Motion control
 
168
  - `reachy-mini` - Reachy Mini SDK
169
  - `aioesphomeapi` - ESPHome protocol
170
  - `pymicro-wakeword` - Wake word detection
171
+ - `opencv-python` - Camera streaming
172
  - `sounddevice` / `soundfile` - Audio processing
173
  - `zeroconf` - mDNS discovery
174
 
pyproject.toml CHANGED
@@ -18,6 +18,9 @@ dependencies = [
18
  "soundfile>=0.12.0",
19
  "numpy>=1.24.0",
20
 
 
 
 
21
  # Wake word detection (local)
22
  # STT/TTS is handled by Home Assistant, not locally
23
  "pymicro-wakeword>=2.0.0,<3.0.0",
 
18
  "soundfile>=0.12.0",
19
  "numpy>=1.24.0",
20
 
21
+ # Camera streaming
22
+ "opencv-python>=4.8.0",
23
+
24
  # Wake word detection (local)
25
  # STT/TTS is handled by Home Assistant, not locally
26
  "pymicro-wakeword>=2.0.0,<3.0.0",
reachy_mini_ha_voice/camera_server.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MJPEG Camera Server for Reachy Mini.
3
+
4
+ This module provides an HTTP server that streams camera frames from Reachy Mini
5
+ as MJPEG, which can be integrated with Home Assistant via Generic Camera.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import threading
11
+ import time
12
+ from typing import Optional, TYPE_CHECKING
13
+
14
+ import cv2
15
+ import numpy as np
16
+
17
+ if TYPE_CHECKING:
18
+ from reachy_mini import ReachyMini
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+ # MJPEG boundary string
23
+ MJPEG_BOUNDARY = "frame"
24
+
25
+
26
+ class MJPEGCameraServer:
27
+ """
28
+ MJPEG streaming server for Reachy Mini camera.
29
+
30
+ Provides HTTP endpoints:
31
+ - /stream - MJPEG video stream
32
+ - /snapshot - Single JPEG image
33
+ - / - Simple status page
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ reachy_mini: Optional["ReachyMini"] = None,
39
+ host: str = "0.0.0.0",
40
+ port: int = 8081,
41
+ fps: int = 15,
42
+ quality: int = 80,
43
+ ):
44
+ """
45
+ Initialize the MJPEG camera server.
46
+
47
+ Args:
48
+ reachy_mini: Reachy Mini robot instance (can be None for testing)
49
+ host: Host address to bind to
50
+ port: Port number for the HTTP server
51
+ fps: Target frames per second for the stream
52
+ quality: JPEG quality (1-100)
53
+ """
54
+ self.reachy_mini = reachy_mini
55
+ self.host = host
56
+ self.port = port
57
+ self.fps = fps
58
+ self.quality = quality
59
+
60
+ self._server: Optional[asyncio.Server] = None
61
+ self._running = False
62
+ self._frame_interval = 1.0 / fps
63
+ self._last_frame: Optional[bytes] = None
64
+ self._last_frame_time: float = 0
65
+ self._frame_lock = threading.Lock()
66
+
67
+ # Frame capture thread
68
+ self._capture_thread: Optional[threading.Thread] = None
69
+
70
+ async def start(self) -> None:
71
+ """Start the MJPEG camera server."""
72
+ if self._running:
73
+ _LOGGER.warning("Camera server already running")
74
+ return
75
+
76
+ self._running = True
77
+
78
+ # Start frame capture thread
79
+ self._capture_thread = threading.Thread(
80
+ target=self._capture_frames,
81
+ daemon=True,
82
+ name="camera-capture"
83
+ )
84
+ self._capture_thread.start()
85
+
86
+ # Start HTTP server
87
+ self._server = await asyncio.start_server(
88
+ self._handle_client,
89
+ self.host,
90
+ self.port,
91
+ )
92
+
93
+ _LOGGER.info("MJPEG Camera server started on http://%s:%d", self.host, self.port)
94
+ _LOGGER.info(" Stream URL: http://<ip>:%d/stream", self.port)
95
+ _LOGGER.info(" Snapshot URL: http://<ip>:%d/snapshot", self.port)
96
+
97
+ async def stop(self) -> None:
98
+ """Stop the MJPEG camera server."""
99
+ self._running = False
100
+
101
+ if self._capture_thread:
102
+ self._capture_thread.join(timeout=2.0)
103
+ self._capture_thread = None
104
+
105
+ if self._server:
106
+ self._server.close()
107
+ await self._server.wait_closed()
108
+ self._server = None
109
+
110
+ _LOGGER.info("MJPEG Camera server stopped")
111
+
112
+ def _capture_frames(self) -> None:
113
+ """Background thread to capture frames from Reachy Mini."""
114
+ _LOGGER.info("Starting camera capture thread")
115
+
116
+ while self._running:
117
+ try:
118
+ frame = self._get_camera_frame()
119
+
120
+ if frame is not None:
121
+ # Encode frame as JPEG
122
+ encode_params = [cv2.IMWRITE_JPEG_QUALITY, self.quality]
123
+ success, jpeg_data = cv2.imencode('.jpg', frame, encode_params)
124
+
125
+ if success:
126
+ with self._frame_lock:
127
+ self._last_frame = jpeg_data.tobytes()
128
+ self._last_frame_time = time.time()
129
+
130
+ # Sleep to maintain target FPS
131
+ time.sleep(self._frame_interval)
132
+
133
+ except Exception as e:
134
+ _LOGGER.error("Error capturing frame: %s", e)
135
+ time.sleep(0.5)
136
+
137
+ _LOGGER.info("Camera capture thread stopped")
138
+
139
+ def _get_camera_frame(self) -> Optional[np.ndarray]:
140
+ """Get a frame from Reachy Mini's camera."""
141
+ if self.reachy_mini is None:
142
+ # Return a test pattern if no robot connected
143
+ return self._generate_test_frame()
144
+
145
+ try:
146
+ frame = self.reachy_mini.media.get_frame()
147
+ return frame
148
+ except Exception as e:
149
+ _LOGGER.debug("Failed to get camera frame: %s", e)
150
+ return None
151
+
152
+ def _generate_test_frame(self) -> np.ndarray:
153
+ """Generate a test pattern frame when no camera is available."""
154
+ # Create a simple test pattern
155
+ frame = np.zeros((480, 640, 3), dtype=np.uint8)
156
+
157
+ # Add some visual elements
158
+ cv2.putText(
159
+ frame,
160
+ "Reachy Mini Camera",
161
+ (150, 200),
162
+ cv2.FONT_HERSHEY_SIMPLEX,
163
+ 1.2,
164
+ (255, 255, 255),
165
+ 2,
166
+ )
167
+ cv2.putText(
168
+ frame,
169
+ "No camera connected",
170
+ (180, 280),
171
+ cv2.FONT_HERSHEY_SIMPLEX,
172
+ 0.8,
173
+ (128, 128, 128),
174
+ 1,
175
+ )
176
+
177
+ # Add timestamp
178
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
179
+ cv2.putText(
180
+ frame,
181
+ timestamp,
182
+ (220, 350),
183
+ cv2.FONT_HERSHEY_SIMPLEX,
184
+ 0.6,
185
+ (0, 255, 0),
186
+ 1,
187
+ )
188
+
189
+ return frame
190
+
191
+ def get_snapshot(self) -> Optional[bytes]:
192
+ """Get the latest frame as JPEG bytes."""
193
+ with self._frame_lock:
194
+ return self._last_frame
195
+
196
+ async def _handle_client(
197
+ self,
198
+ reader: asyncio.StreamReader,
199
+ writer: asyncio.StreamWriter,
200
+ ) -> None:
201
+ """Handle incoming HTTP client connections."""
202
+ try:
203
+ # Read HTTP request
204
+ request_line = await asyncio.wait_for(
205
+ reader.readline(),
206
+ timeout=10.0
207
+ )
208
+ request = request_line.decode('utf-8', errors='ignore').strip()
209
+
210
+ # Read headers (we don't need them but must consume them)
211
+ while True:
212
+ line = await asyncio.wait_for(reader.readline(), timeout=5.0)
213
+ if line == b'\r\n' or line == b'\n' or line == b'':
214
+ break
215
+
216
+ # Parse request path
217
+ parts = request.split(' ')
218
+ if len(parts) >= 2:
219
+ path = parts[1]
220
+ else:
221
+ path = '/'
222
+
223
+ _LOGGER.debug("HTTP request: %s", request)
224
+
225
+ if path == '/stream':
226
+ await self._handle_stream(writer)
227
+ elif path == '/snapshot':
228
+ await self._handle_snapshot(writer)
229
+ else:
230
+ await self._handle_index(writer)
231
+
232
+ except asyncio.TimeoutError:
233
+ _LOGGER.debug("Client connection timeout")
234
+ except ConnectionResetError:
235
+ _LOGGER.debug("Client connection reset")
236
+ except Exception as e:
237
+ _LOGGER.error("Error handling client: %s", e)
238
+ finally:
239
+ try:
240
+ writer.close()
241
+ await writer.wait_closed()
242
+ except Exception:
243
+ pass
244
+
245
+ async def _handle_index(self, writer: asyncio.StreamWriter) -> None:
246
+ """Handle index page request."""
247
+ html = f"""<!DOCTYPE html>
248
+ <html>
249
+ <head>
250
+ <title>Reachy Mini Camera</title>
251
+ <style>
252
+ body {{ font-family: Arial, sans-serif; margin: 40px; background: #1a1a2e; color: #eee; }}
253
+ h1 {{ color: #00d4ff; }}
254
+ .container {{ max-width: 800px; margin: 0 auto; }}
255
+ .stream {{ width: 100%; max-width: 640px; border: 2px solid #00d4ff; border-radius: 8px; }}
256
+ a {{ color: #00d4ff; }}
257
+ .info {{ background: #16213e; padding: 20px; border-radius: 8px; margin-top: 20px; }}
258
+ </style>
259
+ </head>
260
+ <body>
261
+ <div class="container">
262
+ <h1>Reachy Mini Camera</h1>
263
+ <img class="stream" src="/stream" alt="Camera Stream">
264
+ <div class="info">
265
+ <h3>Endpoints:</h3>
266
+ <ul>
267
+ <li><a href="/stream">/stream</a> - MJPEG video stream</li>
268
+ <li><a href="/snapshot">/snapshot</a> - Single JPEG snapshot</li>
269
+ </ul>
270
+ <h3>Home Assistant Integration:</h3>
271
+ <p>Add a Generic Camera with URL: <code>http://&lt;ip&gt;:{self.port}/stream</code></p>
272
+ </div>
273
+ </div>
274
+ </body>
275
+ </html>"""
276
+
277
+ response = (
278
+ "HTTP/1.1 200 OK\r\n"
279
+ "Content-Type: text/html; charset=utf-8\r\n"
280
+ f"Content-Length: {len(html)}\r\n"
281
+ "Connection: close\r\n"
282
+ "\r\n"
283
+ )
284
+
285
+ writer.write(response.encode('utf-8'))
286
+ writer.write(html.encode('utf-8'))
287
+ await writer.drain()
288
+
289
+ async def _handle_snapshot(self, writer: asyncio.StreamWriter) -> None:
290
+ """Handle snapshot request - return single JPEG image."""
291
+ jpeg_data = self.get_snapshot()
292
+
293
+ if jpeg_data is None:
294
+ response = (
295
+ "HTTP/1.1 503 Service Unavailable\r\n"
296
+ "Content-Type: text/plain\r\n"
297
+ "Connection: close\r\n"
298
+ "\r\n"
299
+ "No frame available"
300
+ )
301
+ writer.write(response.encode('utf-8'))
302
+ else:
303
+ response = (
304
+ "HTTP/1.1 200 OK\r\n"
305
+ "Content-Type: image/jpeg\r\n"
306
+ f"Content-Length: {len(jpeg_data)}\r\n"
307
+ "Cache-Control: no-cache, no-store, must-revalidate\r\n"
308
+ "Connection: close\r\n"
309
+ "\r\n"
310
+ )
311
+ writer.write(response.encode('utf-8'))
312
+ writer.write(jpeg_data)
313
+
314
+ await writer.drain()
315
+
316
+ async def _handle_stream(self, writer: asyncio.StreamWriter) -> None:
317
+ """Handle MJPEG stream request."""
318
+ # Send MJPEG headers
319
+ response = (
320
+ "HTTP/1.1 200 OK\r\n"
321
+ f"Content-Type: multipart/x-mixed-replace; boundary={MJPEG_BOUNDARY}\r\n"
322
+ "Cache-Control: no-cache, no-store, must-revalidate\r\n"
323
+ "Connection: keep-alive\r\n"
324
+ "\r\n"
325
+ )
326
+ writer.write(response.encode('utf-8'))
327
+ await writer.drain()
328
+
329
+ _LOGGER.debug("Started MJPEG stream")
330
+
331
+ last_sent_time = 0
332
+
333
+ try:
334
+ while self._running:
335
+ # Get latest frame
336
+ with self._frame_lock:
337
+ jpeg_data = self._last_frame
338
+ frame_time = self._last_frame_time
339
+
340
+ # Only send if we have a new frame
341
+ if jpeg_data is not None and frame_time > last_sent_time:
342
+ # Send MJPEG frame
343
+ frame_header = (
344
+ f"--{MJPEG_BOUNDARY}\r\n"
345
+ "Content-Type: image/jpeg\r\n"
346
+ f"Content-Length: {len(jpeg_data)}\r\n"
347
+ "\r\n"
348
+ )
349
+
350
+ writer.write(frame_header.encode('utf-8'))
351
+ writer.write(jpeg_data)
352
+ writer.write(b"\r\n")
353
+ await writer.drain()
354
+
355
+ last_sent_time = frame_time
356
+
357
+ # Small delay to prevent busy loop
358
+ await asyncio.sleep(0.01)
359
+
360
+ except (ConnectionResetError, BrokenPipeError):
361
+ _LOGGER.debug("Client disconnected from stream")
362
+ except Exception as e:
363
+ _LOGGER.error("Error in MJPEG stream: %s", e)
364
+
365
+ _LOGGER.debug("Ended MJPEG stream")
reachy_mini_ha_voice/main.py CHANGED
@@ -132,15 +132,19 @@ class ReachyMiniHAVoiceApp(ReachyMiniApp):
132
  logger.info("Home Assistant Voice Assistant Started!")
133
  logger.info("=" * 50)
134
  logger.info("ESPHome Server: 0.0.0.0:6053")
 
135
  logger.info("Wake word: Okay Nabu")
136
  if reachy_mini:
137
  logger.info("Motion control: enabled")
 
138
  else:
139
  logger.info("Motion control: disabled (no robot)")
 
140
  logger.info("=" * 50)
141
  logger.info("To connect from Home Assistant:")
142
  logger.info(" Settings -> Devices & Services -> Add Integration")
143
  logger.info(" -> ESPHome -> Enter this device's IP:6053")
 
144
  logger.info("=" * 50)
145
 
146
  # Wait for stop signal
 
132
  logger.info("Home Assistant Voice Assistant Started!")
133
  logger.info("=" * 50)
134
  logger.info("ESPHome Server: 0.0.0.0:6053")
135
+ logger.info("Camera Server: 0.0.0.0:8081")
136
  logger.info("Wake word: Okay Nabu")
137
  if reachy_mini:
138
  logger.info("Motion control: enabled")
139
+ logger.info("Camera: enabled (Reachy Mini)")
140
  else:
141
  logger.info("Motion control: disabled (no robot)")
142
+ logger.info("Camera: test pattern (no robot)")
143
  logger.info("=" * 50)
144
  logger.info("To connect from Home Assistant:")
145
  logger.info(" Settings -> Devices & Services -> Add Integration")
146
  logger.info(" -> ESPHome -> Enter this device's IP:6053")
147
+ logger.info(" -> Generic Camera -> http://<ip>:8081/stream")
148
  logger.info("=" * 50)
149
 
150
  # Wait for stop signal
reachy_mini_ha_voice/voice_assistant.py CHANGED
@@ -24,6 +24,7 @@ from .satellite import VoiceSatelliteProtocol
24
  from .util import get_mac
25
  from .zeroconf import HomeAssistantZeroconf
26
  from .motion import ReachyMiniMotion
 
27
 
28
  _LOGGER = logging.getLogger(__name__)
29
 
@@ -43,12 +44,16 @@ class VoiceAssistantService:
43
  host: str = "0.0.0.0",
44
  port: int = 6053,
45
  wake_model: str = "okay_nabu",
 
 
46
  ):
47
  self.reachy_mini = reachy_mini
48
  self.name = name
49
  self.host = host
50
  self.port = port
51
  self.wake_model = wake_model
 
 
52
 
53
  self._server = None
54
  self._discovery = None
@@ -56,6 +61,7 @@ class VoiceAssistantService:
56
  self._running = False
57
  self._state: Optional[ServerState] = None
58
  self._motion = ReachyMiniMotion(reachy_mini)
 
59
 
60
  async def start(self) -> None:
61
  """Start the voice assistant service."""
@@ -143,6 +149,17 @@ class VoiceAssistantService:
143
  self._discovery = HomeAssistantZeroconf(port=self.port, name=self.name)
144
  await self._discovery.register_server()
145
 
 
 
 
 
 
 
 
 
 
 
 
146
  _LOGGER.info("Voice assistant service started on %s:%s", self.host, self.port)
147
 
148
  async def stop(self) -> None:
@@ -161,6 +178,11 @@ class VoiceAssistantService:
161
  if self._discovery:
162
  await self._discovery.unregister_server()
163
 
 
 
 
 
 
164
  # Stop Reachy Mini media system
165
  if self.reachy_mini is not None:
166
  try:
 
24
  from .util import get_mac
25
  from .zeroconf import HomeAssistantZeroconf
26
  from .motion import ReachyMiniMotion
27
+ from .camera_server import MJPEGCameraServer
28
 
29
  _LOGGER = logging.getLogger(__name__)
30
 
 
44
  host: str = "0.0.0.0",
45
  port: int = 6053,
46
  wake_model: str = "okay_nabu",
47
+ camera_port: int = 8081,
48
+ camera_enabled: bool = True,
49
  ):
50
  self.reachy_mini = reachy_mini
51
  self.name = name
52
  self.host = host
53
  self.port = port
54
  self.wake_model = wake_model
55
+ self.camera_port = camera_port
56
+ self.camera_enabled = camera_enabled
57
 
58
  self._server = None
59
  self._discovery = None
 
61
  self._running = False
62
  self._state: Optional[ServerState] = None
63
  self._motion = ReachyMiniMotion(reachy_mini)
64
+ self._camera_server: Optional[MJPEGCameraServer] = None
65
 
66
  async def start(self) -> None:
67
  """Start the voice assistant service."""
 
149
  self._discovery = HomeAssistantZeroconf(port=self.port, name=self.name)
150
  await self._discovery.register_server()
151
 
152
+ # Start camera server if enabled
153
+ if self.camera_enabled:
154
+ self._camera_server = MJPEGCameraServer(
155
+ reachy_mini=self.reachy_mini,
156
+ host=self.host,
157
+ port=self.camera_port,
158
+ fps=15,
159
+ quality=80,
160
+ )
161
+ await self._camera_server.start()
162
+
163
  _LOGGER.info("Voice assistant service started on %s:%s", self.host, self.port)
164
 
165
  async def stop(self) -> None:
 
178
  if self._discovery:
179
  await self._discovery.unregister_server()
180
 
181
+ # Stop camera server
182
+ if self._camera_server:
183
+ await self._camera_server.stop()
184
+ self._camera_server = None
185
+
186
  # Stop Reachy Mini media system
187
  if self.reachy_mini is not None:
188
  try: