Desmond-Dong commited on
Commit
4efaf4f
·
1 Parent(s): 47cb5cb

Restructure app for Reachy Mini App Assistant

Browse files

- Add ReachyMiniApp inheritance with run method
- Create app.py as main entry point
- Add web UI (index.html, style.css, main.js)
- Update pyproject.toml with reachy-mini dependency and entry point
- Follow Reachy Mini app structure requirements

Files changed (5) hide show
  1. index.html +61 -0
  2. main.js +47 -0
  3. pyproject.toml +5 -1
  4. reachy_mini_ha_voice/app.py +329 -0
  5. style.css +163 -0
index.html ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Reachy Mini Home Assistant Voice Assistant</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <header>
12
+ <h1>🤖 Reachy Mini Home Assistant Voice Assistant</h1>
13
+ <p class="subtitle">基于 ESPHome 协议的语音助手</p>
14
+ </header>
15
+
16
+ <main>
17
+ <section class="status">
18
+ <h2>状态</h2>
19
+ <div class="status-indicator">
20
+ <span class="status-dot" id="statusDot"></span>
21
+ <span class="status-text" id="statusText">未运行</span>
22
+ </div>
23
+ </section>
24
+
25
+ <section class="info">
26
+ <h2>信息</h2>
27
+ <div class="info-card">
28
+ <h3>🎤 唤醒词</h3>
29
+ <p>默认唤醒词: "Okay Nabu"</p>
30
+ <p>支持多个唤醒词: Alexa, Hey Jarvis, Hey Home Assistant 等</p>
31
+ </div>
32
+
33
+ <div class="info-card">
34
+ <h3>🔌 连接</h3>
35
+ <p>ESPHome 端口: 6053</p>
36
+ <p>自动发现: 已启用 (mDNS/Zeroconf)</p>
37
+ </div>
38
+
39
+ <div class="info-card">
40
+ <h3>🏠 Home Assistant</h3>
41
+ <p>1. 在 Home Assistant 中添加 ESPHome 集成</p>
42
+ <p>2. 输入 Reachy Mini 的 IP 地址</p>
43
+ <p>3. 端口: 6053</p>
44
+ </div>
45
+ </section>
46
+
47
+ <section class="actions">
48
+ <h2>操作</h2>
49
+ <button class="btn btn-primary" id="startBtn">启动</button>
50
+ <button class="btn btn-secondary" id="stopBtn">停止</button>
51
+ </section>
52
+ </main>
53
+
54
+ <footer>
55
+ <p>基于 <a href="https://github.com/OHF-Voice/linux-voice-assistant">OHF-Voice/linux-voice-assistant</a> 修改</p>
56
+ </footer>
57
+ </div>
58
+
59
+ <script src="main.js"></script>
60
+ </body>
61
+ </html>
main.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Reachy Mini Home Assistant Voice Assistant UI
2
+
3
+ document.addEventListener('DOMContentLoaded', function() {
4
+ const statusDot = document.getElementById('statusDot');
5
+ const statusText = document.getElementById('statusText');
6
+ const startBtn = document.getElementById('startBtn');
7
+ const stopBtn = document.getElementById('stopBtn');
8
+
9
+ // Check initial status
10
+ checkStatus();
11
+
12
+ // Start button click handler
13
+ startBtn.addEventListener('click', function() {
14
+ // This will be handled by the Reachy Mini dashboard
15
+ console.log('Start button clicked');
16
+ });
17
+
18
+ // Stop button click handler
19
+ stopBtn.addEventListener('click', function() {
20
+ // This will be handled by the Reachy Mini dashboard
21
+ console.log('Stop button clicked');
22
+ });
23
+
24
+ // Check status periodically
25
+ setInterval(checkStatus, 5000);
26
+ });
27
+
28
+ function checkStatus() {
29
+ // In a real implementation, this would check the actual status
30
+ // For now, we'll just update the UI
31
+ const statusDot = document.getElementById('statusDot');
32
+ const statusText = document.getElementById('statusText');
33
+
34
+ // Simulate status check
35
+ // In production, this would make an API call to get the actual status
36
+ const isRunning = false; // Change this based on actual status
37
+
38
+ if (isRunning) {
39
+ statusDot.classList.add('running');
40
+ statusDot.classList.remove('stopped');
41
+ statusText.textContent = '运行中';
42
+ } else {
43
+ statusDot.classList.add('stopped');
44
+ statusDot.classList.remove('running');
45
+ statusText.textContent = '未运行';
46
+ }
47
+ }
pyproject.toml CHANGED
@@ -29,6 +29,7 @@ dependencies = [
29
  "pyopen-wakeword>=1,<2",
30
  "pyaudio>=0.2.11",
31
  "zeroconf<1",
 
32
  ]
