drenayaz commited on
Commit
7e89a64
·
0 Parent(s):

Initial commit

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.egg-info/
README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Reachy Mini Controller
3
+ emoji: 👋
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Write your description here
9
+ tags:
10
+ - reachy_mini
11
+ ---
index.html ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width" />
7
+ <title> Reachy Mini Controller </title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+
11
+ <body>
12
+ <div class="hero">
13
+ <div class="hero-content">
14
+ <div class="app-icon">🤖⚡</div>
15
+ <h1> Reachy Mini Controller </h1>
16
+ <p class="tagline">Enter your tagline here</p>
17
+ </div>
18
+ </div>
19
+
20
+ <div class="container">
21
+ <div class="main-card">
22
+ <div class="app-preview">
23
+ <div class="preview-image">
24
+ <div class="camera-feed">🛠️</div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="download-section">
31
+ <div class="download-card">
32
+ <h2>Install This App</h2>
33
+
34
+ <div class="dashboard-config">
35
+ <label for="dashboardUrl">Your Reachy Dashboard URL:</label>
36
+ <input type="url" id="dashboardUrl" value="http://localhost:8000"
37
+ placeholder="http://your-reachy-ip:8000" />
38
+ </div>
39
+
40
+ <button id="installBtn" class="install-btn primary">
41
+ <span class="btn-icon">📥</span>
42
+ Install Reachy Mini Controller to Reachy Mini
43
+ </button>
44
+
45
+ <div id="installStatus" class="install-status"></div>
46
+
47
+ </div>
48
+ </div>
49
+
50
+ <div class="footer">
51
+ <p>
52
+ 🤖 Reachy Mini Controller •
53
+ <a href="https://github.com/pollen-robotics" target="_blank">Pollen Robotics</a> •
54
+ <a href="https://huggingface.co/spaces/pollen-robotics/Reachy_Mini_Apps" target="_blank">Browse More
55
+ Apps</a>
56
+ </p>
57
+ </div>
58
+ </div>
59
+
60
+ <script>
61
+ // Get the current Hugging Face Space URL as the repository URL
62
+ function getCurrentSpaceUrl() {
63
+ // Get current page URL and convert to repository format
64
+ const currentUrl = window.location.href;
65
+
66
+ // Remove any trailing slashes and query parameters
67
+ const cleanUrl = currentUrl.split('?')[0].replace(/\/$/, '');
68
+
69
+ return cleanUrl;
70
+ }
71
+
72
+ // Parse TOML content to extract project name
73
+ function parseTomlProjectName(tomlContent) {
74
+ try {
75
+ const lines = tomlContent.split('\n');
76
+ let inProjectSection = false;
77
+
78
+ for (const line of lines) {
79
+ const trimmedLine = line.trim();
80
+
81
+ // Check if we're entering the [project] section
82
+ if (trimmedLine === '[project]') {
83
+ inProjectSection = true;
84
+ continue;
85
+ }
86
+
87
+ // Check if we're entering a different section
88
+ if (trimmedLine.startsWith('[') && trimmedLine !== '[project]') {
89
+ inProjectSection = false;
90
+ continue;
91
+ }
92
+
93
+ // If we're in the project section, look for the name field
94
+ if (inProjectSection && trimmedLine.startsWith('name')) {
95
+ const match = trimmedLine.match(/name\s*=\s*["']([^"']+)["']/);
96
+ if (match) {
97
+ // Convert to lowercase and replace invalid characters for app naming
98
+ return match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-');
99
+ }
100
+ }
101
+ }
102
+
103
+ throw new Error('Project name not found in pyproject.toml');
104
+ } catch (error) {
105
+ console.error('Error parsing pyproject.toml:', error);
106
+ return 'unknown-app';
107
+ }
108
+ }
109
+
110
+ // Fetch and parse pyproject.toml from the current space
111
+ async function getAppNameFromCurrentSpace() {
112
+ try {
113
+ // Fetch pyproject.toml from the current space
114
+ const response = await fetch('./pyproject.toml');
115
+ if (!response.ok) {
116
+ throw new Error(`Failed to fetch pyproject.toml: ${response.status}`);
117
+ }
118
+
119
+ const tomlContent = await response.text();
120
+ return parseTomlProjectName(tomlContent);
121
+ } catch (error) {
122
+ console.error('Error fetching app name from current space:', error);
123
+ // Fallback to extracting from URL if pyproject.toml is not accessible
124
+ const url = getCurrentSpaceUrl();
125
+ const parts = url.split('/');
126
+ const spaceName = parts[parts.length - 1];
127
+ return spaceName.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
128
+ }
129
+ }
130
+
131
+ async function installToReachy() {
132
+ const dashboardUrl = document.getElementById('dashboardUrl').value.trim();
133
+ const statusDiv = document.getElementById('installStatus');
134
+ const installBtn = document.getElementById('installBtn');
135
+
136
+ if (!dashboardUrl) {
137
+ showStatus('error', 'Please enter your Reachy dashboard URL');
138
+ return;
139
+ }
140
+
141
+ try {
142
+ installBtn.disabled = true;
143
+ installBtn.innerHTML = '<span class="btn-icon">⏳</span>Installing...';
144
+ showStatus('loading', 'Connecting to your Reachy dashboard...');
145
+
146
+ // Test connection
147
+ const testResponse = await fetch(`${dashboardUrl}/api/status`, {
148
+ method: 'GET',
149
+ mode: 'cors',
150
+ });
151
+
152
+ if (!testResponse.ok) {
153
+ throw new Error('Cannot connect to dashboard. Make sure the URL is correct and the dashboard is running.');
154
+ }
155
+
156
+ showStatus('loading', 'Reading app configuration...');
157
+
158
+ // Get app name from pyproject.toml in current space
159
+ const appName = await getAppNameFromCurrentSpace();
160
+
161
+ // Get current space URL as repository URL
162
+ const repoUrl = getCurrentSpaceUrl();
163
+
164
+ showStatus('loading', `Starting installation of "${appName}"...`);
165
+
166
+ // Start installation
167
+ const installResponse = await fetch(`${dashboardUrl}/api/install`, {
168
+ method: 'POST',
169
+ mode: 'cors',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ },
173
+ body: JSON.stringify({
174
+ url: repoUrl,
175
+ name: appName
176
+ })
177
+ });
178
+
179
+ const result = await installResponse.json();
180
+
181
+ if (installResponse.ok) {
182
+ showStatus('success', `✅ Installation started for "${appName}"! Check your dashboard for progress.`);
183
+ setTimeout(() => {
184
+ showStatus('info', `Open your dashboard at ${dashboardUrl} to see the installed app.`);
185
+ }, 3000);
186
+ } else {
187
+ throw new Error(result.detail || 'Installation failed');
188
+ }
189
+
190
+ } catch (error) {
191
+ console.error('Installation error:', error);
192
+ showStatus('error', `❌ ${error.message}`);
193
+ } finally {
194
+ installBtn.disabled = false;
195
+ installBtn.innerHTML = '<span class="btn-icon">📥</span>Install App to Reachy';
196
+ }
197
+ }
198
+
199
+ function showStatus(type, message) {
200
+ const statusDiv = document.getElementById('installStatus');
201
+ statusDiv.className = `install-status ${type}`;
202
+ statusDiv.textContent = message;
203
+ statusDiv.style.display = 'block';
204
+ }
205
+
206
+ function copyToClipboard() {
207
+ const repoUrl = document.getElementById('repoUrl').textContent;
208
+ navigator.clipboard.writeText(repoUrl).then(() => {
209
+ showStatus('success', '📋 Repository URL copied to clipboard!');
210
+ }).catch(() => {
211
+ showStatus('error', 'Failed to copy URL. Please copy manually.');
212
+ });
213
+ }
214
+
215
+ // Update the displayed repository URL on page load
216
+ document.addEventListener('DOMContentLoaded', () => {
217
+ // Auto-detect local dashboard
218
+ const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
219
+ if (isLocalhost) {
220
+ document.getElementById('dashboardUrl').value = 'http://localhost:8000';
221
+ }
222
+
223
+ // Update the repository URL display if element exists
224
+ const repoUrlElement = document.getElementById('repoUrl');
225
+ if (repoUrlElement) {
226
+ repoUrlElement.textContent = getCurrentSpaceUrl();
227
+ }
228
+ });
229
+
230
+ // Event listeners
231
+ document.getElementById('installBtn').addEventListener('click', installToReachy);
232
+ </script>
233
+ </body>
234
+
235
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+
6
+ [project]
7
+ name = "reachy_mini_controller"
8
+ version = "0.1.0"
9
+ description = "Add your description here"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "reachy-mini",
14
+ "pynput"
15
+ ]
16
+ keywords = ["reachy-mini-app"]
17
+
18
+ [project.entry-points."reachy_mini_apps"]
19
+ reachy_mini_controller = "reachy_mini_controller.main:ReachyMiniController"
20
+
21
+ [tool.setuptools]
22
+ package-dir = { "" = "." }
23
+ include-package-data = true
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["."]
27
+
28
+ [tool.setuptools.package-data]
29
+ reachy_mini_controller = ["**/*"] # Also include all non-.py files
reachy_mini_controller/__init__.py ADDED
File without changes
reachy_mini_controller/key_bindings.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "key_binding": {
3
+ "move_forward": "w",
4
+ "move_backward": "s",
5
+ "move_left": "a",
6
+ "move_right": "d",
7
+ "move_up": "q",
8
+ "move_down": "e",
9
+ "turn_yaw_clockwise": "u",
10
+ "turn_yaw_counterclockwise": "o",
11
+ "turn_pitch_clockwise": "i",
12
+ "turn_pitch_counterclockwise": "k",
13
+ "turn_roll_clockwise": "j",
14
+ "turn_roll_counterclockwise": "l",
15
+ "move_right_antenna_clockwise": "b",
16
+ "move_right_antenna_counterclockwise": "n",
17
+ "move_left_antenna_clockwise": "z",
18
+ "move_left_antenna_counterclockwise": "x",
19
+ "turn_body_yaw_clockwise": "c",
20
+ "turn_body_yaw_counterclockwise": "v"
21
+ },
22
+ "speed": {
23
+ "x_speed": 1.0,
24
+ "y_speed": 1.0,
25
+ "z_speed": 1.0,
26
+ "roll_speed": 1.0,
27
+ "pitch_speed": 1.0,
28
+ "yaw_speed": 1.0,
29
+ "left_antenna_speed": 1.0,
30
+ "right_antenna_speed": 0.9,
31
+ "body_speed": 1.0
32
+ }
33
+ }
reachy_mini_controller/key_bindings.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ class KeyBindings:
5
+ def __init__(self, path: Path):
6
+ self.path = path
7
+ self.data = {}
8
+ self.load()
9
+
10
+ def load(self):
11
+ """Charge le JSON complet (touches + vitesses)."""
12
+ if not self.path.exists():
13
+ raise FileNotFoundError(f"Fichier {self.path} introuvable")
14
+
15
+ with open(self.path, "r") as f:
16
+ self.data = json.load(f)
17
+
18
+ self.data.setdefault("key_binding", {})
19
+ self.data.setdefault("speed", {})
20
+
21
+ def save(self):
22
+ """Sauvegarde tout (touches + vitesses)."""
23
+ with open(self.path, "w") as f:
24
+ json.dump(self.data, f, indent=4)
25
+
26
+ def get(self, action: str):
27
+ """Retourne la touche associée à une action."""
28
+ return self.data["key_binding"].get(action)
29
+
30
+ def set_key(self, action: str, key: str):
31
+ """Change une touche et sauvegarde."""
32
+ self.data["key_binding"][action] = key
33
+ self.save()
34
+
35
+ def get_all_keys(self):
36
+ """Retourne toutes les actions → touches."""
37
+ return self.data["key_binding"]
38
+
39
+ def get_speed(self, speed_name: str):
40
+ """Retourne une valeur de vitesse."""
41
+ return self.data["speed"].get(speed_name)
42
+
43
+ def set_speed(self, speed_name: str, value: float | int):
44
+ """Modifie une vitesse et sauvegarde."""
45
+ self.data["speed"][speed_name] = value
46
+ self.save()
47
+
48
+ def get_all_speed(self):
49
+ """Retourne toutes les vitesses."""
50
+ return self.data["speed"]
reachy_mini_controller/key_listener.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pynput import keyboard
2
+
3
+ class KeyListener:
4
+ def __init__(self):
5
+ self.pressed = set()
6
+ self.listener = keyboard.Listener(
7
+ on_press=self.on_press,
8
+ on_release=self.on_release
9
+ )
10
+ self.listener.start()
11
+
12
+ def on_press(self, key):
13
+ try:
14
+ self.pressed.add(key.char)
15
+ except AttributeError:
16
+ pass
17
+
18
+ def on_release(self, key):
19
+ try:
20
+ self.pressed.discard(key.char)
21
+ except AttributeError:
22
+ pass
23
+
24
+ def is_pressed(self, key_char):
25
+ return key_char in self.pressed
reachy_mini_controller/main.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ from reachy_mini import ReachyMini, ReachyMiniApp
3
+ from reachy_mini.utils import create_head_pose
4
+ import time
5
+ from pydantic import BaseModel
6
+ from reachy_mini_controller.key_bindings import KeyBindings
7
+ from reachy_mini_controller.key_listener import KeyListener
8
+ from pathlib import Path
9
+
10
+
11
+ KEY_BINDINGS_FILE = Path(__file__).parent / "key_bindings.json"
12
+
13
+ ROLL_DELTA = 1.0 # degrees
14
+ PITCH_DELTA = 1.0 # degrees
15
+ YAW_DELTA = 1.0 # degrees
16
+ X_DELTA = 0.001 # meters
17
+ Y_DELTA = 0.001 # meters
18
+ Z_DELTA = 0.001 # meters
19
+ BODY_YAW_DELTA = 0.05 # degrees
20
+ RIGHT_ANTENNA_DELTA = 0.08 # degrees
21
+ LEFT_ANTENNA_DELTA = 0.08 # degrees
22
+ FREQUENCE = 100 # Hz
23
+
24
+
25
+
26
+ class ReachyMiniController(ReachyMiniApp):
27
+ # Optional: URL to a custom configuration page for the app
28
+ # eg. "http://localhost:8042"
29
+ custom_app_url: str | None = "http://0.0.0.0:8042"
30
+
31
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
32
+
33
+ kb = KeyBindings(KEY_BINDINGS_FILE)
34
+ keys = KeyListener()
35
+
36
+ class ChangeKeyRequest(BaseModel):
37
+ action: str
38
+ key: str
39
+
40
+ @self.settings_app.get("/get_key_bindings")
41
+ def get_key_bindings():
42
+ return kb.get_all_keys()
43
+
44
+ @self.settings_app.post("/change_key")
45
+ def change_key(req: ChangeKeyRequest):
46
+ kb.set_key(req.action, req.key)
47
+ return {"status": "ok"}
48
+
49
+ class ChangeSpeedRequest(BaseModel):
50
+ name: str
51
+ value: float
52
+
53
+ @self.settings_app.get("/get_speeds")
54
+ def get_speeds():
55
+ return kb.get_all_speed()
56
+
57
+ @self.settings_app.post("/change_speed")
58
+ def change_speed(req: ChangeSpeedRequest):
59
+ kb.set_speed(req.name, req.value)
60
+ return {"status": "ok"}
61
+
62
+ previous_pose = {
63
+ "roll": 0.0,
64
+ "pitch": 0.0,
65
+ "yaw": 0.0,
66
+ "x": 0.0,
67
+ "y": 0.0,
68
+ "z": 0.0,
69
+ "right_antenna": 0.0,
70
+ "left_antenna": 0.0,
71
+ "body_yaw": 0.0
72
+ }
73
+
74
+ head_pose = create_head_pose(
75
+ previous_pose["x"],
76
+ previous_pose["y"],
77
+ previous_pose["z"],
78
+ previous_pose["roll"],
79
+ previous_pose["pitch"],
80
+ previous_pose["yaw"]
81
+ )
82
+ reachy_mini.goto_target(
83
+ head=head_pose,
84
+ antennas=[previous_pose["right_antenna"], previous_pose["left_antenna"]],
85
+ body_yaw=previous_pose["body_yaw"],
86
+ duration=1.0
87
+ )
88
+ while not stop_event.is_set():
89
+ t0 = time.time()
90
+ new_poses = previous_pose.copy()
91
+
92
+ if keys.is_pressed(kb.get("move_forward")):
93
+ x_speed = kb.get_speed("x_speed")
94
+ new_poses["x"] += X_DELTA * x_speed
95
+ # print("move forward")
96
+
97
+ if keys.is_pressed(kb.get("move_backward")):
98
+ x_speed = kb.get_speed("x_speed")
99
+ new_poses["x"] -= X_DELTA * x_speed
100
+ # print("move backward")
101
+
102
+ if keys.is_pressed(kb.get("move_left")):
103
+ y_speed = kb.get_speed("y_speed")
104
+ new_poses["y"] += Y_DELTA * y_speed
105
+ # print("move left")
106
+
107
+ if keys.is_pressed(kb.get("move_right")):
108
+ y_speed = kb.get_speed("y_speed")
109
+ new_poses["y"] -= Y_DELTA * y_speed
110
+ # print("move right")
111
+
112
+ if keys.is_pressed(kb.get("move_up")):
113
+ z_speed = kb.get_speed("z_speed")
114
+ new_poses["z"] += Z_DELTA * z_speed
115
+ # print("move up")
116
+
117
+ if keys.is_pressed(kb.get("move_down")):
118
+ z_speed = kb.get_speed("z_speed")
119
+ new_poses["z"] -= Z_DELTA * z_speed
120
+ # print("move down")
121
+
122
+ if keys.is_pressed(kb.get("turn_yaw_clockwise")):
123
+ yaw_speed = kb.get_speed("yaw_speed")
124
+ new_poses["yaw"] -= YAW_DELTA * yaw_speed
125
+ # print("turn yaw clockwise")
126
+
127
+ if keys.is_pressed(kb.get("turn_yaw_counterclockwise")):
128
+ yaw_speed = kb.get_speed("yaw_speed")
129
+ new_poses["yaw"] += YAW_DELTA * yaw_speed
130
+ # print("turn yaw counterclockwise")
131
+
132
+ if keys.is_pressed(kb.get("turn_pitch_clockwise")):
133
+ pitch_speed = kb.get_speed("pitch_speed")
134
+ new_poses["pitch"] += PITCH_DELTA * pitch_speed
135
+ # print("turn pitch clockwise")
136
+
137
+ if keys.is_pressed(kb.get("turn_pitch_counterclockwise")):
138
+ pitch_speed = kb.get_speed("pitch_speed")
139
+ new_poses["pitch"] -= PITCH_DELTA * pitch_speed
140
+ # print("turn pitch counterclockwise")
141
+
142
+ if keys.is_pressed(kb.get("turn_roll_clockwise")):
143
+ roll_speed = kb.get_speed("roll_speed")
144
+ new_poses["roll"] += ROLL_DELTA * roll_speed
145
+ # print("turn roll clockwise")
146
+
147
+ if keys.is_pressed(kb.get("turn_roll_counterclockwise")):
148
+ roll_speed = kb.get_speed("roll_speed")
149
+ new_poses["roll"] -= ROLL_DELTA * roll_speed
150
+ # print("turn roll counterclockwise")
151
+
152
+ if keys.is_pressed(kb.get("move_right_antenna_clockwise")):
153
+ right_antenna_speed = kb.get_speed("right_antenna_speed")
154
+ new_poses["right_antenna"] -= RIGHT_ANTENNA_DELTA * right_antenna_speed
155
+ # print("move right antenna clockwise")
156
+
157
+ if keys.is_pressed(kb.get("move_right_antenna_counterclockwise")):
158
+ right_antenna_speed = kb.get_speed("right_antenna_speed")
159
+ new_poses["right_antenna"] += RIGHT_ANTENNA_DELTA * right_antenna_speed
160
+ # print("move right antenna counterclockwise")
161
+
162
+ if keys.is_pressed(kb.get("move_left_antenna_clockwise")):
163
+ left_antenna_speed = kb.get_speed("left_antenna_speed")
164
+ new_poses["left_antenna"] -= LEFT_ANTENNA_DELTA * left_antenna_speed
165
+ # print("move left antenna clockwise")
166
+
167
+ if keys.is_pressed(kb.get("move_left_antenna_counterclockwise")):
168
+ left_antenna_speed = kb.get_speed("left_antenna_speed")
169
+ new_poses["left_antenna"] += LEFT_ANTENNA_DELTA * left_antenna_speed
170
+ # print("move left antenna counterclockwise")
171
+
172
+ if keys.is_pressed(kb.get("turn_body_yaw_clockwise")):
173
+ body_speed = kb.get_speed("body_speed")
174
+ new_poses["body_yaw"] += BODY_YAW_DELTA * body_speed
175
+ # print("turn body yaw clockwise")
176
+
177
+ if keys.is_pressed(kb.get("turn_body_yaw_counterclockwise")):
178
+ body_speed = kb.get_speed("body_speed")
179
+ new_poses["body_yaw"] -= BODY_YAW_DELTA * body_speed
180
+ # print("turn body yaw counterclockwise")
181
+
182
+ head_pose = create_head_pose(
183
+ new_poses["x"],
184
+ new_poses["y"],
185
+ new_poses["z"],
186
+ new_poses["roll"],
187
+ new_poses["pitch"],
188
+ new_poses["yaw"],
189
+ degrees=True
190
+ )
191
+ reachy_mini.set_target(
192
+ head=head_pose,
193
+ antennas=[new_poses["right_antenna"], new_poses["left_antenna"]],
194
+ body_yaw=new_poses["body_yaw"],
195
+ )
196
+ previous_pose = new_poses
197
+
198
+ time.sleep(max(0, 1.0 / FREQUENCE - (time.time() - t0)))
199
+
200
+
201
+ if __name__ == "__main__":
202
+ app = ReachyMiniController()
203
+ app.wrapped_run()
reachy_mini_controller/static/index.html ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>Key Binding Config</title>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <link rel="stylesheet" href="/static/style.css">
9
+ </head>
10
+
11
+ <body>
12
+
13
+ <div class="container">
14
+
15
+ <h1>Key Binding Configuration</h1>
16
+
17
+ <div id="status" class="status"></div>
18
+
19
+ <table>
20
+ <thead>
21
+ <tr>
22
+ <th>Action</th>
23
+ <th>Current Key</th>
24
+ <th>Change</th>
25
+ </tr>
26
+ </thead>
27
+ <tbody id="bindings-table"></tbody>
28
+ </table>
29
+
30
+ <hr>
31
+
32
+ <h2>Speed Settings</h2>
33
+
34
+ <div id="speed-container">
35
+ </div>
36
+
37
+ </div>
38
+
39
+ <script src="/static/main.js"></script>
40
+
41
+ </body>
42
+
43
+ </html>
reachy_mini_controller/static/main.js ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let currentBindings = {};
2
+ let currentSpeeds = {};
3
+ let waitingForKey = null;
4
+
5
+
6
+ async function loadBindings() {
7
+ try {
8
+ const resp = await fetch("/get_key_bindings");
9
+ currentBindings = await resp.json();
10
+ renderBindingsTable();
11
+ } catch (e) {
12
+ console.error(e);
13
+ document.getElementById("status").textContent =
14
+ "Error loading key bindings";
15
+ }
16
+ }
17
+
18
+ async function loadSpeeds() {
19
+ try {
20
+ const resp = await fetch("/get_speeds");
21
+ currentSpeeds = await resp.json();
22
+ renderSpeedSliders();
23
+ } catch (e) {
24
+ console.error(e);
25
+ document.getElementById("status").textContent =
26
+ "Error loading speeds";
27
+ }
28
+ }
29
+
30
+
31
+ function renderBindingsTable() {
32
+ const tbody = document.getElementById("bindings-table");
33
+ tbody.innerHTML = "";
34
+
35
+ for (const action in currentBindings) {
36
+ const key = currentBindings[action];
37
+
38
+ const row = document.createElement("tr");
39
+
40
+ row.innerHTML = `
41
+ <td>${action}</td>
42
+ <td id="current-${action}">${key}</td>
43
+ <td><button class="change-btn" onclick="startKeyChange('${action}')">Change</button></td>
44
+ `;
45
+
46
+ tbody.appendChild(row);
47
+ }
48
+ }
49
+
50
+
51
+ function renderSpeedSliders() {
52
+ const container = document.getElementById("speed-container");
53
+ container.innerHTML = "";
54
+
55
+ for (const speedName in currentSpeeds) {
56
+ const value = currentSpeeds[speedName];
57
+
58
+ const block = document.createElement("div");
59
+ block.className = "speed-block";
60
+
61
+ block.innerHTML = `
62
+ <label for="speed-${speedName}">
63
+ ${speedName} :
64
+ <span id="value-${speedName}">${value}</span>
65
+ </label>
66
+ <input
67
+ type="range"
68
+ min="0" max="3" step="0.1"
69
+ id="speed-${speedName}"
70
+ value="${value}"
71
+ oninput="updateSpeedValue('${speedName}', this.value)"
72
+ onchange="sendSpeedChange('${speedName}', this.value)"
73
+ >
74
+ `;
75
+
76
+ container.appendChild(block);
77
+ }
78
+ }
79
+
80
+ function updateSpeedValue(speedName, val) {
81
+ document.getElementById(`value-${speedName}`).textContent = val;
82
+ }
83
+
84
+ async function sendSpeedChange(name, value) {
85
+ try {
86
+ const resp = await fetch("/change_speed", {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ name, value: parseFloat(value) }),
90
+ });
91
+
92
+ const data = await resp.json();
93
+
94
+ if (data.status === "ok") {
95
+ currentSpeeds[name] = value;
96
+ document.getElementById("status").textContent =
97
+ `✔ Speed "${name}" updated to ${value}`;
98
+ }
99
+ } catch (e) {
100
+ console.error(e);
101
+ document.getElementById("status").textContent = "Server error";
102
+ }
103
+ }
104
+
105
+
106
+ function startKeyChange(action) {
107
+ waitingForKey = action;
108
+
109
+ document.getElementById("status").textContent =
110
+ `Press a key to assign to "${action}"...`;
111
+
112
+ const td = document.getElementById(`current-${action}`);
113
+ td.textContent = "Press a key...";
114
+ }
115
+
116
+ document.addEventListener("keydown", async (event) => {
117
+ if (!waitingForKey) return;
118
+
119
+ const action = waitingForKey;
120
+ const newKey = event.key;
121
+
122
+ waitingForKey = null;
123
+
124
+ await changeKey(action, newKey);
125
+ });
126
+
127
+
128
+ async function changeKey(action, key) {
129
+ try {
130
+ const resp = await fetch("/change_key", {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({ action, key }),
134
+ });
135
+
136
+ const data = await resp.json();
137
+
138
+ if (data.status === "ok") {
139
+ currentBindings[action] = key;
140
+ document.getElementById(`current-${action}`).textContent = key;
141
+
142
+ document.getElementById("status").textContent =
143
+ `✔ "${action}" updated to "${key}"`;
144
+ } else {
145
+ document.getElementById("status").textContent =
146
+ `Failed: ${data.message}`;
147
+ }
148
+ } catch (e) {
149
+ console.error(e);
150
+ document.getElementById("status").textContent = "Server error";
151
+ }
152
+ }
153
+
154
+
155
+ loadBindings();
156
+ loadSpeeds();
reachy_mini_controller/static/style.css ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
3
+ background-color: #f4f7f9;
4
+ margin: 0;
5
+ padding: 0;
6
+ }
7
+
8
+ .container {
9
+ max-width: 700px;
10
+ margin: 40px auto;
11
+ background: #ffffff;
12
+ padding: 30px 40px;
13
+ border-radius: 12px;
14
+ box-shadow: 0 4px 20px rgba(0,0,0,0.1);
15
+ }
16
+
17
+ h1, h2 {
18
+ text-align: center;
19
+ color: #333;
20
+ }
21
+
22
+ table {
23
+ width: 100%;
24
+ border-collapse: collapse;
25
+ margin-top: 15px;
26
+ }
27
+
28
+ th, td {
29
+ text-align: left;
30
+ padding: 12px;
31
+ border-bottom: 1px solid #ddd;
32
+ }
33
+
34
+ th {
35
+ background-color: #f0f0f0;
36
+ }
37
+
38
+ button {
39
+ padding: 6px 12px;
40
+ background-color: #4CAF50;
41
+ color: white;
42
+ border: none;
43
+ border-radius: 5px;
44
+ cursor: pointer;
45
+ transition: 0.2s;
46
+ }
47
+
48
+ button:hover {
49
+ background-color: #45a049;
50
+ }
51
+
52
+ .status {
53
+ margin-top: 20px;
54
+ text-align: center;
55
+ font-weight: bold;
56
+ color: #444;
57
+ }
58
+
59
+ .speed-item {
60
+ margin: 15px 0;
61
+ padding: 15px;
62
+ border-radius: 8px;
63
+ background-color: #f8f8f8;
64
+ border: 1px solid #ddd;
65
+ }
66
+
67
+ .speed-item label {
68
+ display: block;
69
+ font-weight: bold;
70
+ margin-bottom: 5px;
71
+ }
72
+
73
+ .speed-item input[type="range"] {
74
+ width: 100%;
75
+ }
76
+
77
+ .speed-value {
78
+ font-weight: bold;
79
+ float: right;
80
+ }
style.css ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, "Helvetica Neue", Arial, sans-serif;
9
+ line-height: 1.6;
10
+ color: #333;
11
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
12
+ min-height: 100vh;
13
+ }
14
+
15
+ .hero {
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ color: white;
18
+ padding: 4rem 2rem;
19
+ text-align: center;
20
+ }
21
+
22
+ .hero-content {
23
+ max-width: 800px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ .app-icon {
28
+ font-size: 4rem;
29
+ margin-bottom: 1rem;
30
+ display: inline-block;
31
+ }
32
+
33
+ .hero h1 {
34
+ font-size: 3rem;
35
+ font-weight: 700;
36
+ margin-bottom: 1rem;
37
+ background: linear-gradient(45deg, #fff, #f0f9ff);
38
+ background-clip: text;
39
+ -webkit-background-clip: text;
40
+ -webkit-text-fill-color: transparent;
41
+ }
42
+
43
+ .tagline {
44
+ font-size: 1.25rem;
45
+ opacity: 0.9;
46
+ max-width: 600px;
47
+ margin: 0 auto;
48
+ }
49
+
50
+ .container {
51
+ max-width: 1200px;
52
+ margin: 0 auto;
53
+ padding: 0 2rem;
54
+ position: relative;
55
+ z-index: 2;
56
+ }
57
+
58
+ .main-card {
59
+ background: white;
60
+ border-radius: 20px;
61
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
62
+ margin-top: -2rem;
63
+ overflow: hidden;
64
+ margin-bottom: 3rem;
65
+ }
66
+
67
+ .app-preview {
68
+ background: linear-gradient(135deg, #1e3a8a, #3b82f6);
69
+ padding: 3rem;
70
+ color: white;
71
+ text-align: center;
72
+ position: relative;
73
+ }
74
+
75
+ .preview-image {
76
+ background: #000;
77
+ border-radius: 15px;
78
+ padding: 2rem;
79
+ max-width: 500px;
80
+ margin: 0 auto;
81
+ position: relative;
82
+ overflow: hidden;
83
+ }
84
+
85
+ .camera-feed {
86
+ font-size: 4rem;
87
+ margin-bottom: 1rem;
88
+ opacity: 0.7;
89
+ }
90
+
91
+ .detection-overlay {
92
+ position: absolute;
93
+ top: 50%;
94
+ left: 50%;
95
+ transform: translate(-50%, -50%);
96
+ width: 100%;
97
+ }
98
+
99
+ .bbox {
100
+ background: rgba(34, 197, 94, 0.9);
101
+ color: white;
102
+ padding: 0.5rem 1rem;
103
+ border-radius: 8px;
104
+ font-size: 0.9rem;
105
+ font-weight: 600;
106
+ margin: 0.5rem;
107
+ display: inline-block;
108
+ border: 2px solid #22c55e;
109
+ }
110
+
111
+ .app-details {
112
+ padding: 3rem;
113
+ }
114
+
115
+ .app-details h2 {
116
+ font-size: 2rem;
117
+ color: #1e293b;
118
+ margin-bottom: 2rem;
119
+ text-align: center;
120
+ }
121
+
122
+ .template-info {
123
+ display: grid;
124
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
125
+ gap: 2rem;
126
+ margin-bottom: 3rem;
127
+ }
128
+
129
+ .info-box {
130
+ background: #f0f9ff;
131
+ border: 2px solid #e0f2fe;
132
+ border-radius: 12px;
133
+ padding: 2rem;
134
+ }
135
+
136
+ .info-box h3 {
137
+ color: #0c4a6e;
138
+ margin-bottom: 1rem;
139
+ font-size: 1.2rem;
140
+ }
141
+
142
+ .info-box p {
143
+ color: #0369a1;
144
+ line-height: 1.6;
145
+ }
146
+
147
+ .how-to-use {
148
+ background: #fefce8;
149
+ border: 2px solid #fde047;
150
+ border-radius: 12px;
151
+ padding: 2rem;
152
+ margin-top: 3rem;
153
+ }
154
+
155
+ .how-to-use h3 {
156
+ color: #a16207;
157
+ margin-bottom: 1.5rem;
158
+ font-size: 1.3rem;
159
+ text-align: center;
160
+ }
161
+
162
+ .steps {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 1.5rem;
166
+ }
167
+
168
+ .step {
169
+ display: flex;
170
+ align-items: flex-start;
171
+ gap: 1rem;
172
+ }
173
+
174
+ .step-number {
175
+ background: #eab308;
176
+ color: white;
177
+ width: 2rem;
178
+ height: 2rem;
179
+ border-radius: 50%;
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ font-weight: bold;
184
+ flex-shrink: 0;
185
+ }
186
+
187
+ .step h4 {
188
+ color: #a16207;
189
+ margin-bottom: 0.5rem;
190
+ font-size: 1.1rem;
191
+ }
192
+
193
+ .step p {
194
+ color: #ca8a04;
195
+ }
196
+
197
+ .download-card {
198
+ background: white;
199
+ border-radius: 20px;
200
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
201
+ padding: 3rem;
202
+ text-align: center;
203
+ }
204
+
205
+ .download-card h2 {
206
+ font-size: 2rem;
207
+ color: #1e293b;
208
+ margin-bottom: 1rem;
209
+ }
210
+
211
+ .download-card>p {
212
+ color: #64748b;
213
+ font-size: 1.1rem;
214
+ margin-bottom: 2rem;
215
+ }
216
+
217
+ .dashboard-config {
218
+ margin-bottom: 2rem;
219
+ text-align: left;
220
+ max-width: 400px;
221
+ margin-left: auto;
222
+ margin-right: auto;
223
+ }
224
+
225
+ .dashboard-config label {
226
+ display: block;
227
+ color: #374151;
228
+ font-weight: 600;
229
+ margin-bottom: 0.5rem;
230
+ }
231
+
232
+ .dashboard-config input {
233
+ width: 100%;
234
+ padding: 0.75rem 1rem;
235
+ border: 2px solid #e5e7eb;
236
+ border-radius: 8px;
237
+ font-size: 0.95rem;
238
+ transition: border-color 0.2s;
239
+ }
240
+
241
+ .dashboard-config input:focus {
242
+ outline: none;
243
+ border-color: #667eea;
244
+ }
245
+
246
+ .install-btn {
247
+ background: linear-gradient(135deg, #667eea, #764ba2);
248
+ color: white;
249
+ border: none;
250
+ padding: 1.25rem 3rem;
251
+ border-radius: 16px;
252
+ font-size: 1.2rem;
253
+ font-weight: 700;
254
+ cursor: pointer;
255
+ transition: all 0.3s ease;
256
+ display: inline-flex;
257
+ align-items: center;
258
+ gap: 0.75rem;
259
+ margin-bottom: 2rem;
260
+ box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
261
+ }
262
+
263
+ .install-btn:hover:not(:disabled) {
264
+ transform: translateY(-3px);
265
+ box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
266
+ }
267
+
268
+ .install-btn:disabled {
269
+ opacity: 0.7;
270
+ cursor: not-allowed;
271
+ transform: none;
272
+ }
273
+
274
+ .manual-option {
275
+ background: #f8fafc;
276
+ border-radius: 12px;
277
+ padding: 2rem;
278
+ margin-top: 2rem;
279
+ }
280
+
281
+ .manual-option h3 {
282
+ color: #1e293b;
283
+ margin-bottom: 1rem;
284
+ font-size: 1.2rem;
285
+ }
286
+
287
+ .manual-option>p {
288
+ color: #64748b;
289
+ margin-bottom: 1rem;
290
+ }
291
+
292
+ .btn-icon {
293
+ font-size: 1.1rem;
294
+ }
295
+
296
+ .install-status {
297
+ padding: 1rem;
298
+ border-radius: 8px;
299
+ font-size: 0.9rem;
300
+ text-align: center;
301
+ display: none;
302
+ margin-top: 1rem;
303
+ }
304
+
305
+ .install-status.success {
306
+ background: #dcfce7;
307
+ color: #166534;
308
+ border: 1px solid #bbf7d0;
309
+ }
310
+
311
+ .install-status.error {
312
+ background: #fef2f2;
313
+ color: #dc2626;
314
+ border: 1px solid #fecaca;
315
+ }
316
+
317
+ .install-status.loading {
318
+ background: #dbeafe;
319
+ color: #1d4ed8;
320
+ border: 1px solid #bfdbfe;
321
+ }
322
+
323
+ .install-status.info {
324
+ background: #e0f2fe;
325
+ color: #0369a1;
326
+ border: 1px solid #7dd3fc;
327
+ }
328
+
329
+ .manual-install {
330
+ background: #1f2937;
331
+ border-radius: 8px;
332
+ padding: 1rem;
333
+ margin-bottom: 1rem;
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 1rem;
337
+ }
338
+
339
+ .manual-install code {
340
+ color: #10b981;
341
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
342
+ font-size: 0.85rem;
343
+ flex: 1;
344
+ overflow-x: auto;
345
+ }
346
+
347
+ .copy-btn {
348
+ background: #374151;
349
+ color: white;
350
+ border: none;
351
+ padding: 0.5rem 1rem;
352
+ border-radius: 6px;
353
+ font-size: 0.8rem;
354
+ cursor: pointer;
355
+ transition: background-color 0.2s;
356
+ }
357
+
358
+ .copy-btn:hover {
359
+ background: #4b5563;
360
+ }
361
+
362
+ .manual-steps {
363
+ color: #6b7280;
364
+ font-size: 0.9rem;
365
+ line-height: 1.8;
366
+ }
367
+
368
+ .footer {
369
+ text-align: center;
370
+ padding: 2rem;
371
+ color: white;
372
+ opacity: 0.8;
373
+ }
374
+
375
+ .footer a {
376
+ color: white;
377
+ text-decoration: none;
378
+ font-weight: 600;
379
+ }
380
+
381
+ .footer a:hover {
382
+ text-decoration: underline;
383
+ }
384
+
385
+ /* Responsive Design */
386
+ @media (max-width: 768px) {
387
+ .hero {
388
+ padding: 2rem 1rem;
389
+ }
390
+
391
+ .hero h1 {
392
+ font-size: 2rem;
393
+ }
394
+
395
+ .container {
396
+ padding: 0 1rem;
397
+ }
398
+
399
+ .app-details,
400
+ .download-card {
401
+ padding: 2rem;
402
+ }
403
+
404
+ .features-grid {
405
+ grid-template-columns: 1fr;
406
+ }
407
+
408
+ .download-options {
409
+ grid-template-columns: 1fr;
410
+ }
411
+ }