embedded-lab-control 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
  2. embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
  3. embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
  4. embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
  5. embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
  7. labctl/__init__.py +8 -0
  8. labctl/actuators/__init__.py +56 -0
  9. labctl/actuators/base.py +183 -0
  10. labctl/actuators/lcus1.py +232 -0
  11. labctl/actuators/mock.py +81 -0
  12. labctl/actuators/runtime.py +1178 -0
  13. labctl/cli.py +7485 -0
  14. labctl/core/__init__.py +36 -0
  15. labctl/core/admin_files.py +388 -0
  16. labctl/core/audit.py +269 -0
  17. labctl/core/claim_keepalive.py +342 -0
  18. labctl/core/config.py +1068 -0
  19. labctl/core/database.py +880 -0
  20. labctl/core/images.py +503 -0
  21. labctl/core/manager.py +2815 -0
  22. labctl/core/models.py +815 -0
  23. labctl/core/safe_open.py +109 -0
  24. labctl/extras.py +128 -0
  25. labctl/health/__init__.py +43 -0
  26. labctl/health/alerts.py +275 -0
  27. labctl/health/checks.py +533 -0
  28. labctl/health/daemon.py +397 -0
  29. labctl/mcp_server.py +5254 -0
  30. labctl/power/__init__.py +13 -0
  31. labctl/power/base.py +145 -0
  32. labctl/power/kasa.py +446 -0
  33. labctl/power/shelly.py +89 -0
  34. labctl/power/tasmota.py +95 -0
  35. labctl/sdwire/__init__.py +10 -0
  36. labctl/sdwire/controller.py +1116 -0
  37. labctl/sdwire/power_guard.py +190 -0
  38. labctl/serial/__init__.py +18 -0
  39. labctl/serial/boot_test.py +342 -0
  40. labctl/serial/capture.py +430 -0
  41. labctl/serial/keys.py +89 -0
  42. labctl/serial/proxy.py +802 -0
  43. labctl/serial/ser2net.py +338 -0
  44. labctl/serial/udev.py +273 -0
  45. labctl/services.py +210 -0
  46. labctl/web/__init__.py +9 -0
  47. labctl/web/activity_broadcaster.py +204 -0
  48. labctl/web/api.py +1003 -0
  49. labctl/web/app.py +202 -0
  50. labctl/web/auth.py +97 -0
  51. labctl/web/claims.py +232 -0
  52. labctl/web/static/css/style.css +477 -0
  53. labctl/web/static/js/app.js +76 -0
  54. labctl/web/templates/activity.html +202 -0
  55. labctl/web/templates/base.html +49 -0
  56. labctl/web/templates/claim_refused.html +38 -0
  57. labctl/web/templates/console.html +237 -0
  58. labctl/web/templates/dashboard.html +100 -0
  59. labctl/web/templates/login.html +25 -0
  60. labctl/web/templates/sbc_detail.html +306 -0
  61. labctl/web/templates/settings.html +78 -0
  62. labctl/web/templates/status_history.html +65 -0
  63. labctl/web/views.py +496 -0
  64. labctl/web/websocket.py +208 -0