33
 
34
  [project.optional-dependencies]
@@ -50,4 +51,7 @@ include-package-data = true
50
 
51
  [tool.setuptools.packages.find]
52
  include = ["reachy_mini_ha_voice"]
53
- exclude = ["tests", "tests.*"]
 
 
 
 
29
  "pyopen-wakeword>=1,<2",
30
  "pyaudio>=0.2.11",
31
  "zeroconf<1",
32
+ "reachy-mini>=1.0.0",
33
  ]
34
 
35
  [project.optional-dependencies]
 
51
 
52
  [tool.setuptools.packages.find]
53
  include = ["reachy_mini_ha_voice"]
54
+ exclude = ["tests", "tests.*"]
55
+
56
+ [project.entry-points."reachy_mini.apps"]
57
+ reachy_mini_ha_voice = "reachy_mini_ha_voice.app:ReachyMiniHAVoiceApp"
reachy_mini_ha_voice/app.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reachy Mini Home Assistant Voice Assistant App."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+ from queue import Queue
9
+ from typing import Dict, List, Optional, Set, Union
10
+
11
+ import numpy as np
12
+ import pyaudio
13
+ from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
14
+ from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
15
+
16
+ from reachy_mini import ReachyMini, ReachyMiniApp
17
+
18
+ from .models import (
19
+ AvailableWakeWord,
20
+ Preferences,
21
+ ServerState,
22
+ WakeWordType,
23
+ AudioPlayer,
24
+ )
25
+ from .satellite import VoiceSatelliteProtocol
26
+ from .util import get_mac
27
+ from .zeroconf import HomeAssistantZeroconf
28
+
29
+ _LOGGER = logging.getLogger(__name__)
30
+ _MODULE_DIR = Path(__file__).parent
31
+ _REPO_DIR = _MODULE_DIR.parent
32
+ _WAKEWORDS_DIR = _REPO_DIR / "wakewords"
33
+ _SOUNDS_DIR = _REPO_DIR / "sounds"
34
+
35
+
36
+ class ReachyMiniHAVoiceApp(ReachyMiniApp):
37
+ """Home Assistant Voice Assistant for Reachy Mini."""
38
+
39
+ custom_app_url: Optional[str] = None
40
+
41
+ def __init__(self):
42
+ """Initialize the app."""
43
+ self._state: Optional[ServerState] = None
44
+ self._event_loop: Optional[asyncio.AbstractEventLoop] = None
45
+ self._audio_thread: Optional[threading.Thread] = None
46
+ self._server_task: Optional[asyncio.Task] = None
47
+
48
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
49
+ """Run the voice assistant."""
50
+ _LOGGER.info("Starting Reachy Mini Home Assistant Voice Assistant")
51
+
52
+ try:
53
+ # Create event loop
54
+ self._event_loop = asyncio.new_event_loop()
55
+ asyncio.set_event_loop(self._event_loop)
56
+
57
+ # Initialize server state
58
+ self._state = self._init_state(reachy_mini)
59
+
60
+ # Start audio processing thread
61
+ self._audio_thread = threading.Thread(
62
+ target=self._process_audio,
63
+ args=(self._state,),
64
+ daemon=True,
65
+ )
66
+ self._audio_thread.start()
67
+
68
+ # Start ESPHome server
69
+ self._server_task = self._event_loop.create_task(
70
+ self._run_server(self._state)
71
+ )
72
+ self._event_loop.run_until_complete(self._server_task)
73
+
74
+ except Exception as e:
75
+ _LOGGER.error("Error running voice assistant: %s", e)
76
+ finally:
77
+ _LOGGER.info("Shutting down voice assistant")
78
+ if self._audio_thread:
79
+ self._audio_thread.join(timeout=5)
80
+ if self._event_loop:
81
+ self._event_loop.close()
82
+
83
+ def _init_state(self, reachy_mini: ReachyMini) -> ServerState:
84
+ """Initialize server state."""
85
+ # Load wake words
86
+ available_wake_words = self._load_wake_words()
87
+
88
+ # Load active wake words
89
+ active_wake_words = set()
90
+ wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
91
+
92
+ # Use default wake word
93
+ default_wake_word = "okay_nabu"
94
+ if default_wake_word in available_wake_words:
95
+ try:
96
+ wake_word = available_wake_words[default_wake_word]
97
+ wake_models[default_wake_word] = wake_word.load()
98
+ active_wake_words.add(default_wake_word)
99
+ _LOGGER.info("Loaded wake word: %s", default_wake_word)
100
+ except Exception as e:
101
+ _LOGGER.error("Failed to load wake word %s: %s", default_wake_word, e)
102
+
103
+ # Load stop model
104
+ stop_model = self._load_stop_model()
105
+
106
+ # Create audio players
107
+ tts_player = AudioPlayer()
108
+
109
+ return ServerState(
110
+ name="ReachyMini",
111
+ mac_address=get_mac(),
112
+ audio_queue=Queue(),
113
+ entities=[],
114
+ available_wake_words=available_wake_words,
115
+ wake_words=wake_models,
116
+ active_wake_words=active_wake_words,
117
+ stop_word=stop_model,
118
+ music_player=AudioPlayer(),
119
+ tts_player=tts_player,
120
+ wakeup_sound=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
121
+ timer_finished_sound=str(_SOUNDS_DIR / "timer_finished.flac"),
122
+ preferences=Preferences(),
123
+ preferences_path=_REPO_DIR / "preferences.json",
124
+ refractory_seconds=2.0,
125
+ download_dir=_REPO_DIR / "local",
126
+ reachy_integration=None, # Not using Reachy integration for now
127
+ )
128
+
129
+ def _load_wake_words(self) -> Dict[str, AvailableWakeWord]:
130
+ """Load available wake words."""
131
+ available_wake_words: Dict[str, AvailableWakeWord] = {}
132
+
133
+ for wake_word_dir in [_WAKEWORDS_DIR]:
134
+ if not wake_word_dir.exists():
135
+ continue
136
+
137
+ for model_config_path in wake_word_dir.glob("*.json"):
138
+ model_id = model_config_path.stem
139
+ if model_id == "stop":
140
+ continue
141
+
142
+ try:
143
+ import json
144
+
145
+ with open(model_config_path, "r", encoding="utf-8") as f:
146
+ model_config = json.load(f)
147
+ model_type = WakeWordType(
148
+ model_config.get("type", "microWakeWord")
149
+ )
150
+ if model_type == WakeWordType.OPEN_WAKE_WORD:
151
+ wake_word_path = model_config_path.parent / model_config["model"]
152
+ else:
153
+ wake_word_path = model_config_path
154
+
155
+ available_wake_words[model_id] = AvailableWakeWord(
156
+ id=model_id,
157
+ type=model_type,
158
+ wake_word=model_config["wake_word"],
159
+ trained_languages=model_config.get("trained_languages", []),
160
+ wake_word_path=wake_word_path,
161
+ )
162
+ except Exception as e:
163
+ _LOGGER.error(
164
+ "Error loading wake word config %s: %s", model_config_path, e
165
+ )
166
+
167
+ return available_wake_words
168
+
169
+ def _load_stop_model(self) -> Optional[MicroWakeWord]:
170
+ """Load stop word model."""
171
+ stop_config_path = _WAKEWORDS_DIR / "stop.json"
172
+ if not stop_config_path.exists():
173
+ return None
174
+
175
+ try:
176
+ return MicroWakeWord.from_config(stop_config_path)
177
+ except Exception as e:
178
+ _LOGGER.error("Failed to load stop model: %s", e)
179
+ return None
180
+
181
+ async def _run_server(self, state: ServerState) -> None:
182
+ """Run ESPHome server."""
183
+ # Start ESPHome server
184
+ loop = asyncio.get_running_loop()
185
+ server = await loop.create_server(
186
+ lambda: VoiceSatelliteProtocol(state), host="0.0.0.0", port=6053
187
+ )
188
+
189
+ # Auto discovery (zeroconf, mDNS)
190
+ discovery = HomeAssistantZeroconf(port=6053, name="ReachyMini")
191
+ await discovery.register_server()
192
+
193
+ try:
194
+ async with server:
195
+ _LOGGER.info("ESPHome server started on port 6053")
196
+ await server.serve_forever()
197
+ finally:
198
+ await discovery.unregister_server()
199
+
200
+ def _process_audio(self, state: ServerState) -> None:
201
+ """Process audio from microphone."""
202
+ import pyaudio
203
+
204
+ p = pyaudio.PyAudio()
205
+
206
+ # Get input device
207
+ device_index = None
208
+ for i in range(p.get_device_count()):
209
+ info = p.get_device_info_by_index(i)
210
+ if info["maxInputChannels"] > 0:
211
+ device_index = i
212
+ break
213
+
214
+ if device_index is None:
215
+ _LOGGER.error("No audio input device found")
216
+ return
217
+
218
+ CHUNK = 1024
219
+ FORMAT = pyaudio.paInt16
220
+ CHANNELS = 1
221
+ RATE = 16000
222
+
223
+ try:
224
+ stream = p.open(
225
+ format=FORMAT,
226
+ channels=CHANNELS,
227
+ rate=RATE,
228
+ input=True,
229
+ input_device_index=device_index,
230
+ frames_per_buffer=CHUNK,
231
+ )
232
+
233
+ wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
234
+ micro_features: Optional[MicroWakeWordFeatures] = None
235
+ micro_inputs: List[np.ndarray] = []
236
+
237
+ oww_features: Optional[OpenWakeWordFeatures] = None
238
+ oww_inputs: List[np.ndarray] = []
239
+ has_oww = False
240
+
241
+ last_active: Optional[float] = None
242
+
243
+ _LOGGER.info("Audio processing started")
244
+
245
+ while True:
246
+ try:
247
+ data = stream.read(CHUNK, exception_on_overflow=False)
248
+ audio_array = (
249
+ np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
250
+ )
251
+
252
+ # Send to satellite if connected
253
+ if state.satellite is not None:
254
+ state.satellite.handle_audio(data)
255
+
256
+ # Update wake word models
257
+ if (not wake_words) or (state.wake_words_changed and state.wake_words):
258
+ state.wake_words_changed = False
259
+ wake_words = [
260
+ ww
261
+ for ww in state.wake_words.values()
262
+ if ww.id in state.active_wake_words
263
+ ]
264
+
265
+ has_oww = False
266
+ for wake_word in wake_words:
267
+ if isinstance(wake_word, OpenWakeWord):
268
+ has_oww = True
269
+
270
+ if micro_features is None:
271
+ micro_features = MicroWakeWordFeatures()
272
+
273
+ if has_oww and (oww_features is None):
274
+ oww_features = OpenWakeWordFeatures.from_builtin()
275
+
276
+ # Process wake words
277
+ if wake_words:
278
+ assert micro_features is not None
279
+ micro_inputs.clear()
280
+ micro_inputs.extend(micro_features.process_streaming(data))
281
+
282
+ if has_oww:
283
+ assert oww_features is not None
284
+ oww_inputs.clear()
285
+ oww_inputs.extend(oww_features.process_streaming(data))
286
+
287
+ for wake_word in wake_words:
288
+ activated = False
289
+ if isinstance(wake_word, MicroWakeWord):
290
+ for micro_input in micro_inputs:
291
+ if wake_word.process_streaming(micro_input):
292
+ activated = True
293
+ elif isinstance(wake_word, OpenWakeWord):
294
+ for oww_input in oww_inputs:
295
+ for prob in wake_word.process_streaming(oww_input):
296
+ if prob > 0.5:
297
+ activated = True
298
+
299
+ if activated:
300
+ now = time.monotonic()
301
+ if (last_active is None) or (
302
+ (now - last_active) > state.refractory_seconds
303
+ ):
304
+ if state.satellite:
305
+ state.satellite.wakeup(wake_word)
306
+ last_active = now
307
+
308
+ # Process stop word
309
+ if state.stop_word is not None:
310
+ stopped = False
311
+ for micro_input in micro_inputs:
312
+ if state.stop_word.process_streaming(micro_input):
313
+ stopped = True
314
+
315
+ if stopped and (state.stop_word.id in state.active_wake_words):
316
+ if state.satellite:
317
+ state.satellite.stop()
318
+
319
+ except Exception as e:
320
+ _LOGGER.error("Error processing audio: %s", e)
321
+ time.sleep(0.1)
322
+
323
+ except Exception as e:
324
+ _LOGGER.error("Error opening audio stream: %s", e)
325
+ finally:
326
+ stream.stop_stream()
327
+ stream.close()
328
+ p.terminate()
329
+ _LOGGER.info("Audio processing stopped")
style.css ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
9
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
10
+ min-height: 100vh;
11
+ padding: 20px;
12
+ }
13
+
14
+ .container {
15
+ max-width: 800px;
16
+ margin: 0 auto;
17
+ background: white;
18
+ border-radius: 20px;
19
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
20
+ overflow: hidden;
21
+ }
22
+
23
+ header {
24
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
25
+ color: white;
26
+ padding: 40px;
27
+ text-align: center;
28
+ }
29
+
30
+ header h1 {
31
+ font-size: 2.5em;
32
+ margin-bottom: 10px;
33
+ }
34
+
35
+ .subtitle {
36
+ font-size: 1.2em;
37
+ opacity: 0.9;
38
+ }
39
+
40
+ main {
41
+ padding: 40px;
42
+ }
43
+
44
+ section {
45
+ margin-bottom: 40px;
46
+ }
47
+
48
+ h2 {
49
+ color: #333;
50
+ margin-bottom: 20px;
51
+ font-size: 1.8em;
52
+ }
53
+
54
+ .status-indicator {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 15px;
58
+ padding: 20px;
59
+ background: #f5f5f5;
60
+ border-radius: 10px;
61
+ }
62
+
63
+ .status-dot {
64
+ width: 20px;
65
+ height: 20px;
66
+ border-radius: 50%;
67
+ background: #ccc;
68
+ transition: background 0.3s;
69
+ }
70
+
71
+ .status-dot.running {
72
+ background: #4CAF50;
73
+ animation: pulse 2s infinite;
74
+ }
75
+
76
+ .status-dot.stopped {
77
+ background: #f44336;
78
+ }
79
+
80
+ @keyframes pulse {
81
+ 0%, 100% {
82
+ opacity: 1;
83
+ }
84
+ 50% {
85
+ opacity: 0.5;
86
+ }
87
+ }
88
+
89
+ .status-text {
90
+ font-size: 1.2em;
91
+ color: #666;
92
+ }
93
+
94
+ .info-card {
95
+ background: #f9f9f9;
96
+ padding: 20px;
97
+ border-radius: 10px;
98
+ margin-bottom: 15px;
99
+ border-left: 4px solid #667eea;
100
+ }
101
+
102
+ .info-card h3 {
103
+ color: #667eea;
104
+ margin-bottom: 10px;
105
+ }
106
+
107
+ .info-card p {
108
+ color: #666;
109
+ line-height: 1.6;
110
+ margin-bottom: 5px;
111
+ }
112
+
113
+ .actions {
114
+ text-align: center;
115
+ }
116
+
117
+ .btn {
118
+ padding: 15px 40px;
119
+ font-size: 1.1em;
120
+ border: none;
121
+ border-radius: 10px;
122
+ cursor: pointer;
123
+ transition: all 0.3s;
124
+ margin: 0 10px;
125
+ }
126
+
127
+ .btn-primary {
128
+ background: #667eea;
129
+ color: white;
130
+ }
131
+
132
+ .btn-primary:hover {
133
+ background: #5568d3;
134
+ transform: translateY(-2px);
135
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
136
+ }
137
+
138
+ .btn-secondary {
139
+ background: #f44336;
140
+ color: white;
141
+ }
142
+
143
+ .btn-secondary:hover {
144
+ background: #da190b;
145
+ transform: translateY(-2px);
146
+ box-shadow: 0 5px 15px rgba(244, 67, 54, 0.4);
147
+ }
148
+
149
+ footer {
150
+ background: #f5f5f5;
151
+ padding: 20px;
152
+ text-align: center;
153
+ color: #666;
154
+ }
155
+
156
+ footer a {
157
+ color: #667eea;
158
+ text-decoration: none;
159
+ }
160
+
161
+ footer a:hover {
162
+ text-decoration: underline;
163
+ }