shepherds-console 0.1.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.
- shepherds_console/__init__.py +411 -0
- shepherds_console/__main__.py +130 -0
- shepherds_console/dashboard.py +289 -0
- shepherds_console/display.py +355 -0
- shepherds_console/models.py +392 -0
- shepherds_console/voice.py +282 -0
- shepherds_console/wattage.py +288 -0
- shepherds_console/web.py +345 -0
- shepherds_console-0.1.0.dist-info/METADATA +220 -0
- shepherds_console-0.1.0.dist-info/RECORD +14 -0
- shepherds_console-0.1.0.dist-info/WHEEL +5 -0
- shepherds_console-0.1.0.dist-info/entry_points.txt +2 -0
- shepherds_console-0.1.0.dist-info/licenses/LICENSE +21 -0
- shepherds_console-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shepherd's Console — The box on the boat. The thing you talk to.
|
|
3
|
+
|
|
4
|
+
A physical device OS that runs on Raspberry Pi, offline, with visible
|
|
5
|
+
conservation fences. No cloud. No API keys. No nonsense.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from shepherds_console import ShepherdsConsole
|
|
9
|
+
|
|
10
|
+
console = ShepherdsConsole(fence_config="marine_fence.flx")
|
|
11
|
+
response = console.speak("What's the halibut quota for Area 3A?")
|
|
12
|
+
print(response)
|
|
13
|
+
|
|
14
|
+
CLI:
|
|
15
|
+
python -m shepherds_console # interactive console
|
|
16
|
+
python -m shepherds_console --serve # systemd service mode
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from shepherds_console.display import ConsoleDisplay, DisplayMode
|
|
29
|
+
from shepherds_console.models import LocalModels, ModelNotAvailableError
|
|
30
|
+
from shepherds_console.wattage import WattageManager, PowerState
|
|
31
|
+
from shepherds_console.voice import VoiceInterface, VoiceHardwareError
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from conservation_enforcer import ConservationEnforcer, EnforcementResult
|
|
35
|
+
from skenna import NegativeSpaceNavigator, Hazard, HazardType
|
|
36
|
+
except ImportError:
|
|
37
|
+
# Stub for environments without the full stack
|
|
38
|
+
ConservationEnforcer = None
|
|
39
|
+
EnforcementResult = None
|
|
40
|
+
NegativeSpaceNavigator = None
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
|
43
|
+
__author__ = "SuperInstance"
|
|
44
|
+
__license__ = "MIT"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"ShepherdsConsole",
|
|
48
|
+
"ConsoleDisplay",
|
|
49
|
+
"LocalModels",
|
|
50
|
+
"WattageManager",
|
|
51
|
+
"VoiceInterface",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ShepherdsConsole:
|
|
56
|
+
"""The box on the boat. The thing you talk to.
|
|
57
|
+
|
|
58
|
+
Orchestrates local models, conservation fences, and a visible display
|
|
59
|
+
into a single offline-first AI console.
|
|
60
|
+
|
|
61
|
+
Architecture:
|
|
62
|
+
User Input → Local Model → Conservation Fence → Display → Response
|
|
63
|
+
↑ ↓
|
|
64
|
+
Wattage Manager ← ← ← ← ← ← ← ← ← ← ← ← ←
|
|
65
|
+
|
|
66
|
+
The fence is mandatory. The model cannot bypass it. The display
|
|
67
|
+
always shows what was blocked, even if the user never sees the raw output.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
fence_config: Optional[str] = None,
|
|
73
|
+
model_path: Optional[str] = None,
|
|
74
|
+
model_dir: Optional[str] = None,
|
|
75
|
+
enable_voice: bool = False,
|
|
76
|
+
enable_display: bool = True,
|
|
77
|
+
display_device: str = "fb0",
|
|
78
|
+
power_source: str = "battery",
|
|
79
|
+
):
|
|
80
|
+
"""Initialize the Shepherd's Console.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
fence_config: Path to .flx fence file. Falls back to bundled default.
|
|
84
|
+
model_path: Path to a specific .gguf model file.
|
|
85
|
+
model_dir: Directory to search for models.
|
|
86
|
+
enable_voice: Enable microphone + TTS voice interface.
|
|
87
|
+
enable_display: Enable the visible fence display.
|
|
88
|
+
display_device: Display device (fb0, hdmi, pygame).
|
|
89
|
+
power_source: "battery" or "shore" — affects wattage management.
|
|
90
|
+
"""
|
|
91
|
+
# Resolve fence
|
|
92
|
+
self.fence = self._load_fence(fence_config)
|
|
93
|
+
|
|
94
|
+
# Navigator for negative-space routing (unused in basic mode but available)
|
|
95
|
+
self.navigator = NegativeSpaceNavigator() if NegativeSpaceNavigator else None
|
|
96
|
+
|
|
97
|
+
# Local models — the brain
|
|
98
|
+
self.models = LocalModels(
|
|
99
|
+
model_path=model_path,
|
|
100
|
+
model_dir=model_dir or os.environ.get(
|
|
101
|
+
"SHEPHERDS_MODEL_DIR",
|
|
102
|
+
str(Path.home() / "shepherds-console" / "models"),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Wattage manager — the metabolism
|
|
107
|
+
self.wattage = WattageManager(
|
|
108
|
+
source=power_source,
|
|
109
|
+
on_low_power=self._on_low_power,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Display — the glass box
|
|
113
|
+
self.display = (
|
|
114
|
+
ConsoleDisplay(device=display_device, mode=DisplayMode.VISIBLE_FENCE)
|
|
115
|
+
if enable_display
|
|
116
|
+
else ConsoleDisplay(mode=DisplayMode.OFF)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Voice — optional mouth and ears
|
|
120
|
+
self.voice: Optional[VoiceInterface] = None
|
|
121
|
+
if enable_voice:
|
|
122
|
+
try:
|
|
123
|
+
self.voice = VoiceInterface()
|
|
124
|
+
except VoiceHardwareError as e:
|
|
125
|
+
self.display.show_warning(f"Voice unavailable: {e}")
|
|
126
|
+
self.voice = None
|
|
127
|
+
|
|
128
|
+
# State
|
|
129
|
+
self._running = False
|
|
130
|
+
self._exchange_count = 0
|
|
131
|
+
self._fence_blocked_count = 0
|
|
132
|
+
|
|
133
|
+
# Show boot screen
|
|
134
|
+
self.display.show_boot(
|
|
135
|
+
version=__version__,
|
|
136
|
+
model=self.models.current_model_name,
|
|
137
|
+
fence=self.fence_name,
|
|
138
|
+
watts=self.wattage.current_watts,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# ── Public API ──────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def speak(self, text: str) -> str:
|
|
144
|
+
"""User talks. Model responds. Fence checks the response.
|
|
145
|
+
|
|
146
|
+
This is the main interaction. The flow:
|
|
147
|
+
1. Check wattage budget (defer if low power)
|
|
148
|
+
2. Generate response with local model
|
|
149
|
+
3. Run conservation fence on the output
|
|
150
|
+
4. Display everything — blocked, flagged, allowed
|
|
151
|
+
5. Return the safe response (or correction)
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
text: What the user said/typed.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The fence-checked response string.
|
|
158
|
+
"""
|
|
159
|
+
self._exchange_count += 1
|
|
160
|
+
|
|
161
|
+
# Check energy budget
|
|
162
|
+
if not self.wattage.can_infer():
|
|
163
|
+
msg = (
|
|
164
|
+
"⚡ Power budget exhausted. The console is conserving energy. "
|
|
165
|
+
"Non-critical inference deferred."
|
|
166
|
+
)
|
|
167
|
+
self.display.show_warning(msg)
|
|
168
|
+
return msg
|
|
169
|
+
|
|
170
|
+
# Start wattage tracking for this inference
|
|
171
|
+
with self.wattage.inference_scope() as energy:
|
|
172
|
+
# Generate raw model output
|
|
173
|
+
try:
|
|
174
|
+
raw_output = self.models.generate(
|
|
175
|
+
text,
|
|
176
|
+
max_tokens=self.wattage.token_budget,
|
|
177
|
+
precision=self.wattage.model_precision,
|
|
178
|
+
)
|
|
179
|
+
except ModelNotAvailableError:
|
|
180
|
+
msg = (
|
|
181
|
+
"📦 No local model found. Add a .gguf file to the models directory.\n"
|
|
182
|
+
f" Directory: {self.models.model_dir}\n"
|
|
183
|
+
" The fence is active. The model is not."
|
|
184
|
+
)
|
|
185
|
+
self.display.show_warning(msg)
|
|
186
|
+
return msg
|
|
187
|
+
|
|
188
|
+
# Run the conservation fence
|
|
189
|
+
if self.fence is not None:
|
|
190
|
+
result: EnforcementResult = self.fence.enforce(text, raw_output)
|
|
191
|
+
allowed = result.allowed
|
|
192
|
+
corrected = result.output
|
|
193
|
+
violation = result.violation
|
|
194
|
+
cycles = result.cycles
|
|
195
|
+
else:
|
|
196
|
+
# No fence loaded — this should not happen in production
|
|
197
|
+
allowed = True
|
|
198
|
+
corrected = raw_output
|
|
199
|
+
violation = None
|
|
200
|
+
cycles = 0
|
|
201
|
+
|
|
202
|
+
if not allowed:
|
|
203
|
+
self._fence_blocked_count += 1
|
|
204
|
+
|
|
205
|
+
# Display the full picture
|
|
206
|
+
self.display.show_exchange(
|
|
207
|
+
user_input=text,
|
|
208
|
+
raw_output=raw_output,
|
|
209
|
+
allowed=allowed,
|
|
210
|
+
final_output=corrected,
|
|
211
|
+
violation=violation,
|
|
212
|
+
energy=energy,
|
|
213
|
+
exchange_num=self._exchange_count,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Speak if voice is enabled
|
|
217
|
+
if self.voice:
|
|
218
|
+
self.voice.speak(corrected)
|
|
219
|
+
|
|
220
|
+
return corrected
|
|
221
|
+
|
|
222
|
+
def listen(self) -> Optional[str]:
|
|
223
|
+
"""Listen for voice input via push-to-talk.
|
|
224
|
+
|
|
225
|
+
Returns the transcribed text, or None if no speech detected.
|
|
226
|
+
"""
|
|
227
|
+
if not self.voice:
|
|
228
|
+
return None
|
|
229
|
+
return self.voice.listen()
|
|
230
|
+
|
|
231
|
+
def status(self) -> dict:
|
|
232
|
+
"""Return current console status for monitoring."""
|
|
233
|
+
return {
|
|
234
|
+
"version": __version__,
|
|
235
|
+
"model": self.models.current_model_name,
|
|
236
|
+
"fence": self.fence_name,
|
|
237
|
+
"exchanges": self._exchange_count,
|
|
238
|
+
"blocked": self._fence_blocked_count,
|
|
239
|
+
"block_rate": (
|
|
240
|
+
self._fence_blocked_count / self._exchange_count
|
|
241
|
+
if self._exchange_count > 0
|
|
242
|
+
else 0.0
|
|
243
|
+
),
|
|
244
|
+
"watts": self.wattage.current_watts,
|
|
245
|
+
"power_state": self.wattage.state.name,
|
|
246
|
+
"battery_pct": self.wattage.battery_pct,
|
|
247
|
+
"voice_enabled": self.voice is not None,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def serve(self):
|
|
251
|
+
"""Run as a service. Listens on the console or voice interface."""
|
|
252
|
+
self._running = True
|
|
253
|
+
self.display.show_ready()
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
while self._running:
|
|
257
|
+
if self.voice:
|
|
258
|
+
# Voice mode: push-to-talk
|
|
259
|
+
text = self.listen()
|
|
260
|
+
if text:
|
|
261
|
+
self.speak(text)
|
|
262
|
+
else:
|
|
263
|
+
# Text mode: read from stdin
|
|
264
|
+
try:
|
|
265
|
+
text = input("🐑 > ").strip()
|
|
266
|
+
except (EOFError, KeyboardInterrupt):
|
|
267
|
+
break
|
|
268
|
+
if text:
|
|
269
|
+
if text in ("/quit", "/exit", "/q"):
|
|
270
|
+
break
|
|
271
|
+
elif text == "/status":
|
|
272
|
+
info = self.status()
|
|
273
|
+
for k, v in info.items():
|
|
274
|
+
print(f" {k}: {v}")
|
|
275
|
+
else:
|
|
276
|
+
response = self.speak(text)
|
|
277
|
+
print(response)
|
|
278
|
+
print()
|
|
279
|
+
finally:
|
|
280
|
+
self._running = False
|
|
281
|
+
self.display.show_shutdown()
|
|
282
|
+
self.wattage.close()
|
|
283
|
+
|
|
284
|
+
# ── Internal ────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def fence_name(self) -> str:
|
|
288
|
+
if hasattr(self, "_fence_name"):
|
|
289
|
+
return self._fence_name
|
|
290
|
+
return "none"
|
|
291
|
+
|
|
292
|
+
def _load_fence(self, fence_config: Optional[str]) -> Optional["ConservationEnforcer"]:
|
|
293
|
+
"""Load a conservation fence from a .flx file or bundled default."""
|
|
294
|
+
if ConservationEnforcer is None:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
fence_paths = []
|
|
298
|
+
|
|
299
|
+
if fence_config:
|
|
300
|
+
fence_paths.append(Path(fence_config))
|
|
301
|
+
|
|
302
|
+
# Check fence dir
|
|
303
|
+
fence_dir = Path(
|
|
304
|
+
os.environ.get("SHEPHERDS_FENCE_DIR",
|
|
305
|
+
str(Path(__file__).parent.parent.parent / "fences"))
|
|
306
|
+
)
|
|
307
|
+
fence_paths.append(fence_dir / "marine_fence.flx")
|
|
308
|
+
fence_paths.append(fence_dir / "default_fence.flx")
|
|
309
|
+
|
|
310
|
+
for fp in fence_paths:
|
|
311
|
+
if fp.exists():
|
|
312
|
+
self._fence_name = fp.name
|
|
313
|
+
try:
|
|
314
|
+
return ConservationEnforcer.from_policy_file(str(fp))
|
|
315
|
+
except Exception:
|
|
316
|
+
# Fence file might be pseudo-bytecode for now
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
# Build a default fence from policy functions
|
|
320
|
+
self._fence_name = "builtin:combined"
|
|
321
|
+
try:
|
|
322
|
+
from conservation_enforcer import combined_policy
|
|
323
|
+
from conservation_enforcer.assembler import assemble
|
|
324
|
+
bytecode = assemble(combined_policy(max_tokens=500))
|
|
325
|
+
return ConservationEnforcer(bytecode, budget=500)
|
|
326
|
+
except Exception:
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def _on_low_power(self, power_state):
|
|
330
|
+
"""Callback when power state changes."""
|
|
331
|
+
if power_state == PowerState.DEGRADED:
|
|
332
|
+
self.display.show_warning(
|
|
333
|
+
f"🔋 Low battery ({self.wattage.battery_pct:.0f}%). "
|
|
334
|
+
f"Reducing model precision to {self.wattage.model_precision}."
|
|
335
|
+
)
|
|
336
|
+
elif power_state == PowerState.CRITICAL:
|
|
337
|
+
self.display.show_warning(
|
|
338
|
+
"🔴 Critical battery. Non-critical inference suspended. "
|
|
339
|
+
"Emergency mode: fence-only, no generation."
|
|
340
|
+
)
|
|
341
|
+
elif power_state == PowerState.SHUTDOWN:
|
|
342
|
+
self.display.show_warning("🔴 Battery exhausted. Shutting down.")
|
|
343
|
+
|
|
344
|
+
# ── CLI ─────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
@classmethod
|
|
347
|
+
def main(cls):
|
|
348
|
+
"""CLI entry point."""
|
|
349
|
+
parser = argparse.ArgumentParser(
|
|
350
|
+
prog="shepherds-console",
|
|
351
|
+
description="🐑 Offline AI with visible conservation fences",
|
|
352
|
+
)
|
|
353
|
+
parser.add_argument(
|
|
354
|
+
"--fence", "-f",
|
|
355
|
+
help="Path to .flx fence file",
|
|
356
|
+
default=None,
|
|
357
|
+
)
|
|
358
|
+
parser.add_argument(
|
|
359
|
+
"--model", "-m",
|
|
360
|
+
help="Path to .gguf model file",
|
|
361
|
+
default=None,
|
|
362
|
+
)
|
|
363
|
+
parser.add_argument(
|
|
364
|
+
"--model-dir",
|
|
365
|
+
help="Directory containing .gguf models",
|
|
366
|
+
default=None,
|
|
367
|
+
)
|
|
368
|
+
parser.add_argument(
|
|
369
|
+
"--voice", "-v",
|
|
370
|
+
help="Enable voice interface",
|
|
371
|
+
action="store_true",
|
|
372
|
+
)
|
|
373
|
+
parser.add_argument(
|
|
374
|
+
"--no-display",
|
|
375
|
+
help="Disable the visible fence display",
|
|
376
|
+
action="store_true",
|
|
377
|
+
)
|
|
378
|
+
parser.add_argument(
|
|
379
|
+
"--display",
|
|
380
|
+
help="Display device (fb0, hdmi, pygame)",
|
|
381
|
+
default="fb0",
|
|
382
|
+
)
|
|
383
|
+
parser.add_argument(
|
|
384
|
+
"--serve", "-s",
|
|
385
|
+
help="Run as a service (for systemd)",
|
|
386
|
+
action="store_true",
|
|
387
|
+
)
|
|
388
|
+
parser.add_argument(
|
|
389
|
+
"--power",
|
|
390
|
+
choices=["battery", "shore"],
|
|
391
|
+
default="battery",
|
|
392
|
+
help="Power source",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
args = parser.parse_args()
|
|
396
|
+
|
|
397
|
+
console = cls(
|
|
398
|
+
fence_config=args.fence,
|
|
399
|
+
model_path=args.model,
|
|
400
|
+
model_dir=args.model_dir,
|
|
401
|
+
enable_voice=args.voice,
|
|
402
|
+
enable_display=not args.no_display,
|
|
403
|
+
display_device=args.display,
|
|
404
|
+
power_source=args.power,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
console.serve()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__":
|
|
411
|
+
ShepherdsConsole.main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Console entry point for shepherds-console."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import http.server
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from shepherds_console import ShepherdsConsole
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_demo_console() -> ShepherdsConsole:
|
|
13
|
+
"""Build a demo console with sample data."""
|
|
14
|
+
c = ShepherdsConsole()
|
|
15
|
+
|
|
16
|
+
# Pastures
|
|
17
|
+
c.add_pasture("translation-farm", mode="plow", capacity=12)
|
|
18
|
+
c.add_pasture("code-review-meadow", mode="plow", capacity=8)
|
|
19
|
+
c.add_pasture("idle-grove", mode="fallow", capacity=5)
|
|
20
|
+
|
|
21
|
+
# Fences
|
|
22
|
+
c.add_fence("token-budget", limit=500_000, action="throttle")
|
|
23
|
+
c.add_fence("rate-limit", limit=1000, action="block")
|
|
24
|
+
c.add_fence("context-window", limit=128_000, action="alert")
|
|
25
|
+
|
|
26
|
+
# Animals
|
|
27
|
+
c.add_animal("translator-7", pasture="translation-farm", role="flux")
|
|
28
|
+
c.add_animal("translator-12", pasture="translation-farm", role="flux")
|
|
29
|
+
c.add_animal("reviewer-3", pasture="code-review-meadow", role="sentinel")
|
|
30
|
+
c.add_animal("reviewer-5", pasture="code-review-meadow", role="sentinel")
|
|
31
|
+
c.add_animal("scout-1", pasture="idle-grove", role="scout", health="offline")
|
|
32
|
+
|
|
33
|
+
# Simulate some activity
|
|
34
|
+
c.fences["token-budget"].consumed = 342_000
|
|
35
|
+
c.fences["rate-limit"].consumed = 847
|
|
36
|
+
c.fences["context-window"].consumed = 91_000
|
|
37
|
+
|
|
38
|
+
for _ in range(142):
|
|
39
|
+
c.complete_task("translator-7", cost=200)
|
|
40
|
+
for _ in range(87):
|
|
41
|
+
c.complete_task("translator-12", cost=150)
|
|
42
|
+
for _ in range(53):
|
|
43
|
+
c.complete_task("reviewer-3")
|
|
44
|
+
for _ in range(31):
|
|
45
|
+
c.complete_task("reviewer-5")
|
|
46
|
+
|
|
47
|
+
c.pastures["translation-farm"].tasks_in_progress = 3
|
|
48
|
+
c.pastures["code-review-meadow"].tasks_in_progress = 1
|
|
49
|
+
c.pastures["translation-farm"].throughput = 7.2
|
|
50
|
+
c.pastures["code-review-meadow"].throughput = 2.8
|
|
51
|
+
|
|
52
|
+
return c
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: list[str] | None = None) -> int:
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
prog="shepherds-console",
|
|
58
|
+
description="🐑 The Shepherd's Console — single-pane operations for working animal infrastructure.",
|
|
59
|
+
)
|
|
60
|
+
mode = parser.add_mutually_exclusive_group()
|
|
61
|
+
mode.add_argument(
|
|
62
|
+
"--web",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="Serve web dashboard instead of terminal output",
|
|
65
|
+
)
|
|
66
|
+
mode.add_argument(
|
|
67
|
+
"--json",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="Output JSON status",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--port",
|
|
73
|
+
type=int,
|
|
74
|
+
default=8080,
|
|
75
|
+
help="Port for web server (default: 8080)",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--host",
|
|
79
|
+
default="127.0.0.1",
|
|
80
|
+
help="Host for web server (default: 127.0.0.1)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--demo",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="Use demo data (no live connections)",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
args = parser.parse_args(argv)
|
|
89
|
+
|
|
90
|
+
console = _build_demo_console() if args.demo else ShepherdsConsole()
|
|
91
|
+
|
|
92
|
+
if args.json:
|
|
93
|
+
import json
|
|
94
|
+
print(json.dumps(console.status(), indent=2))
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
if args.web:
|
|
98
|
+
html_content = console.render_html()
|
|
99
|
+
|
|
100
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
101
|
+
def do_GET(self):
|
|
102
|
+
self.send_response(200)
|
|
103
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
104
|
+
self.end_headers()
|
|
105
|
+
self.wfile.write(
|
|
106
|
+
console.render_html().encode()
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def log_message(self, *a):
|
|
110
|
+
pass # suppress access logs
|
|
111
|
+
|
|
112
|
+
server = http.server.HTTPServer((args.host, args.port), Handler)
|
|
113
|
+
print(
|
|
114
|
+
f"🐑 Shepherd's Console serving at "
|
|
115
|
+
f"http://{args.host}:{args.port}"
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
server.serve_forever()
|
|
119
|
+
except KeyboardInterrupt:
|
|
120
|
+
print("\nShutting down.")
|
|
121
|
+
server.shutdown()
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
# Terminal mode
|
|
125
|
+
print(console.render_terminal())
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
sys.exit(main())
|