From 27d2e3b42d290e72f8ee5735fcd5c73dcaed4517 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Fri, 26 Dec 2025 13:34:32 -0500 Subject: feat(kidcam): complete implementation - Detector.py: YOLOv8-nano person detection - Streamer.py: GStreamer HLS video streaming - Notifier.py: Telegram bot notifications - Core.py: State machine orchestration - deploy.sh: Ubuntu deployment script - kidcam.service: systemd unit - Documentation (README, project overview) Includes tests, type hints, follows repo conventions. Fixed Worker.hs missing engineOnToolTrace (jr now builds). Added Python deps: opencv, ultralytics, python-telegram-bot. Amp-Thread-ID: https://ampcode.com/threads/T-019b5bc1-b00a-701f-ab4f-04738e8a733c Co-authored-by: Amp --- Biz/Kidcam/Core.py | 271 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100755 Biz/Kidcam/Core.py (limited to 'Biz/Kidcam/Core.py') diff --git a/Biz/Kidcam/Core.py b/Biz/Kidcam/Core.py new file mode 100755 index 0000000..5ac3247 --- /dev/null +++ b/Biz/Kidcam/Core.py @@ -0,0 +1,271 @@ +#!/usr/bin/env run.sh +"""Main orchestration service for Kidcam.""" + +# : out kidcam +# : dep pytest +# : dep pytest-asyncio +import asyncio +import enum +import logging +import Omni.Log as Log # type: ignore +import os +import pathlib +import signal +import sys +import time +import typing + +logger = logging.getLogger(__name__) + + +class State(enum.Enum): + """State machine states for Kidcam service.""" + + IDLE = "idle" + ACTIVE = "active" + COOLDOWN = "cooldown" + + +class Config(typing.TypedDict): + """Configuration for Kidcam service.""" + + camera_device: str + stream_port: int + detection_confidence: float + cooldown_minutes: int + telegram_bot_token: str + telegram_chat_id: str + + +def load_config(config_path: str | None = None) -> Config: + """Load configuration from file or environment variables.""" + if config_path and pathlib.Path(config_path).exists(): + import json + + with pathlib.Path(config_path).open(encoding="utf-8") as f: + data = json.load(f) + return typing.cast("Config", data) + + return { + "camera_device": os.getenv("CAMERA_DEVICE", "/dev/video0"), + "stream_port": int(os.getenv("STREAM_PORT", "8554")), + "detection_confidence": float(os.getenv("DETECTION_CONFIDENCE", "0.5")), + "cooldown_minutes": int(os.getenv("COOLDOWN_MINUTES", "5")), + "telegram_bot_token": os.getenv("TELEGRAM_BOT_TOKEN", ""), + "telegram_chat_id": os.getenv("TELEGRAM_CHAT_ID", ""), + } + + +class KidcamService: + """Main orchestration service for Kidcam.""" + + def __init__( + self: "KidcamService", config_path: str | None = None + ) -> None: + """Initialize the Kidcam service with configuration.""" + self.config = load_config(config_path) + self.state = State.IDLE + self.last_detection_time = 0.0 + self.running = False + self.shutdown_event = asyncio.Event() + + self.detector: typing.Any | None = None + self.streamer: typing.Any | None = None + self.notifier: typing.Any | None = None + + logger.info("Kidcam service initialized") + logger.debug(f"Config: {self.config}") + + async def _setup_components(self: "KidcamService") -> None: + """Initialize detector, streamer, and notifier components.""" + try: + import Biz.Kidcam.Detector as Detector + import Biz.Kidcam.Notifier as Notifier + import Biz.Kidcam.Streamer as Streamer + + self.detector = Detector.PersonDetector( + model_path="yolov8n.pt", + confidence_threshold=self.config["detection_confidence"], + ) + self.streamer = Streamer.VideoStreamer( + device=self.config["camera_device"], + port=self.config["stream_port"], + ) + self.notifier = Notifier.TelegramNotifier( + bot_token=self.config["telegram_bot_token"], + chat_id=self.config["telegram_chat_id"], + ) + logger.info("All components initialized successfully") + except ImportError as e: + logger.warning("Component not available: %s", e) + logger.warning("Running in stub mode") + + async def _transition_to_idle(self: "KidcamService") -> None: + """Transition to IDLE state.""" + logger.info("State: IDLE - Monitoring for person detection") + self.state = State.IDLE + + async def _transition_to_active(self: "KidcamService") -> None: + """Transition to ACTIVE state.""" + logger.info("State: ACTIVE - Person detected, starting stream") + self.state = State.ACTIVE + self.last_detection_time = time.time() + + if self.streamer: + try: + await self.streamer.start() + logger.info( + f"Stream started on port {self.config['stream_port']}" + ) + except Exception as e: + logger.exception("Failed to start stream: %s", e) + + if self.notifier: + try: + stream_url = ( + f"rtsp://kidcam:{self.config['stream_port']}/stream" + ) + await self.notifier.send_notification( + f"👶 Person detected! Stream available at {stream_url}" + ) + logger.info("Notification sent") + except Exception as e: + logger.exception("Failed to send notification: %s", e) + + async def _transition_to_cooldown(self: "KidcamService") -> None: + """Transition to COOLDOWN state.""" + logger.info( + f"State: COOLDOWN - {self.config['cooldown_minutes']} minute cooldown started" + ) + self.state = State.COOLDOWN + + if self.streamer: + try: + await self.streamer.stop() + logger.info("Stream stopped") + except Exception as e: + logger.exception("Failed to stop stream: %s", e) + + async def _check_cooldown_expired(self: "KidcamService") -> bool: + """Check if cooldown period has expired.""" + if self.state != State.COOLDOWN: + return False + + elapsed_minutes = (time.time() - self.last_detection_time) / 60 + if elapsed_minutes >= self.config["cooldown_minutes"]: + logger.info("Cooldown period expired") + return True + return False + + async def _detect_person(self: "KidcamService") -> bool: + """Check if a person is detected by the detector.""" + if not self.detector: + return False + + try: + return await self.detector.detect() + except Exception as e: + logger.exception("Detection error: %s", e) + return False + + async def run(self: "KidcamService") -> None: + """Main event loop for the Kidcam service.""" + self.running = True + await self._setup_components() + await self._transition_to_idle() + + logger.info("Starting main event loop") + + try: + while self.running: + if await self.shutdown_event.wait(): + break + + if self.state == State.IDLE: + if await self._detect_person(): + await self._transition_to_active() + await asyncio.sleep(1.0) + + elif self.state == State.ACTIVE: + if not await self._detect_person(): + await self._transition_to_cooldown() + await asyncio.sleep(1.0) + + elif self.state == State.COOLDOWN: + if await self._check_cooldown_expired(): + await self._transition_to_idle() + await asyncio.sleep(10.0) + + except Exception as e: + logger.exception("Error in main loop: %s", e) + raise + finally: + await self.shutdown() + + async def shutdown(self: "KidcamService") -> None: + """Cleanup and shutdown the service gracefully.""" + logger.info("Shutting down Kidcam service") + self.running = False + + if self.streamer: + try: + await self.streamer.stop() + except Exception as e: + logger.exception("Error stopping streamer: %s", e) + + if self.detector: + try: + await self.detector.close() + except Exception as e: + logger.exception("Error closing detector: %s", e) + + logger.info("Shutdown complete") + + +def _setup_signal_handlers(service: KidcamService) -> None: + """Setup graceful shutdown on SIGTERM and SIGINT.""" + + def signal_handler(sig: int, frame: typing.Any) -> None: + logger.info("Received signal %s, initiating shutdown", sig) + service.shutdown_event.set() + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + +async def main() -> None: + """Entrypoint to start the Kidcam service.""" + _ = Log.setup(logger, level=logging.INFO) + + config_path = sys.argv[1] if len(sys.argv) > 1 else None + service = KidcamService(config_path=config_path) + + _setup_signal_handlers(service) + + logger.info("Starting Kidcam service") + await service.run() + + +def test() -> None: + """Test the Kidcam service.""" + _ = Log.setup(logger, level=logging.DEBUG) + + logger.info("Testing configuration loading") + config = load_config() + assert config["camera_device"] == "/dev/video0" + assert config["stream_port"] == 8554 + assert config["detection_confidence"] == 0.5 + assert config["cooldown_minutes"] == 5 + + logger.info("Testing state transitions") + service = KidcamService() + assert service.state == State.IDLE + + logger.info("Testing shutdown") + asyncio.run(service.shutdown()) + + logger.info("All tests passed") + + +if __name__ == "__main__": + asyncio.run(main()) -- cgit v1.2.3