labctl/web/views.py ADDED
@@ -0,0 +1,496 @@
1
+ """
2
+ Web views for lab controller dashboard.
3
+ """
4
+
5
+ from flask import (
6
+ Blueprint,
7
+ Response,
8
+ current_app,
9
+ flash,
10
+ g,
11
+ redirect,
12
+ render_template,
13
+ stream_with_context,
14
+ url_for,
15
+ )
16
+
17
+ from labctl.actuators.runtime import power_cycle_with_bindings, power_on_with_bindings
18
+ from labctl.core.models import AddressType, PlugType, PortType, Status
19
+ from labctl.power import PowerController, PowerState
20
+ from labctl.web import claims
21
+ from labctl.web.api import (
22
+ begin_power_cycle,
23
+ end_power_cycle,
24
+ outlet_cycle_in_progress,
25
+ )
26
+
27
+ views_bp = Blueprint("views", __name__)
28
+
29
+
30
+ @views_bp.route("/")
31
+ def index():
32
+ """Dashboard home page."""
33
+ sbcs = g.manager.list_sbcs()
34
+
35
+ # Get power states for SBCs with plugs
36
+ power_states = {}
37
+ for sbc in sbcs:
38
+ if sbc.power_plug:
39
+ try:
40
+ controller = PowerController.from_plug(sbc.power_plug)
41
+ power_states[sbc.name] = controller.get_state()
42
+ except Exception:
43
+ power_states[sbc.name] = PowerState.UNKNOWN
44
+
45
+ # Index active claims by SBC name for dashboard badges
46
+ claims_by_sbc = {c.sbc_name: c for c in g.manager.list_active_claims()}
47
+
48
+ return render_template(
49
+ "dashboard.html",
50
+ sbcs=sbcs,
51
+ power_states=power_states,
52
+ claims_by_sbc=claims_by_sbc,
53
+ PowerState=PowerState,
54
+ )
55
+
56
+
57
+ @views_bp.route("/sbc/<name>")
58
+ def sbc_detail(name: str):
59
+ """SBC detail page."""
60
+ sbc = g.manager.get_sbc_by_name(name)
61
+ if not sbc:
62
+ flash(f"SBC '{name}' not found", "error")
63
+ return redirect(url_for("views.index"))
64
+
65
+ power_state = None
66
+ if sbc.power_plug:
67
+ try:
68
+ controller = PowerController.from_plug(sbc.power_plug)
69
+ power_state = controller.get_state()
70
+ except Exception:
71
+ power_state = PowerState.UNKNOWN
72
+
73
+ # Load active claim for this SBC
74
+ active_claim = g.manager.get_active_claim(sbc.name)
75
+
76
+ return render_template(
77
+ "sbc_detail.html",
78
+ sbc=sbc,
79
+ power_state=power_state,
80
+ active_claim=active_claim,
81
+ PowerState=PowerState,
82
+ Status=Status,
83
+ PortType=PortType,
84
+ AddressType=AddressType,
85
+ PlugType=PlugType,
86
+ )
87
+
88
+
89
+ @views_bp.route("/sbc/<name>/power/<action>", methods=["POST"])
90
+ def sbc_power_action(name: str, action: str):
91
+ """Handle power control actions."""
92
+ sbc = g.manager.get_sbc_by_name(name)
93
+ if not sbc:
94
+ flash(f"SBC '{name}' not found", "error")
95
+ return redirect(url_for("views.index"))
96
+
97
+ if not sbc.power_plug:
98
+ flash("No power plug assigned", "error")
99
+ return redirect(url_for("views.sbc_detail", name=name))
100
+
101
+ if action not in ("on", "off", "cycle"):
102
+ flash(f"Unknown action: {action}", "error")
103
+ return redirect(url_for("views.sbc_detail", name=name))
104
+
105
+ refusal = claims.check(name)
106
+ if refusal:
107
+ return claims.refusal_page(refusal, name)
108
+
109
+ # Same guards as REST: one cycle of an outlet at a time, rate-limited,
110
+ # and no on/off of an outlet while it is being cycled.
111
+ if action == "cycle":
112
+ busy = begin_power_cycle(sbc)
113
+ if busy:
114
+ flash(f"Error: {busy}", "error")
115
+ return redirect(url_for("views.sbc_detail", name=name))
116
+ elif outlet_cycle_in_progress(sbc):
117
+ flash(
118
+ f"Error: a power cycle of {name!r}'s power outlet is in progress; "
119
+ "retry when it has finished",
120
+ "error",
121
+ )
122
+ return redirect(url_for("views.sbc_detail", name=name))
123
+
124
+ success = False
125
+ try:
126
+ controller = PowerController.from_plug(sbc.power_plug)
127
+
128
+ if action == "on":
129
+ success = power_on_with_bindings(g.manager, sbc, controller)
130
+ msg = "Power ON" if success else "Failed to power on"
131
+ elif action == "off":
132
+ success = controller.power_off()
133
+ msg = "Power OFF" if success else "Failed to power off"
134
+ else:
135
+ success = power_cycle_with_bindings(g.manager, sbc, controller)
136
+ msg = "Power cycled" if success else "Failed to power cycle"
137
+
138
+ flash(msg, "success" if success else "error")
139
+ except Exception as e:
140
+ flash(f"Error: {e}", "error")
141
+ finally:
142
+ if action == "cycle":
143
+ end_power_cycle(sbc, success)
144
+
145
+ return redirect(url_for("views.sbc_detail", name=name))
146
+
147
+
148
+ @views_bp.route("/sbc/<name>/console")
149
+ def sbc_console(name: str):
150
+ """SBC console page with xterm.js terminal."""
151
+ sbc = g.manager.get_sbc_by_name(name)
152
+ if not sbc:
153
+ flash(f"SBC '{name}' not found", "error")
154
+ return redirect(url_for("views.index"))
155
+
156
+ # Find console port
157
+ port = sbc.console_port
158
+
159
+ return render_template(
160
+ "console.html",
161
+ sbc=sbc,
162
+ port=port,
163
+ )
164
+
165
+
166
+ @views_bp.route("/sbc/<name>/history")
167
+ def sbc_history(name: str):
168
+ """SBC status history page."""
169
+ sbc = g.manager.get_sbc_by_name(name)
170
+ if not sbc:
171
+ flash(f"SBC '{name}' not found", "error")
172
+ return redirect(url_for("views.index"))
173
+
174
+ # Get status history
175
+ history = g.manager.get_status_history(sbc_id=sbc.id, limit=100)
176
+
177
+ return render_template(
178
+ "status_history.html",
179
+ sbc=sbc,
180
+ history=history,
181
+ Status=Status,
182
+ )
183
+
184
+
185
+ @views_bp.route("/sbc/<name>/edit", methods=["POST"])
186
+ def sbc_edit(name: str):
187
+ """Handle SBC edit form."""
188
+ from flask import request
189
+
190
+ sbc = g.manager.get_sbc_by_name(name)
191
+ if not sbc:
192
+ flash(f"SBC '{name}' not found", "error")
193
+ return redirect(url_for("views.index"))
194
+
195
+ new_name = request.form.get("name", "").strip() or None
196
+ project = request.form.get("project", "").strip() or None
197
+ description = request.form.get("description", "").strip() or None
198
+ ssh_user = request.form.get("ssh_user", "").strip() or "root"
199
+ status = request.form.get("status", "").strip()
200
+
201
+ status_enum = Status(status) if status else None
202
+
203
+ refusal = claims.check(name, shared=False)
204
+ if refusal:
205
+ return claims.refusal_page(refusal, name)
206
+
207
+ # Only pass new_name if it actually changed
208
+ rename = new_name if new_name and new_name != name else None
209
+
210
+ g.manager.update_sbc(
211
+ sbc.id,
212
+ name=rename,
213
+ project=project,
214
+ description=description,
215
+ ssh_user=ssh_user,
216
+ status=status_enum,
217
+ )
218
+
219
+ display_name = rename or name
220
+ flash(f"Updated SBC '{display_name}'", "success")
221
+ return redirect(url_for("views.sbc_detail", name=display_name))
222
+
223
+
224
+ @views_bp.route("/sbc/<name>/port/assign", methods=["POST"])
225
+ def sbc_port_assign(name: str):
226
+ """Handle port assignment form."""
227
+ from flask import request
228
+
229
+ sbc = g.manager.get_sbc_by_name(name)
230
+ if not sbc:
231
+ flash(f"SBC '{name}' not found", "error")
232
+ return redirect(url_for("views.index"))
233
+
234
+ port_type = request.form.get("port_type", "").strip()
235
+ alias = request.form.get("alias", "").strip() or None
236
+ device = request.form.get("device", "").strip()
237
+ tcp_port = request.form.get("tcp_port", "").strip()
238
+ baud_rate = request.form.get("baud_rate", "115200").strip()
239
+
240
+ if not port_type or not device:
241
+ flash("Port type and device are required", "error")
242
+ return redirect(url_for("views.sbc_detail", name=name))
243
+
244
+ # The boards already wired to this console are checked too: otherwise
245
+ # a claimed board's console could be typed into through this one.
246
+ refusal = claims.check(
247
+ name,
248
+ shared=False,
249
+ sharers=dict(
250
+ serial_device_path=device,
251
+ tcp_port=claims.port_number(tcp_port),
252
+ ),
253
+ )
254
+ if refusal:
255
+ return claims.refusal_page(refusal, name)
256
+
257
+ try:
258
+ g.manager.assign_serial_port(
259
+ sbc_id=sbc.id,
260
+ port_type=PortType(port_type),
261
+ device_path=device,
262
+ tcp_port=int(tcp_port) if tcp_port else None,
263
+ baud_rate=int(baud_rate),
264
+ alias=alias,
265
+ )
266
+ flash(f"Assigned {port_type} port", "success")
267
+ except Exception as e:
268
+ flash(f"Error: {e}", "error")
269
+
270
+ return redirect(url_for("views.sbc_detail", name=name))
271
+
272
+
273
+ @views_bp.route("/sbc/<name>/port/remove/<port_type>", methods=["POST"])
274
+ def sbc_port_remove(name: str, port_type: str):
275
+ """Remove port assignment."""
276
+ sbc = g.manager.get_sbc_by_name(name)
277
+ if not sbc:
278
+ flash(f"SBC '{name}' not found", "error")
279
+ return redirect(url_for("views.index"))
280
+
281
+ refusal = claims.check(name, shared=False)
282
+ if refusal:
283
+ return claims.refusal_page(refusal, name)
284
+
285
+ if g.manager.remove_serial_port(sbc.id, PortType(port_type)):
286
+ flash(f"Removed {port_type} port", "success")
287
+ else:
288
+ flash(f"No {port_type} port to remove", "error")
289
+
290
+ return redirect(url_for("views.sbc_detail", name=name))
291
+
292
+
293
+ @views_bp.route("/sbc/<name>/network/set", methods=["POST"])
294
+ def sbc_network_set(name: str):
295
+ """Handle network address form."""
296
+ from flask import request
297
+
298
+ sbc = g.manager.get_sbc_by_name(name)
299
+ if not sbc:
300
+ flash(f"SBC '{name}' not found", "error")
301
+ return redirect(url_for("views.index"))
302
+
303
+ address_type = request.form.get("address_type", "").strip()
304
+ ip_address = request.form.get("ip_address", "").strip()
305
+ mac_address = request.form.get("mac_address", "").strip() or None
306
+ hostname = request.form.get("hostname", "").strip() or None
307
+
308
+ if not address_type or not ip_address:
309
+ flash("Address type and IP are required", "error")
310
+ return redirect(url_for("views.sbc_detail", name=name))
311
+
312
+ refusal = claims.check(name, shared=False)
313
+ if refusal:
314
+ return claims.refusal_page(refusal, name)
315
+
316
+ try:
317
+ g.manager.set_network_address(
318
+ sbc_id=sbc.id,
319
+ address_type=AddressType(address_type),
320
+ ip_address=ip_address,
321
+ mac_address=mac_address,
322
+ hostname=hostname,
323
+ )
324
+ flash(f"Set {address_type} address", "success")
325
+ except Exception as e:
326
+ flash(f"Error: {e}", "error")
327
+
328
+ return redirect(url_for("views.sbc_detail", name=name))
329
+
330
+
331
+ @views_bp.route("/sbc/<name>/network/remove/<address_type>", methods=["POST"])
332
+ def sbc_network_remove(name: str, address_type: str):
333
+ """Remove network address."""
334
+ sbc = g.manager.get_sbc_by_name(name)
335
+ if not sbc:
336
+ flash(f"SBC '{name}' not found", "error")
337
+ return redirect(url_for("views.index"))
338
+
339
+ refusal = claims.check(name, shared=False)
340
+ if refusal:
341
+ return claims.refusal_page(refusal, name)
342
+
343
+ if g.manager.remove_network_address(sbc.id, AddressType(address_type)):
344
+ flash(f"Removed {address_type} address", "success")
345
+ else:
346
+ flash(f"No {address_type} address to remove", "error")
347
+
348
+ return redirect(url_for("views.sbc_detail", name=name))
349
+
350
+
351
+ @views_bp.route("/sbc/<name>/plug/assign", methods=["POST"])
352
+ def sbc_plug_assign(name: str):
353
+ """Handle power plug assignment form."""
354
+ from flask import request
355
+
356
+ sbc = g.manager.get_sbc_by_name(name)
357
+ if not sbc:
358
+ flash(f"SBC '{name}' not found", "error")
359
+ return redirect(url_for("views.index"))
360
+
361
+ plug_type = request.form.get("plug_type", "").strip()
362
+ address = request.form.get("address", "").strip()
363
+ plug_index = request.form.get("plug_index", "1").strip()
364
+
365
+ if not plug_type or not address:
366
+ flash("Plug type and address are required", "error")
367
+ return redirect(url_for("views.sbc_detail", name=name))
368
+
369
+ # Outlets are numbered from 1; index 0 addresses every outlet on a
370
+ # Tasmota device at once.
371
+ if claims.port_number(plug_index) is None:
372
+ flash("Plug index must be a whole number from 1 to 65535", "error")
373
+ return redirect(url_for("views.sbc_detail", name=name))
374
+ try:
375
+ plug_type_enum = PlugType(plug_type)
376
+ except ValueError:
377
+ flash(f"Unknown plug type: {plug_type}", "error")
378
+ return redirect(url_for("views.sbc_detail", name=name))
379
+
380
+ # The boards already on this outlet are checked too: otherwise a
381
+ # claimed board could be powered through this one.
382
+ refusal = claims.check(
383
+ name,
384
+ shared=False,
385
+ sharers=dict(plug=(plug_type_enum.value, address, int(plug_index))),
386
+ )
387
+ if refusal:
388
+ return claims.refusal_page(refusal, name)
389
+
390
+ try:
391
+ g.manager.assign_power_plug(
392
+ sbc_id=sbc.id,
393
+ plug_type=plug_type_enum,
394
+ address=address,
395
+ plug_index=int(plug_index),
396
+ )
397
+ flash(f"Assigned {plug_type} plug", "success")
398
+ except Exception as e:
399
+ flash(f"Error: {e}", "error")
400
+
401
+ return redirect(url_for("views.sbc_detail", name=name))
402
+
403
+
404
+ @views_bp.route("/sbc/<name>/plug/remove", methods=["POST"])
405
+ def sbc_plug_remove(name: str):
406
+ """Remove power plug assignment."""
407
+ sbc = g.manager.get_sbc_by_name(name)
408
+ if not sbc:
409
+ flash(f"SBC '{name}' not found", "error")
410
+ return redirect(url_for("views.index"))
411
+
412
+ refusal = claims.check(name, shared=False)
413
+ if refusal:
414
+ return claims.refusal_page(refusal, name)
415
+
416
+ if g.manager.remove_power_plug(sbc.id):
417
+ flash("Removed power plug", "success")
418
+ else:
419
+ flash("No power plug to remove", "error")
420
+
421
+ return redirect(url_for("views.sbc_detail", name=name))
422
+
423
+
424
+ @views_bp.route("/sbc/<name>/claim/force-release", methods=["POST"])
425
+ def sbc_force_release(name: str):
426
+ """Force-release a claim from the web dashboard."""
427
+ from flask import request as flask_request
428
+
429
+ from labctl.core.models import ClaimNotFoundError
430
+
431
+ sbc = g.manager.get_sbc_by_name(name)
432
+ if not sbc:
433
+ flash(f"SBC '{name}' not found", "error")
434
+ return redirect(url_for("views.index"))
435
+
436
+ if not claims.web_actor().operator:
437
+ flash(
438
+ "Only operators may force-release a claim; your user has role 'agent'.",
439
+ "error",
440
+ )
441
+ return redirect(url_for("views.sbc_detail", name=name))
442
+
443
+ reason = flask_request.form.get("reason", "force-released via web dashboard")
444
+ try:
445
+ g.manager.force_release_claim(name, reason, released_by=claims.web_user_name())
446
+ flash(f"Force-released claim on '{name}'", "success")
447
+ except ClaimNotFoundError:
448
+ flash(f"No active claim on '{name}'", "error")
449
+
450
+ return redirect(url_for("views.sbc_detail", name=name))
451
+
452
+
453
+ @views_bp.route("/settings")
454
+ def settings():
455
+ """Settings page."""
456
+ from flask import current_app
457
+
458
+ config = current_app.config.get("LABCTL_CONFIG")
459
+
460
+ return render_template(
461
+ "settings.html",
462
+ config=config,
463
+ )
464
+
465
+
466
+ @views_bp.route("/activity")
467
+ def activity():
468
+ """Live activity-stream page."""
469
+ broadcaster = current_app.config.get("ACTIVITY_BROADCASTER")
470
+ initial = broadcaster.recent() if broadcaster else []
471
+ return render_template(
472
+ "activity.html",
473
+ initial_events=[e.to_dict() for e in initial],
474
+ )
475
+
476
+
477
+ @views_bp.route("/activity/stream")
478
+ def activity_stream():
479
+ """Server-Sent Events feed of live activity events.
480
+
481
+ Sends a one-shot hydration of the last N events, then live events
482
+ as they arrive. Keeps alive with a comment-only frame every 15s.
483
+ """
484
+ from labctl.web.activity_broadcaster import iter_sse_frames
485
+
486
+ broadcaster = current_app.config.get("ACTIVITY_BROADCASTER")
487
+ if broadcaster is None:
488
+ return Response("Activity broadcaster not initialized", status=503)
489
+
490
+ response = Response(
491
+ stream_with_context(iter_sse_frames(broadcaster)),
492
+ mimetype="text/event-stream",
493
+ )
494
+ response.headers["Cache-Control"] = "no-cache"
495
+ response.headers["X-Accel-Buffering"] = "no" # disable nginx buffering
496
+ return response
@@ -0,0 +1,208 @@
1
+ """
2
+ WebSocket bridge for serial console in web interface.
3
+
4
+ Bridges between browser WebSocket and serial proxy TCP connection.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from typing import Optional
10
+
11
+ from flask import g, request
12
+ from flask_socketio import SocketIO, emit, join_room, leave_room
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # SocketIO instance - initialized in init_socketio()
17
+ socketio: Optional[SocketIO] = None
18
+
19
+ # Active console connections: {sid: {sbc_name, reader_task, writer}}
20
+ active_connections: dict = {}
21
+
22
+
23
+ def init_socketio(app, **kwargs):
24
+ """Initialize SocketIO with Flask app."""
25
+ global socketio
26
+ socketio = SocketIO(app, **kwargs)
27
+ register_handlers(socketio)
28
+ return socketio
29
+
30
+
31
+ def register_handlers(sio: SocketIO):
32
+ """Register SocketIO event handlers."""
33
+
34
+ @sio.on("connect", namespace="/console")
35
+ def handle_connect():
36
+ """Handle new WebSocket connection."""
37
+ logger.info(f"WebSocket client connected: {request.sid}")
38
+
39
+ @sio.on("disconnect", namespace="/console")
40
+ def handle_disconnect():
41
+ """Handle WebSocket disconnection."""
42
+ sid = request.sid
43
+ logger.info(f"WebSocket client disconnected: {sid}")
44
+
45
+ # Clean up any active console connection
46
+ if sid in active_connections:
47
+ conn = active_connections.pop(sid)
48
+ if "reader_task" in conn and conn["reader_task"]:
49
+ conn["reader_task"].cancel()
50
+ if "writer" in conn and conn["writer"]:
51
+ try:
52
+ conn["writer"].close()
53
+ except Exception:
54
+ pass
55
+
56
+ @sio.on("open_console", namespace="/console")
57
+ def handle_open_console(data):
58
+ """Open console connection to SBC.
59
+
60
+ Expected data: {"sbc_name": "sbc1"}
61
+ """
62
+ sid = request.sid
63
+ sbc_name = data.get("sbc_name")
64
+
65
+ if not sbc_name:
66
+ emit("error", {"message": "sbc_name required"})
67
+ return
68
+
69
+ # Get SBC info
70
+ manager = g.manager
71
+ sbc = manager.get_sbc_by_name(sbc_name)
72
+
73
+ if not sbc:
74
+ emit("error", {"message": f"SBC '{sbc_name}' not found"})
75
+ return
76
+
77
+ console_port = sbc.console_port
78
+ if not console_port or not console_port.tcp_port:
79
+ emit("error", {"message": "No console port configured"})
80
+ return
81
+
82
+ # Join room for this SBC
83
+ join_room(sbc_name)
84
+
85
+ # Store connection info
86
+ active_connections[sid] = {
87
+ "sbc_name": sbc_name,
88
+ "tcp_port": console_port.tcp_port,
89
+ "reader_task": None,
90
+ "writer": None,
91
+ }
92
+
93
+ emit(
94
+ "console_opened",
95
+ {
96
+ "sbc_name": sbc_name,
97
+ "message": f"Connected to {sbc_name} console",
98
+ },
99
+ )
100
+
101
+ # Start async reader in background
102
+ # Note: This is simplified - in production, use a proper async bridge
103
+ logger.info(f"Console opened for {sbc_name} by {sid}")
104
+
105
+ @sio.on("console_input", namespace="/console")
106
+ def handle_console_input(data):
107
+ """Handle input from browser to send to console.
108
+
109
+ Expected data: {"text": "ls -la\n"}
110
+ """
111
+ sid = request.sid
112
+
113
+ if sid not in active_connections:
114
+ emit("error", {"message": "Console not opened"})
115
+ return
116
+
117
+ conn = active_connections[sid]
118
+ text = data.get("text", "")
119
+
120
+ if not text:
121
+ return
122
+
123
+ # Send to console via proxy
124
+ # Note: Actual implementation would write to TCP socket
125
+ logger.debug(f"Console input from {sid}: {repr(text)}")
126
+
127
+ # Echo back for now (in production, this comes from the serial device)
128
+ emit("console_output", {"text": text}, room=conn["sbc_name"])
129
+
130
+ @sio.on("close_console", namespace="/console")
131
+ def handle_close_console():
132
+ """Close console connection."""
133
+ sid = request.sid
134
+
135
+ if sid in active_connections:
136
+ conn = active_connections.pop(sid)
137
+ leave_room(conn["sbc_name"])
138
+ emit("console_closed", {"message": "Console closed"})
139
+ logger.info(f"Console closed for {conn['sbc_name']} by {sid}")
140
+
141
+
142
+ class ConsoleWebSocketBridge:
143
+ """
144
+ Bridge between WebSocket and TCP proxy.
145
+
146
+ This class handles the async bridging between Flask-SocketIO
147
+ and the asyncio-based serial proxy.
148
+ """
149
+
150
+ def __init__(self, sio: SocketIO, sid: str, sbc_name: str, proxy_port: int):
151
+ self.sio = sio
152
+ self.sid = sid
153
+ self.sbc_name = sbc_name
154
+ self.proxy_port = proxy_port
155
+ self._reader: Optional[asyncio.StreamReader] = None
156
+ self._writer: Optional[asyncio.StreamWriter] = None
157
+ self._running = False
158
+
159
+ async def connect(self) -> bool:
160
+ """Connect to proxy TCP port."""
161
+ try:
162
+ self._reader, self._writer = await asyncio.open_connection(
163
+ "localhost", self.proxy_port
164
+ )
165
+ self._running = True
166
+ return True
167
+ except Exception as e:
168
+ logger.error(f"Failed to connect to proxy: {e}")
169
+ return False
170
+
171
+ async def disconnect(self):
172
+ """Disconnect from proxy."""
173
+ self._running = False
174
+ if self._writer:
175
+ self._writer.close()
176
+ try:
177
+ await self._writer.wait_closed()
178
+ except Exception:
179
+ pass
180
+
181
+ async def send_to_console(self, data: str):
182
+ """Send data to console."""
183
+ if self._writer and self._running:
184
+ self._writer.write(data.encode())
185
+ await self._writer.drain()
186
+
187
+ async def read_loop(self):
188
+ """Read from console and emit to WebSocket."""
189
+ while self._running and self._reader:
190
+ try:
191
+ data = await asyncio.wait_for(self._reader.read(4096), timeout=1.0)
192
+ if not data:
193
+ break
194
+
195
+ # Emit to WebSocket
196
+ self.sio.emit(
197
+ "console_output",
198
+ {"text": data.decode("utf-8", errors="replace")},
199
+ namespace="/console",
200
+ room=self.sbc_name,
201
+ )
202
+ except asyncio.TimeoutError:
203
+ continue
204
+ except Exception as e:
205
+ logger.error(f"Console read error: {e}")
206
+ break
207
+
208
+ self._running = False