mirror of
https://github.com/Nighthawk42/fulloch.git
synced 2026-08-30 09:02:25 +00:00
@@ -0,0 +1,85 @@
|
||||
# =============================================================================
|
||||
# Version Control
|
||||
# =============================================================================
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
|
||||
# =============================================================================
|
||||
# Python Artifacts
|
||||
# =============================================================================
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# =============================================================================
|
||||
# Testing
|
||||
# =============================================================================
|
||||
tests/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# =============================================================================
|
||||
# Documentation
|
||||
# =============================================================================
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
LICENSE
|
||||
|
||||
# =============================================================================
|
||||
# IDE and Editors
|
||||
# =============================================================================
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# =============================================================================
|
||||
# Local Configuration (copy manually or mount)
|
||||
# =============================================================================
|
||||
.env
|
||||
.env.example
|
||||
data/config.yml
|
||||
data/config.example.yml
|
||||
data/credentials.json
|
||||
data/token.json
|
||||
|
||||
# =============================================================================
|
||||
# Local Data and Models (mount separately)
|
||||
# =============================================================================
|
||||
data/models/
|
||||
data/cache/
|
||||
*.gguf
|
||||
|
||||
# =============================================================================
|
||||
# Logs and Temporary
|
||||
# =============================================================================
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# =============================================================================
|
||||
# OS Generated
|
||||
# =============================================================================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# =============================================================================
|
||||
# Development Files
|
||||
# =============================================================================
|
||||
0ld/
|
||||
Makefile
|
||||
docker-compose*.yml
|
||||
compose*.yml
|
||||
@@ -0,0 +1,22 @@
|
||||
# Fulloch Environment Variables
|
||||
# Copy this file to .env and update with your values.
|
||||
# These values are loaded automatically via python-dotenv.
|
||||
|
||||
# =============================================================================
|
||||
# Spotify OAuth (Alternative to config.yml)
|
||||
# =============================================================================
|
||||
# If you prefer environment variables over config.yml for Spotify credentials:
|
||||
# SPOTIPY_CLIENT_ID=your_spotify_client_id
|
||||
# SPOTIPY_CLIENT_SECRET=your_spotify_client_secret
|
||||
# SPOTIPY_REDIRECT_URI=http://localhost:8888/callback
|
||||
|
||||
# =============================================================================
|
||||
# LG ThinQ (Alternative to config.yml)
|
||||
# =============================================================================
|
||||
# THINQ_ACCESS_TOKEN=your_thinq_access_token
|
||||
# THINQ_CLIENT_ID=your_thinq_client_id
|
||||
|
||||
# =============================================================================
|
||||
# Google Calendar (if using service account)
|
||||
# =============================================================================
|
||||
# GOOGLE_APPLICATION_CREDENTIALS=./data/service-account.json
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
# =============================================================================
|
||||
# Python
|
||||
# =============================================================================
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# =============================================================================
|
||||
# Testing
|
||||
# =============================================================================
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# =============================================================================
|
||||
# IDE and Editors
|
||||
# =============================================================================
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.pydevproject
|
||||
|
||||
# =============================================================================
|
||||
# Local Configuration and Secrets
|
||||
# =============================================================================
|
||||
.env
|
||||
data/config.yml
|
||||
data/credentials.json
|
||||
data/token.json
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# =============================================================================
|
||||
# Local Data and Models
|
||||
# =============================================================================
|
||||
data/models/
|
||||
data/cache/
|
||||
*.gguf
|
||||
|
||||
# =============================================================================
|
||||
# Logs and Databases
|
||||
# =============================================================================
|
||||
*.log
|
||||
*.sqlite
|
||||
*.db
|
||||
*_db/
|
||||
|
||||
# =============================================================================
|
||||
# OS Generated
|
||||
# =============================================================================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# =============================================================================
|
||||
# Temporary Files
|
||||
# =============================================================================
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# =============================================================================
|
||||
# Old/Archive Folders
|
||||
# =============================================================================
|
||||
0ld/
|
||||
|
||||
# =============================================================================
|
||||
# Keep Example Configs (negation patterns)
|
||||
# =============================================================================
|
||||
!data/config.example.yml
|
||||
!.env.example
|
||||
@@ -0,0 +1,136 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Fulloch (the **Full**y **Loc**al **H**ome voice assistant) is a fully local, privacy-focused AI voice home assistant. It runs speech recognition (Moonshine ASR), text-to-speech (Kokoro TTS), and a small language model (Qwen 3 4B) entirely on-device with no cloud dependencies.
|
||||
|
||||
## Build and Run Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -e ".[dev]" # Install with dev dependencies
|
||||
python app.py
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
```bash
|
||||
./launch.sh # Downloads models, configures GPU/CPU, starts services
|
||||
```
|
||||
|
||||
The launch script handles model downloads (Qwen GGUF, Kokoro, Moonshine) and Docker Compose setup.
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
pytest tests/ # Run all tests
|
||||
pytest tests/test_intent_catch.py # Test regex intent patterns
|
||||
pytest tests/test_tool_registry.py # Test tool registration
|
||||
```
|
||||
|
||||
### Testing Individual Components
|
||||
```bash
|
||||
python utils/intent_catch.py # Test regex intent patterns
|
||||
python utils/intents.py # Test intent handler with tool registry
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Package (`core/`)
|
||||
The main assistant logic is split into focused modules:
|
||||
|
||||
- `core/audio.py` - AudioCapture class, silence detection, recorder thread
|
||||
- `core/asr.py` - Moonshine ASR loading and pipeline
|
||||
- `core/tts.py` - Kokoro TTS loading and speak_stream()
|
||||
- `core/slm.py` - Qwen SLM loading and generate_slm()
|
||||
- `core/assistant.py` - Main orchestration, transcriber thread, wakeword detection
|
||||
|
||||
### Audio Pipeline (Two Threads)
|
||||
- **Recorder thread** (`core/audio.py`): Captures microphone input, detects silence/speech via RMS threshold, enqueues complete utterances
|
||||
- **Transcriber thread** (`core/assistant.py`): Runs Moonshine ASR, detects wakeword, processes intents
|
||||
|
||||
### Intent Resolution (Three-Tier Fallback)
|
||||
1. **Regex catch** (`utils/intent_catch.py`): Fast pattern matching for common commands (play, stop, pause, timer, time)
|
||||
2. **AI intent detection**: Qwen SLM with JSON grammar constraint parses `{"intent": "name", "args": [...]}`
|
||||
3. **Free-form chat**: Falls back to conversational AI if intent is ambiguous
|
||||
|
||||
### Tool Registry System
|
||||
Tools are registered via decorator in `tools/tool_registry.py`:
|
||||
```python
|
||||
from tools.tool_registry import tool
|
||||
|
||||
@tool(name="function_name", description="...", aliases=["alias1"])
|
||||
def my_function(param: str) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
All tools auto-import via `tools/__init__.py`. Schemas auto-generate for OpenAI function calling format.
|
||||
|
||||
### Intent Formats (Two Supported)
|
||||
- Function call: `{"function_call": {"name": "...", "arguments": "..."}}`
|
||||
- Legacy: `{"intent": "...", "args": [...]}`
|
||||
|
||||
## Key Configuration
|
||||
|
||||
### Audio Parameters (`core/audio.py`)
|
||||
```python
|
||||
SAMPLE_RATE = 16000
|
||||
CHUNK_DURATION_MS = 200 # Callback slice
|
||||
SILENCE_DURATION_MS = 1000 # End of utterance threshold
|
||||
MIN_UTTERANCE_MS = 1500 # Minimum speech length
|
||||
MAX_UTTERANCE_MS = 10000 # Maximum speech length
|
||||
SILENCE_THRESHOLD = 0.001 # RMS threshold (lower = more sensitive)
|
||||
```
|
||||
|
||||
### Config Files (not in git)
|
||||
- `data/config.yml`: Service endpoints, wakeword, integration settings
|
||||
- `.env`: Credentials (Spotify, Google, etc.)
|
||||
- `data/models/`: Local model cache (~2-3GB)
|
||||
|
||||
### Example Config Files (in git)
|
||||
- `data/config.example.yml`: Template with all settings documented
|
||||
- `.env.example`: Template for credentials
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
1. Create `tools/new_tool.py`
|
||||
2. Use `@tool()` decorator to register functions
|
||||
3. Import in `tools/__init__.py`
|
||||
4. Tool automatically available in intent prompts and registry
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
fulloch/
|
||||
├── app.py # Entry point
|
||||
├── core/ # Core modules
|
||||
│ ├── __init__.py
|
||||
│ ├── audio.py # Audio capture
|
||||
│ ├── asr.py # Speech recognition
|
||||
│ ├── tts.py # Text-to-speech
|
||||
│ ├── slm.py # Language model
|
||||
│ └── assistant.py # Orchestration
|
||||
├── tools/ # Smart home tools
|
||||
│ ├── __init__.py
|
||||
│ ├── tool_registry.py
|
||||
│ └── ...
|
||||
├── utils/ # Utilities
|
||||
│ ├── __init__.py
|
||||
│ ├── intent_catch.py
|
||||
│ ├── intents.py
|
||||
│ └── system_prompts.py
|
||||
├── audio/ # Audio utilities
|
||||
│ ├── __init__.py
|
||||
│ └── beep_manager.py
|
||||
├── tests/ # Test suite
|
||||
│ ├── conftest.py
|
||||
│ ├── test_intent_catch.py
|
||||
│ └── test_tool_registry.py
|
||||
└── data/ # Config and models
|
||||
└── config.example.yml
|
||||
```
|
||||
|
||||
## Available Integrations
|
||||
|
||||
Spotify, Philips Hue, Google Calendar, LG ThinQ, Pioneer AVR, Airtouch HVAC, WebOS TV, SearXNG search, BOM Australia weather
|
||||
@@ -0,0 +1,30 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a positive experience for everyone.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- Trolling, insulting/derogatory comments, and personal attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information without permission
|
||||
- Other conduct which could reasonably be considered inappropriate
|
||||
|
||||
## Enforcement
|
||||
|
||||
Project maintainers are responsible for clarifying standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
# Contributing to Fulloch
|
||||
|
||||
Thank you for your interest in contributing to Fulloch! This document provides guidelines for contributing to the project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Set up the development environment:
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
pip install -e ".[dev]" # Install dev dependencies
|
||||
```
|
||||
4. Copy configuration files:
|
||||
```bash
|
||||
cp data/config.example.yml data/config.yml
|
||||
cp .env.example .env
|
||||
```
|
||||
5. Edit `data/config.yml` with your settings
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
Fulloch uses a decorator-based tool registry system. To add a new tool:
|
||||
|
||||
### Step 1: Create a new tool file
|
||||
|
||||
Create `tools/my_tool.py`:
|
||||
|
||||
```python
|
||||
"""
|
||||
My new tool description.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
from .tool_registry import tool, tool_registry
|
||||
|
||||
# Load configuration if needed
|
||||
MY_CONFIG = config.get('my_tool', {})
|
||||
|
||||
|
||||
@tool(
|
||||
name="my_function",
|
||||
description="What this function does (shown to AI)",
|
||||
aliases=["alias1", "alias2"] # Optional alternative names
|
||||
)
|
||||
def my_function(param1: str, param2: int = 10) -> str:
|
||||
"""
|
||||
Detailed docstring for the function.
|
||||
|
||||
Args:
|
||||
param1: Description of param1
|
||||
param2: Description of param2 (default: 10)
|
||||
|
||||
Returns:
|
||||
Result message
|
||||
"""
|
||||
# Implementation here
|
||||
return f"Result: {param1}, {param2}"
|
||||
```
|
||||
|
||||
### Step 2: Register the tool
|
||||
|
||||
Add the import to `tools/__init__.py`:
|
||||
|
||||
```python
|
||||
from . import my_tool
|
||||
```
|
||||
|
||||
Add to the `__all__` list:
|
||||
|
||||
```python
|
||||
__all__ = [
|
||||
# ... existing tools ...
|
||||
'my_tool',
|
||||
]
|
||||
```
|
||||
|
||||
### Step 3: Add configuration (if needed)
|
||||
|
||||
Add a section to `data/config.example.yml`:
|
||||
|
||||
```yaml
|
||||
# =============================================================================
|
||||
# My Tool
|
||||
# =============================================================================
|
||||
my_tool:
|
||||
setting1: "value1"
|
||||
setting2: 123
|
||||
```
|
||||
|
||||
### Step 4: Test your tool
|
||||
|
||||
```bash
|
||||
python tools/my_tool.py
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- Follow PEP 8 style guidelines
|
||||
- Use type hints for function parameters and return values
|
||||
- Write docstrings for all public functions and classes
|
||||
- Keep functions focused and single-purpose
|
||||
- Use meaningful variable and function names
|
||||
|
||||
### Logging
|
||||
|
||||
Use the standard logging module:
|
||||
|
||||
```python
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.debug("Debug message")
|
||||
logger.info("Info message")
|
||||
logger.warning("Warning message")
|
||||
logger.error("Error message")
|
||||
```
|
||||
|
||||
### Async Functions
|
||||
|
||||
For I/O-bound operations (network, file system), use async:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
async def _my_async_function():
|
||||
"""Internal async implementation."""
|
||||
# async code here
|
||||
pass
|
||||
|
||||
|
||||
@tool(name="my_function", description="...")
|
||||
def my_function():
|
||||
"""Sync wrapper for the async function."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_my_async_function())
|
||||
else:
|
||||
return loop.create_task(_my_async_function())
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a feature branch from `main`:
|
||||
```bash
|
||||
git checkout -b feature/my-feature
|
||||
```
|
||||
|
||||
2. Make your changes and commit with clear messages:
|
||||
```bash
|
||||
git commit -m "Add my new feature"
|
||||
```
|
||||
|
||||
3. Run tests before submitting:
|
||||
```bash
|
||||
pytest tests/
|
||||
```
|
||||
|
||||
4. Push to your fork and create a Pull Request
|
||||
|
||||
5. Fill out the PR template with:
|
||||
- Summary of changes
|
||||
- Test plan
|
||||
- Any breaking changes
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting issues, please include:
|
||||
|
||||
- Python version (`python --version`)
|
||||
- Operating system
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Relevant log output
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to open an issue for questions or discussion about potential contributions.
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first to leverage Docker cache
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app.py .
|
||||
COPY tools/ tools/
|
||||
COPY utils/ utils/
|
||||
COPY wav/ wav/
|
||||
COPY audio/ audio/
|
||||
|
||||
# Run the app
|
||||
CMD ["python", "app.py"]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Use NVIDIA CUDA base image (includes nvcc compiler for building)
|
||||
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04
|
||||
|
||||
# Avoid interactive prompts during package installation
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install Python 3.12 and build tools
|
||||
RUN apt-get update && apt-get install -y \
|
||||
software-properties-common \
|
||||
&& add-apt-repository ppa:deadsnakes/ppa \
|
||||
&& apt-get update && apt-get install -y \
|
||||
python3.12 \
|
||||
python3.12-venv \
|
||||
python3.12-dev \
|
||||
python3-pip \
|
||||
git \
|
||||
build-essential \
|
||||
cmake \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Python 3.12 as default
|
||||
RUN ln -s /usr/bin/python3.12 /usr/bin/python
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 2. Set Environment Variables to force CUDA build
|
||||
# -DGGML_CUDA=on is the flag for recent llama-cpp-python versions (0.3.x)
|
||||
ENV CMAKE_ARGS="-DGGML_CUDA=on"
|
||||
ENV FORCE_CMAKE=1
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
# 3. Install dependencies
|
||||
# This will now compile llama-cpp-python with CUDA support
|
||||
RUN python3.12 -m pip install --upgrade pip && \
|
||||
python3.12 -m pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application files (ignoring __pycache__ via .dockerignore)
|
||||
COPY app.py .
|
||||
COPY tools/ tools/
|
||||
COPY utils/ utils/
|
||||
COPY wav/ wav/
|
||||
COPY audio/ audio/
|
||||
|
||||
CMD ["python3.12", "app.py"]
|
||||
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Liam Pettigrew
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Security Policy
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Fulloch is designed with privacy as a core principle. All processing happens locally on your device:
|
||||
|
||||
- **Speech Recognition**: Moonshine ASR runs entirely on-device
|
||||
- **Text-to-Speech**: Kokoro TTS runs entirely on-device
|
||||
- **Language Model**: Qwen runs entirely on-device via llama.cpp
|
||||
- **No Cloud Dependencies**: No data is sent to external servers for AI processing
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **Do NOT** open a public GitHub issue for security vulnerabilities
|
||||
2. Email the maintainers directly with details of the vulnerability
|
||||
3. Include steps to reproduce if possible
|
||||
4. Allow reasonable time for a fix before public disclosure
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `data/config.yml` contains service credentials and should never be committed
|
||||
- `.env` files contain sensitive environment variables
|
||||
- Both files are excluded from git via `.gitignore`
|
||||
|
||||
### Network Services
|
||||
|
||||
Fulloch connects to external services for smart home control:
|
||||
|
||||
| Service | Connection Type | Data Sent |
|
||||
|---------|----------------|-----------|
|
||||
| Spotify | HTTPS API | Playback commands |
|
||||
| Philips Hue | Local HTTP | Light commands |
|
||||
| Google Calendar | HTTPS API | Calendar queries |
|
||||
| SearXNG | Local HTTP | Search queries |
|
||||
| LG ThinQ | HTTPS API | Appliance queries |
|
||||
| WebOS TV | Local WebSocket | TV commands |
|
||||
| Pioneer AVR | Local TCP | Audio commands |
|
||||
| Airtouch | Local Discovery | HVAC commands |
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Network Isolation**: Run Fulloch on a trusted local network
|
||||
2. **Credential Rotation**: Regularly rotate API keys and tokens
|
||||
3. **Minimal Permissions**: Use read-only API access where possible
|
||||
4. **Update Dependencies**: Keep dependencies updated for security patches
|
||||
|
||||
### OAuth Tokens
|
||||
|
||||
- Google Calendar tokens are stored in `data/token.json`
|
||||
- Spotify tokens are managed by the spotipy library
|
||||
- Tokens should be treated as secrets and not shared
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| Latest | Yes |
|
||||
| Older | No |
|
||||
|
||||
Only the latest version receives security updates.
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fulloch - The Fully Local Home Voice Assistant
|
||||
|
||||
A fully local, privacy-focused AI voice home assistant.
|
||||
Runs speech recognition (Moonshine ASR), text-to-speech (Kokoro TTS),
|
||||
and a small language model (Qwen 3 4B) entirely on-device.
|
||||
|
||||
Usage:
|
||||
python app.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
# Load configuration
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# Point HF cache to models folder
|
||||
models_dir = Path("./data/models").resolve()
|
||||
os.environ["HF_HOME"] = str(models_dir)
|
||||
|
||||
# Set environment variables for offline mode and disabling telemetry
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1"
|
||||
os.environ["DO_NOT_TRACK"] = "1"
|
||||
os.environ["ANONYMIZED_TELEMETRY"] = "False"
|
||||
os.environ["VLLM_NO_USAGE_STATS"] = "1"
|
||||
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from core.assistant import Assistant
|
||||
|
||||
# Configuration
|
||||
WAKEWORD = config['general']['wakeword']
|
||||
SLM_MODEL = "./data/models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf"
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the voice assistant."""
|
||||
assistant = Assistant(wakeword=WAKEWORD, slm_model_path=SLM_MODEL)
|
||||
assistant.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Audio utilities package for Fulloch voice assistant.
|
||||
|
||||
Contains:
|
||||
- beep_manager: Manages beep/notification playback
|
||||
"""
|
||||
|
||||
from .beep_manager import BeepManager
|
||||
|
||||
__all__ = ["BeepManager"]
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
BeepManager module for the voice assistant.
|
||||
|
||||
Handles playing beep/notification sounds in a thread-safe, non-blocking way.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import sounddevice as sd
|
||||
import soundfile as sf
|
||||
import os
|
||||
|
||||
class BeepManager:
|
||||
"""Manages beep/notification playback."""
|
||||
def __init__(self):
|
||||
# Get the path to the controller directory
|
||||
self.audio_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
self.controller_dir = os.path.dirname(self.audio_dir)
|
||||
self.wav_dir = os.path.join(self.controller_dir, "wav")
|
||||
|
||||
def _get_wav_path(self, filename: str) -> str:
|
||||
"""Get full path to wav file in controller directory."""
|
||||
return os.path.join(self.wav_dir, filename)
|
||||
|
||||
def _play_beep(self, filename: str = "activation.wav"):
|
||||
wav_path = self._get_wav_path(filename)
|
||||
data, samplerate = sf.read(wav_path, dtype='float32')
|
||||
sd.play(data, samplerate)
|
||||
sd.wait()
|
||||
|
||||
def play_beep(self, filename: str = "activation.wav"):
|
||||
"""
|
||||
Play a beep sound in a non-blocking way.
|
||||
|
||||
Args:
|
||||
filename: Name of wav file in controller/wav directory
|
||||
"""
|
||||
threading.Thread(target=self._play_beep, args=(filename,), daemon=True).start()
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
# Fulloch AI
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
env_file:
|
||||
- .env
|
||||
container_name: fulloch-ai
|
||||
devices:
|
||||
- /dev/snd:/dev/snd
|
||||
group_add:
|
||||
- audio
|
||||
volumes:
|
||||
- ./data:/app/data:rw
|
||||
|
||||
# Web Search - Searxng
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: searxng
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./searxng_data:/etc/searxng:rw
|
||||
restart: unless-stopped
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
- DAC_OVERRIDE
|
||||
|
||||
volumes:
|
||||
searxng_data: {}
|
||||
app_data: {}
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
# Fulloch AI - GPU
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
env_file:
|
||||
- .env
|
||||
container_name: fulloch-ai
|
||||
devices:
|
||||
- /dev/snd:/dev/snd
|
||||
group_add:
|
||||
- audio
|
||||
volumes:
|
||||
- ./data:/app/data:rw
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
# Web Search - Searxng
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: searxng
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./searxng_data:/etc/searxng:rw
|
||||
restart: unless-stopped
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
- DAC_OVERRIDE
|
||||
|
||||
volumes:
|
||||
searxng_data: {}
|
||||
app_data: {}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Core package for Fulloch voice assistant.
|
||||
|
||||
This package contains the core components:
|
||||
- audio: Audio capture and silence detection
|
||||
- asr: Automatic speech recognition (Moonshine)
|
||||
- tts: Text-to-speech synthesis (Kokoro)
|
||||
- slm: Small language model inference (Qwen)
|
||||
- assistant: Main orchestration and wakeword detection
|
||||
"""
|
||||
|
||||
from .audio import (
|
||||
AudioCapture,
|
||||
is_silent,
|
||||
SAMPLE_RATE,
|
||||
SILENCE_THRESHOLD,
|
||||
)
|
||||
from .asr import load_moonshine, stream_generator
|
||||
from .tts import speak_stream, kpipeline
|
||||
from .slm import load_slm, generate_slm
|
||||
from .assistant import Assistant
|
||||
|
||||
__all__ = [
|
||||
# Audio
|
||||
"AudioCapture",
|
||||
"is_silent",
|
||||
"SAMPLE_RATE",
|
||||
"SILENCE_THRESHOLD",
|
||||
# ASR
|
||||
"load_moonshine",
|
||||
"stream_generator",
|
||||
# TTS
|
||||
"speak_stream",
|
||||
"kpipeline",
|
||||
# SLM
|
||||
"load_slm",
|
||||
"generate_slm",
|
||||
# Assistant
|
||||
"Assistant",
|
||||
]
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Automatic Speech Recognition module using Moonshine ASR.
|
||||
|
||||
Handles loading and running the Moonshine speech recognition model.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Generator
|
||||
|
||||
import torch
|
||||
from transformers import AutoProcessor, MoonshineForConditionalGeneration, pipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Model configuration
|
||||
ASR_MODEL_NAME = "UsefulSensors/moonshine-tiny"
|
||||
|
||||
# Device configuration
|
||||
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
DTYPE = torch.float16 if DEVICE == "cuda" else torch.float32
|
||||
|
||||
|
||||
def load_moonshine():
|
||||
"""
|
||||
Load the Moonshine ASR model and create a pipeline.
|
||||
|
||||
Returns:
|
||||
A Hugging Face pipeline configured for automatic speech recognition
|
||||
"""
|
||||
logger.info(f"Loading {ASR_MODEL_NAME} on {DEVICE}...")
|
||||
|
||||
processor = AutoProcessor.from_pretrained(ASR_MODEL_NAME)
|
||||
asr_model = MoonshineForConditionalGeneration.from_pretrained(ASR_MODEL_NAME).to(
|
||||
device=DEVICE,
|
||||
dtype=DTYPE,
|
||||
)
|
||||
|
||||
asr_pipe = pipeline(
|
||||
task="automatic-speech-recognition",
|
||||
model=asr_model,
|
||||
tokenizer=processor.tokenizer,
|
||||
feature_extractor=processor.feature_extractor,
|
||||
device=-1 if DEVICE == "cuda" else 0,
|
||||
dtype=DTYPE,
|
||||
)
|
||||
|
||||
return asr_pipe
|
||||
|
||||
|
||||
def stream_generator(queue) -> Generator:
|
||||
"""
|
||||
Generator that yields audio from a queue for the ASR pipeline.
|
||||
|
||||
Args:
|
||||
queue: Queue containing audio numpy arrays. None signals stop.
|
||||
|
||||
Yields:
|
||||
Audio numpy arrays until None is received
|
||||
"""
|
||||
while True:
|
||||
audio = queue.get()
|
||||
if audio is None:
|
||||
break
|
||||
yield audio
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Main assistant orchestration module.
|
||||
|
||||
Handles wakeword detection, intent processing, and response generation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from .audio import AudioCapture
|
||||
from .asr import load_moonshine, stream_generator
|
||||
from .tts import speak_stream, remove_emoji
|
||||
from .slm import load_slm, generate_slm, SLM_MODEL
|
||||
|
||||
from utils.system_prompts import getIntentSystemPrompt, getChatSystemPrompt
|
||||
from utils.intent_catch import catchAll
|
||||
import utils.intents as intents
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Assistant:
|
||||
"""
|
||||
Main voice assistant class that orchestrates all components.
|
||||
|
||||
Attributes:
|
||||
wakeword: The activation phrase
|
||||
audio_capture: Audio capture instance
|
||||
asr_pipe: Speech recognition pipeline
|
||||
slm_model: Language model (optional)
|
||||
grammar: JSON grammar for structured output
|
||||
"""
|
||||
|
||||
def __init__(self, wakeword: str, slm_model_path: str = SLM_MODEL):
|
||||
"""
|
||||
Initialize the assistant.
|
||||
|
||||
Args:
|
||||
wakeword: Activation phrase to listen for
|
||||
slm_model_path: Path to SLM model (empty to disable AI)
|
||||
"""
|
||||
self.wakeword = wakeword.lower()
|
||||
self.slm_model_path = slm_model_path
|
||||
self.audio_capture = AudioCapture()
|
||||
|
||||
# Models loaded lazily in transcriber thread
|
||||
self.asr_pipe = None
|
||||
self.slm_model = None
|
||||
self.grammar = None
|
||||
self.intent_prompt = None
|
||||
self.chat_prompt = None
|
||||
|
||||
def _load_models(self):
|
||||
"""Load ASR and optionally SLM models."""
|
||||
self.asr_pipe = load_moonshine()
|
||||
|
||||
if self.slm_model_path:
|
||||
self.grammar, self.slm_model = load_slm(self.slm_model_path)
|
||||
self.intent_prompt = getIntentSystemPrompt()
|
||||
self.chat_prompt = getChatSystemPrompt()
|
||||
|
||||
def _handle_wakeword(self, user_prompt: str) -> str:
|
||||
"""
|
||||
Process user input after wakeword detection.
|
||||
|
||||
Args:
|
||||
user_prompt: Text spoken after the wakeword
|
||||
|
||||
Returns:
|
||||
Response text to speak
|
||||
"""
|
||||
answer = ""
|
||||
run_chat = False
|
||||
|
||||
logger.info(f"WAKEWORD detected: {user_prompt}")
|
||||
|
||||
# Try regex intent catch first (fast path)
|
||||
caught = catchAll(user_prompt)
|
||||
|
||||
if isinstance(caught, dict):
|
||||
# Regex matched an intent
|
||||
logger.info(f"Regex caught intent: {caught}")
|
||||
answer = intents.handle_intent(caught)
|
||||
logger.info(f"Intent answer: {answer}")
|
||||
|
||||
elif self.slm_model:
|
||||
# Use AI for intent detection
|
||||
logger.info(f"AI intent query: {user_prompt}")
|
||||
|
||||
answer = generate_slm(
|
||||
self.slm_model,
|
||||
user_prompt=user_prompt,
|
||||
grammar=self.grammar,
|
||||
system_prompt=self.intent_prompt,
|
||||
)
|
||||
|
||||
logger.info(f"AI answer: {answer}")
|
||||
|
||||
if answer.strip('"'):
|
||||
try:
|
||||
parsed = json.loads(answer)
|
||||
logger.info(f"AI generated intent: {parsed}")
|
||||
answer = intents.handle_intent(parsed)
|
||||
logger.info(f"Intent answer: {answer}")
|
||||
|
||||
if "User question:" in answer:
|
||||
user_prompt = answer
|
||||
run_chat = True
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to load intent from {answer}: {e}")
|
||||
answer = ""
|
||||
else:
|
||||
run_chat = True
|
||||
|
||||
if run_chat:
|
||||
# Provide feedback while processing
|
||||
speak_stream(random.choice([
|
||||
"Okay, let me think about that.",
|
||||
"Just a second.",
|
||||
"Got it, let me think.",
|
||||
"Let's see."
|
||||
]))
|
||||
|
||||
logger.info(f"AI chat query: {user_prompt}")
|
||||
answer = generate_slm(
|
||||
self.slm_model,
|
||||
user_prompt=user_prompt,
|
||||
system_prompt=self.chat_prompt,
|
||||
)
|
||||
logger.info(f"AI chat answer: {answer}")
|
||||
|
||||
# Fallback if no answer
|
||||
if not answer.strip('"'):
|
||||
answer = random.choice([
|
||||
"Sorry, can you repeat that",
|
||||
"I don't understand",
|
||||
"Sorry, I didn't hear you properly",
|
||||
"Can you say that again?"
|
||||
])
|
||||
|
||||
return answer
|
||||
|
||||
def _transcriber_thread(self):
|
||||
"""
|
||||
Main transcription thread that processes audio and responds.
|
||||
"""
|
||||
self._load_models()
|
||||
logger.info("Transcriber started")
|
||||
|
||||
for result in self.asr_pipe(
|
||||
stream_generator(self.audio_capture.audio_queue),
|
||||
batch_size=1,
|
||||
generate_kwargs={"max_new_tokens": 256}
|
||||
):
|
||||
try:
|
||||
text = result.get("text", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
logger.debug(f"Transcribed: {text}")
|
||||
|
||||
if self.wakeword not in text.lower():
|
||||
continue
|
||||
|
||||
# Extract text after wakeword
|
||||
user_prompt = text.lower().split(self.wakeword)[1].strip(",. ").replace('"', '')
|
||||
|
||||
if not user_prompt:
|
||||
logger.debug("Nothing after wakeword")
|
||||
continue
|
||||
|
||||
# Pause transcription while processing
|
||||
self.audio_capture.transcribing = False
|
||||
|
||||
# Process and respond
|
||||
answer = self._handle_wakeword(user_prompt)
|
||||
cleaned = remove_emoji(answer.replace('"', '').replace('*', ''))
|
||||
speak_stream(cleaned)
|
||||
|
||||
# Resume transcription
|
||||
self.audio_capture.transcribing = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription error: {e}")
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Start the assistant and run until interrupted.
|
||||
"""
|
||||
rec_thread = threading.Thread(
|
||||
target=self.audio_capture.recorder_thread,
|
||||
daemon=True
|
||||
)
|
||||
trans_thread = threading.Thread(
|
||||
target=self._transcriber_thread,
|
||||
daemon=True
|
||||
)
|
||||
|
||||
rec_thread.start()
|
||||
trans_thread.start()
|
||||
|
||||
logger.info("Press Ctrl+C to stop.")
|
||||
try:
|
||||
while True:
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Stopping...")
|
||||
self.audio_capture.stop()
|
||||
rec_thread.join(timeout=2)
|
||||
trans_thread.join(timeout=2)
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Audio capture and silence detection module.
|
||||
|
||||
Handles microphone input, silence detection via RMS threshold,
|
||||
and audio buffering for the speech recognition pipeline.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Audio configuration
|
||||
SAMPLE_RATE = 16000
|
||||
CHUNK_DURATION_MS = 200
|
||||
SILENCE_DURATION_MS = 1000
|
||||
MIN_UTTERANCE_MS = 1500
|
||||
MAX_UTTERANCE_MS = 10000
|
||||
SILENCE_THRESHOLD = 0.001
|
||||
|
||||
# Derived values
|
||||
frames_per_chunk = int(SAMPLE_RATE * CHUNK_DURATION_MS / 1000)
|
||||
silence_chunks_needed = max(1, int(SILENCE_DURATION_MS / CHUNK_DURATION_MS))
|
||||
min_utterance_samples = int(SAMPLE_RATE * MIN_UTTERANCE_MS / 1000)
|
||||
max_utterance_samples = int(SAMPLE_RATE * MAX_UTTERANCE_MS / 1000)
|
||||
|
||||
|
||||
def is_silent(chunk: np.ndarray, threshold: float = SILENCE_THRESHOLD) -> bool:
|
||||
"""
|
||||
Check if an audio chunk is silent based on RMS energy.
|
||||
|
||||
Args:
|
||||
chunk: Audio samples as numpy array
|
||||
threshold: RMS threshold below which audio is considered silent
|
||||
|
||||
Returns:
|
||||
True if the chunk is silent, False otherwise
|
||||
"""
|
||||
if chunk.size == 0:
|
||||
return True
|
||||
rms = np.sqrt(np.mean(chunk ** 2))
|
||||
return rms < threshold
|
||||
|
||||
|
||||
class AudioCapture:
|
||||
"""
|
||||
Manages audio capture from microphone with silence detection.
|
||||
|
||||
Attributes:
|
||||
audio_buffer: Deque holding audio chunks
|
||||
audio_queue: Queue for complete utterances ready for transcription
|
||||
running: Flag to control capture loop
|
||||
transcribing: Flag to control whether to process audio
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
chunk_duration_ms: int = CHUNK_DURATION_MS,
|
||||
silence_duration_ms: int = SILENCE_DURATION_MS,
|
||||
min_utterance_ms: int = MIN_UTTERANCE_MS,
|
||||
max_utterance_ms: int = MAX_UTTERANCE_MS,
|
||||
silence_threshold: float = SILENCE_THRESHOLD,
|
||||
):
|
||||
self.sample_rate = sample_rate
|
||||
self.chunk_duration_ms = chunk_duration_ms
|
||||
self.silence_threshold = silence_threshold
|
||||
|
||||
# Derived values
|
||||
self.frames_per_chunk = int(sample_rate * chunk_duration_ms / 1000)
|
||||
self.silence_chunks_needed = max(1, int(silence_duration_ms / chunk_duration_ms))
|
||||
self.min_utterance_samples = int(sample_rate * min_utterance_ms / 1000)
|
||||
self.max_utterance_samples = int(sample_rate * max_utterance_ms / 1000)
|
||||
|
||||
# State
|
||||
self.audio_buffer: deque = deque()
|
||||
self.audio_queue: "queue.Queue[Optional[np.ndarray]]" = queue.Queue()
|
||||
self.running = True
|
||||
self.transcribing = True
|
||||
|
||||
def _audio_callback(self, indata, frames, time_info, status):
|
||||
"""Callback for sounddevice InputStream."""
|
||||
if status:
|
||||
logger.info(status)
|
||||
chunk = indata[:, 0].astype(np.float32)
|
||||
self.audio_buffer.append(chunk)
|
||||
|
||||
def recorder_thread(self):
|
||||
"""
|
||||
Main recording thread that captures audio and detects utterances.
|
||||
|
||||
Runs continuously, accumulating audio chunks and detecting
|
||||
end-of-utterance based on silence duration.
|
||||
"""
|
||||
logger.info("Starting microphone stream...")
|
||||
silence_counter = 0
|
||||
|
||||
with sd.InputStream(
|
||||
channels=1,
|
||||
samplerate=self.sample_rate,
|
||||
dtype="float32",
|
||||
blocksize=self.frames_per_chunk,
|
||||
callback=self._audio_callback,
|
||||
):
|
||||
while self.running:
|
||||
time.sleep(self.chunk_duration_ms / 1000.0)
|
||||
|
||||
if self.transcribing:
|
||||
if not self.audio_buffer:
|
||||
continue
|
||||
|
||||
# Check silence on the most recent chunk
|
||||
last_chunk = self.audio_buffer[-1]
|
||||
if is_silent(last_chunk, self.silence_threshold):
|
||||
silence_counter += 1
|
||||
else:
|
||||
silence_counter = 0
|
||||
|
||||
# Check if we've hit silence threshold or max length
|
||||
buffer_samples = len(self.audio_buffer) * self.frames_per_chunk
|
||||
if (silence_counter < self.silence_chunks_needed and
|
||||
buffer_samples <= self.max_utterance_samples):
|
||||
continue
|
||||
|
||||
# End of utterance - concatenate buffer
|
||||
buf = np.concatenate(list(self.audio_buffer), axis=0)
|
||||
|
||||
# Only enqueue if minimum length met
|
||||
if buf.size >= self.min_utterance_samples:
|
||||
self.audio_queue.put(buf.copy())
|
||||
secs = buf.size / self.sample_rate
|
||||
logger.debug(f"Enqueued {secs:.2f}s for transcription")
|
||||
|
||||
# Reset state
|
||||
self.audio_buffer.clear()
|
||||
silence_counter = 0
|
||||
|
||||
def stop(self):
|
||||
"""Signal the recorder to stop and inject poison pill."""
|
||||
self.running = False
|
||||
self.audio_queue.put(None)
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Small Language Model module using Qwen via llama.cpp.
|
||||
|
||||
Handles loading and running the Qwen language model for intent
|
||||
detection and conversational AI.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
from llama_cpp import Llama, LlamaGrammar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Model configuration
|
||||
SLM_MODEL = "./data/models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf"
|
||||
GRAMMAR_FILE = "./data/models/grammars/json.gbnf"
|
||||
|
||||
N_CONTEXT = 8192
|
||||
N_THREADS = 4
|
||||
N_BATCH = 512
|
||||
|
||||
# Device configuration
|
||||
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def load_slm(
|
||||
model_path: str = SLM_MODEL,
|
||||
grammar_path: str = GRAMMAR_FILE,
|
||||
n_ctx: int = N_CONTEXT,
|
||||
n_threads: int = N_THREADS,
|
||||
n_batch: int = N_BATCH,
|
||||
):
|
||||
"""
|
||||
Load the Small Language Model and JSON grammar.
|
||||
|
||||
Args:
|
||||
model_path: Path to the GGUF model file
|
||||
grammar_path: Path to the JSON grammar file
|
||||
n_ctx: Context window size
|
||||
n_threads: Number of CPU threads
|
||||
n_batch: Batch size for inference
|
||||
|
||||
Returns:
|
||||
Tuple of (grammar, model)
|
||||
"""
|
||||
logger.info(f"Loading {model_path} on {DEVICE}...")
|
||||
|
||||
slm_model = Llama(
|
||||
model_path=model_path,
|
||||
n_ctx=n_ctx,
|
||||
n_threads=n_threads,
|
||||
n_batch=n_batch,
|
||||
n_gpu_layers=-1 if DEVICE == "cuda" else 0
|
||||
)
|
||||
|
||||
grammar = LlamaGrammar.from_file(grammar_path)
|
||||
|
||||
return grammar, slm_model
|
||||
|
||||
|
||||
def generate_slm(
|
||||
slm_model,
|
||||
user_prompt: str,
|
||||
grammar: Optional[LlamaGrammar] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
max_new_tokens: int = N_CONTEXT,
|
||||
temperature: float = 0.7,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a response from the language model.
|
||||
|
||||
Args:
|
||||
slm_model: The loaded Llama model
|
||||
user_prompt: User's input text
|
||||
grammar: Optional grammar constraint for structured output
|
||||
system_prompt: Optional system prompt
|
||||
max_new_tokens: Maximum tokens to generate
|
||||
temperature: Sampling temperature
|
||||
|
||||
Returns:
|
||||
Generated text response
|
||||
"""
|
||||
# Reset before each call to avoid buffer cache issues
|
||||
slm_model.reset()
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
|
||||
stream = slm_model.create_chat_completion(
|
||||
messages=messages,
|
||||
max_tokens=max_new_tokens,
|
||||
grammar=grammar,
|
||||
stream=True,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
full_text = ""
|
||||
for chunk in stream:
|
||||
if "choices" in chunk and len(chunk["choices"]) > 0:
|
||||
delta = chunk["choices"][0].get("delta", {})
|
||||
if "content" in delta:
|
||||
token_text = delta["content"]
|
||||
full_text += token_text
|
||||
|
||||
return full_text
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Text-to-Speech module using Kokoro TTS.
|
||||
|
||||
Handles loading and running the Kokoro text-to-speech model.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import sounddevice as sd
|
||||
import torch
|
||||
from kokoro import KPipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Model configuration
|
||||
TTS_MODEL_NAME = "hexgrad/Kokoro-82M"
|
||||
|
||||
# Device configuration
|
||||
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
# Emoji removal pattern
|
||||
EMOJI_PATTERN = re.compile(
|
||||
"["
|
||||
"\U0001F600-\U0001F64F" # emoticons
|
||||
"\U0001F300-\U0001F5FF" # symbols & pictographs
|
||||
"\U0001F680-\U0001F6FF" # transport & map symbols
|
||||
"\U0001F1E0-\U0001F1FF" # flags
|
||||
"\u2600-\u26FF" # misc symbols
|
||||
"\u2700-\u27BF" # dingbats
|
||||
"]+",
|
||||
flags=re.UNICODE,
|
||||
)
|
||||
|
||||
# Create global pipeline (loads model once)
|
||||
logger.info(f"Loading {TTS_MODEL_NAME} on {DEVICE}...")
|
||||
kpipeline = KPipeline(
|
||||
repo_id=TTS_MODEL_NAME,
|
||||
lang_code="a", # "a" = auto
|
||||
device=DEVICE
|
||||
)
|
||||
|
||||
|
||||
def remove_emoji(text: str) -> str:
|
||||
"""Remove emoji characters from text."""
|
||||
return EMOJI_PATTERN.sub("", text)
|
||||
|
||||
|
||||
def speak_stream(text: str, voice: str = "af_bella", speed: float = 1.2):
|
||||
"""
|
||||
Generate speech from text using Kokoro and stream to speakers.
|
||||
|
||||
Args:
|
||||
text: Text to synthesize
|
||||
voice: Voice model to use (default: af_bella)
|
||||
speed: Speech speed multiplier (default: 1.2)
|
||||
"""
|
||||
generator = kpipeline(
|
||||
text,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
split_pattern=r"\n+",
|
||||
)
|
||||
|
||||
sample_rate = 24000 # Kokoro uses 24 kHz
|
||||
|
||||
for _, _, audio in generator:
|
||||
sd.play(audio, samplerate=sample_rate, blocking=True)
|
||||
@@ -0,0 +1,132 @@
|
||||
# Fulloch Configuration Example
|
||||
# Copy this file to config.yml and update with your values.
|
||||
# See README.md for detailed setup instructions for each integration.
|
||||
|
||||
# =============================================================================
|
||||
# General Settings
|
||||
# =============================================================================
|
||||
general:
|
||||
# Wakeword to activate the assistant (case-insensitive)
|
||||
# Other examples: "alexa", "jeeves", "mycroft"
|
||||
wakeword: "computer"
|
||||
|
||||
# =============================================================================
|
||||
# Spotify Integration
|
||||
# =============================================================================
|
||||
# Register your app at https://developer.spotify.com/dashboard
|
||||
# Required scopes: user-read-playback-state, user-modify-playback-state, user-read-currently-playing
|
||||
spotify:
|
||||
client_id: "your_spotify_client_id"
|
||||
client_secret: "your_spotify_client_secret"
|
||||
redirect_uri: "http://localhost:8888/callback"
|
||||
# Device name as shown in Spotify Connect
|
||||
device_id: "Your Speaker Name"
|
||||
# Enable Pioneer AVR integration for audio output switching
|
||||
# Set to false if not using an AVR
|
||||
use_avr: false
|
||||
|
||||
# =============================================================================
|
||||
# Philips Hue Lighting
|
||||
# =============================================================================
|
||||
# Press the button on your Hue Bridge before first connection
|
||||
philips:
|
||||
hue_hub_ip: "192.168.1.100"
|
||||
|
||||
# =============================================================================
|
||||
# Home Assistant Integration
|
||||
# =============================================================================
|
||||
# Connect to your Home Assistant instance via REST API
|
||||
# 1. Go to your HA profile: http://your-ha:8123/profile
|
||||
# 2. Scroll to "Long-Lived Access Tokens" and create a token
|
||||
#
|
||||
# IMPORTANT: This integration must be explicitly enabled. When enabled, it
|
||||
# registers generic tool names like "turn_on" and "turn_off" which may conflict
|
||||
# with other integrations (e.g., Philips Hue lighting). Only enable if you want
|
||||
# Home Assistant to be your primary home automation controller.
|
||||
home_assistant:
|
||||
# Set to true to enable Home Assistant tools (disabled by default)
|
||||
enabled: false
|
||||
# URL to your Home Assistant instance
|
||||
url: "http://192.168.1.50:8123"
|
||||
# Long-lived access token from your HA profile
|
||||
token: "your_long_lived_access_token"
|
||||
# Request timeout in seconds
|
||||
timeout: 10
|
||||
# Map friendly names to entity IDs for easier voice control
|
||||
# Keys are lowercase, values are full entity IDs
|
||||
entity_aliases:
|
||||
living room lights: "light.living_room"
|
||||
bedroom lights: "light.bedroom"
|
||||
front door: "lock.front_door"
|
||||
garage: "cover.garage_door"
|
||||
thermostat: "climate.main"
|
||||
movie time: "scene.movie_time"
|
||||
bedtime: "script.bedtime"
|
||||
|
||||
# =============================================================================
|
||||
# Weather - Bureau of Meteorology (Australia)
|
||||
# =============================================================================
|
||||
# BOM FTP server for weather data (Australian locations only)
|
||||
# Find your local XML file at ftp://ftp.bom.gov.au/anon/gen/fwo/
|
||||
bom:
|
||||
host: "ftp.bom.gov.au"
|
||||
path: "/anon/gen/fwo/IDN11060.xml"
|
||||
# Default location for weather forecasts
|
||||
default: "Sydney"
|
||||
|
||||
# =============================================================================
|
||||
# Google Calendar Integration
|
||||
# =============================================================================
|
||||
# 1. Create credentials at https://console.cloud.google.com/
|
||||
# 2. Enable Google Calendar API
|
||||
# 3. Download OAuth 2.0 credentials JSON file
|
||||
google:
|
||||
# Path to OAuth client credentials file
|
||||
cred_file: "./data/credentials.json"
|
||||
# Path to store OAuth token (auto-generated on first auth)
|
||||
token_file: "./data/token.json"
|
||||
|
||||
# =============================================================================
|
||||
# LG ThinQ (Smart Appliances)
|
||||
# =============================================================================
|
||||
# Get credentials from LG ThinQ developer portal
|
||||
thinq:
|
||||
access_token: "your_thinq_access_token"
|
||||
country_code: "AU"
|
||||
client_id: "your_thinq_client_id"
|
||||
|
||||
# =============================================================================
|
||||
# Web Search - SearXNG
|
||||
# =============================================================================
|
||||
# Local SearXNG instance for web search (started via docker-compose)
|
||||
search:
|
||||
searxng_url: "http://localhost:8080/search"
|
||||
|
||||
# =============================================================================
|
||||
# LG WebOS TV Control
|
||||
# =============================================================================
|
||||
# Find TV IP in TV settings: Network > Wi-Fi Connection > Advanced
|
||||
# Find MAC address in TV settings: Network > Wi-Fi Connection > Advanced
|
||||
webos:
|
||||
ip_address: "192.168.1.101"
|
||||
mac_address: "AA:BB:CC:DD:EE:FF"
|
||||
|
||||
# =============================================================================
|
||||
# Pioneer/Onkyo AVR (Audio Receiver)
|
||||
# =============================================================================
|
||||
# Uses eISCP protocol over network
|
||||
# Default port is 60128 for most Pioneer/Onkyo receivers
|
||||
pioneer:
|
||||
avr_host: "192.168.1.102"
|
||||
avr_port: 60128
|
||||
|
||||
# =============================================================================
|
||||
# Airtouch HVAC Control
|
||||
# =============================================================================
|
||||
# Map zone names to zone IDs (0-indexed)
|
||||
# Zone IDs correspond to the order in your Airtouch system
|
||||
airtouch:
|
||||
living room: 0
|
||||
bedroom: 1
|
||||
office: 2
|
||||
upstairs: 3
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 699 KiB |
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Directory definitions
|
||||
BASE_DIR="$(pwd)/data/models"
|
||||
GRAMMAR_DIR="$BASE_DIR/grammars"
|
||||
HUB_DIR="$BASE_DIR/hub"
|
||||
|
||||
# Model specific variables
|
||||
QWEN_REPO="unsloth/Qwen3-4B-Instruct-2507-GGUF"
|
||||
QWEN_FILE="Qwen3-4B-Instruct-2507-Q4_K_M.gguf"
|
||||
|
||||
# Define the cache folders
|
||||
KOKORO_DIR="$HUB_DIR/models--hexgrad--Kokoro-82M"
|
||||
MOONSHINE_DIR="$HUB_DIR/models--UsefulSensors--moonshine-tiny"
|
||||
|
||||
# 1. Ensure Dependencies are installed
|
||||
if ! command -v huggingface-cli &> /dev/null; then
|
||||
echo "⬇️ huggingface-cli not found. Installing..."
|
||||
pip install -U "huggingface_hub[cli]"
|
||||
fi
|
||||
|
||||
# 2. Create Directory Structure
|
||||
echo "📂 Checking directory structure..."
|
||||
mkdir -p "$GRAMMAR_DIR"
|
||||
mkdir -p "$HUB_DIR"
|
||||
|
||||
# 3. Check and Download json.gbnf
|
||||
if [ ! -f "$GRAMMAR_DIR/json.gbnf" ]; then
|
||||
echo "⬇️ Downloading json.gbnf..."
|
||||
wget -q --show-progress -O "$GRAMMAR_DIR/json.gbnf" \
|
||||
"https://raw.githubusercontent.com/ggml-org/llama.cpp/master/grammars/json.gbnf"
|
||||
else
|
||||
echo "✅ json.gbnf exists."
|
||||
fi
|
||||
|
||||
# 4. Check and Download Qwen3 GGUF
|
||||
if [ ! -f "$BASE_DIR/$QWEN_FILE" ]; then
|
||||
echo "⬇️ Downloading $QWEN_FILE..."
|
||||
huggingface-cli download "$QWEN_REPO" "$QWEN_FILE" \
|
||||
--local-dir "$BASE_DIR" \
|
||||
--local-dir-use-symlinks False
|
||||
else
|
||||
echo "✅ $QWEN_FILE exists."
|
||||
fi
|
||||
|
||||
# 5. Check and Download Kokoro-82M (TTS)
|
||||
if [ ! -d "$KOKORO_DIR" ]; then
|
||||
echo "⬇️ Downloading Kokoro-82M..."
|
||||
huggingface-cli download hexgrad/Kokoro-82M \
|
||||
--cache-dir "$HUB_DIR"
|
||||
else
|
||||
echo "✅ Kokoro-82M exists."
|
||||
fi
|
||||
|
||||
# 6. Check and Download Moonshine Tiny (STT)
|
||||
if [ ! -d "$MOONSHINE_DIR" ]; then
|
||||
echo "⬇️ Downloading Moonshine Tiny..."
|
||||
huggingface-cli download UsefulSensors/moonshine-tiny \
|
||||
--cache-dir "$HUB_DIR"
|
||||
else
|
||||
echo "✅ Moonshine-tiny exists."
|
||||
fi
|
||||
|
||||
# 7. Prompt the user
|
||||
read -p "Are you using a GPU? (y/n): " response
|
||||
response=${response,,}
|
||||
if [[ "$response" == "y" || "$response" == "yes" ]]; then
|
||||
mv Dockerfile Dockerfile_cpu
|
||||
mv Dockerfile_gpu Dockerfile
|
||||
mv compose.yml compose_cpu.yml
|
||||
mv compose_gpu.yml compose.yml
|
||||
echo "✅ Using GPU enabled containers"
|
||||
else
|
||||
echo "✅ Using default containers"
|
||||
fi
|
||||
|
||||
# 8. Launch Docker Compose
|
||||
echo "🚀 All files checked. Starting services..."
|
||||
docker compose up -d
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
PRIVATE_REPO_DIR=$(pwd)
|
||||
PUBLIC_REPO_DIR="../fulloch" # Path to your local public repo clone
|
||||
COMMIT_MSG="V1.0: $(date +'%Y-%m-%d')"
|
||||
|
||||
mkdir -p "$PUBLIC_REPO_DIR"
|
||||
|
||||
# 1. Check if public repo is clean
|
||||
cd "$PUBLIC_REPO_DIR" || exit
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
echo "Error: Public repo has uncommitted changes. Please clean it first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Clean out old public files (except .git) to handle deletions
|
||||
# Use find to delete everything but .git directory
|
||||
find . -maxdepth 1 -not -name '.git' -not -name '.' -exec rm -rf {} +
|
||||
|
||||
# 3. Export clean snapshot from Private Repo
|
||||
cd "$PRIVATE_REPO_DIR" || exit
|
||||
# Creates a tarball of the current HEAD, excluding 'export-ignore' files, and pipes it to tar extract in public dir
|
||||
git archive HEAD | tar -x -C "$PUBLIC_REPO_DIR"
|
||||
|
||||
# 4. Commit and Push Public
|
||||
cd "$PUBLIC_REPO_DIR" || exit
|
||||
git add .
|
||||
git commit -m "$COMMIT_MSG"
|
||||
git push origin main
|
||||
|
||||
echo "✅ Public repo updated successfully!"
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "fulloch"
|
||||
version = "0.1.0"
|
||||
description = "A fully local, privacy-focused AI voice home assistant"
|
||||
readme = "readme.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "Fulloch Contributors"}
|
||||
]
|
||||
keywords = [
|
||||
"voice-assistant",
|
||||
"home-automation",
|
||||
"speech-recognition",
|
||||
"text-to-speech",
|
||||
"local-ai",
|
||||
"privacy",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Home Automation",
|
||||
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
|
||||
# Core dependencies required for basic operation
|
||||
dependencies = [
|
||||
"numpy>=1.22.0",
|
||||
"setuptools>=80.0.0",
|
||||
"pyyaml>=6.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
# Audio
|
||||
"sounddevice>=0.5.0",
|
||||
"soundfile>=0.13.0",
|
||||
# AI/ML Core
|
||||
"torch>=2.0.0",
|
||||
"transformers>=4.40.0",
|
||||
"kokoro>=0.9.0",
|
||||
"accelerate>=1.0.0",
|
||||
"llama-cpp-python>=0.3.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Smart home integrations
|
||||
smart-home = [
|
||||
"phue>=1.1.0", # Philips Hue
|
||||
"spotipy>=2.25.0", # Spotify
|
||||
"pyairtouch>=3.0.0", # Airtouch HVAC
|
||||
"thinqconnect>=1.0.0", # LG ThinQ
|
||||
"bscpylgtv>=0.5.0", # LG WebOS TV
|
||||
]
|
||||
|
||||
# Google services
|
||||
google = [
|
||||
"google-auth>=2.0.0",
|
||||
"google-auth-oauthlib>=0.4.0",
|
||||
"google-api-python-client>=2.0.0",
|
||||
]
|
||||
|
||||
# Web search
|
||||
search = [
|
||||
"beautifulsoup4>=4.12.0",
|
||||
"requests>=2.31.0",
|
||||
]
|
||||
|
||||
# Utilities
|
||||
utils = [
|
||||
"xmltodict>=0.13.0", # Weather XML parsing
|
||||
"word2number>=1.1", # Timer duration parsing
|
||||
]
|
||||
|
||||
# Development tools
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"ruff>=0.4.0",
|
||||
"mypy>=1.10.0",
|
||||
]
|
||||
|
||||
# All optional dependencies
|
||||
all = [
|
||||
"fulloch[smart-home,google,search,utils]",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
fulloch = "app:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/liampetti/fulloch"
|
||||
Documentation = "https://github.com/liampetti/fulloch#readme"
|
||||
Repository = "https://github.com/liampetti/fulloch"
|
||||
Issues = "https://github.com/liampetti/fulloch/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["core*", "tools*", "utils*", "audio*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["*.txt", "*.yml", "*.yaml"]
|
||||
|
||||
# Pytest configuration
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = [
|
||||
"-v",
|
||||
"--tb=short",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
# Ruff linter configuration
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
]
|
||||
|
||||
# MyPy configuration
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
# Coverage configuration
|
||||
[tool.coverage.run]
|
||||
source = ["core", "tools", "utils"]
|
||||
omit = ["tests/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if __name__ == .__main__.:",
|
||||
"raise NotImplementedError",
|
||||
]
|
||||
@@ -0,0 +1,290 @@
|
||||
# Fulloch
|
||||
|
||||
<p align="center">
|
||||
<img src="fulloch.png" alt="Fulloch Logo" width="200">
|
||||
</p>
|
||||
|
||||
> The **Ful**ly **Loc**al **H**ome Voice Assistant
|
||||
|
||||
A privacy-focused voice assistant that runs speech recognition, text-to-speech, and language model inference entirely on-device with no cloud dependencies.
|
||||
|
||||
## Features
|
||||
|
||||
- **100% Local Processing**: All AI runs on your hardware
|
||||
- **Privacy First**: No data leaves your device for AI processing
|
||||
- **Low Latency**: Optimized for real-time voice interaction
|
||||
- **Extensible**: Easy to add new smart home integrations
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
+------------------+
|
||||
| Microphone |
|
||||
+--------+---------+
|
||||
|
|
||||
+--------v---------+
|
||||
| Audio Capture |
|
||||
| (Silence Det.) |
|
||||
+--------+---------+
|
||||
|
|
||||
+--------v---------+
|
||||
| Moonshine ASR |
|
||||
| (Speech→Text) |
|
||||
+--------+---------+
|
||||
|
|
||||
+---------------+---------------+
|
||||
| |
|
||||
+--------v---------+ +--------v---------+
|
||||
| Regex Intent | | Qwen 3 SLM |
|
||||
| (Fast Path) | | (AI Intent) |
|
||||
+--------+---------+ +--------+---------+
|
||||
| |
|
||||
+---------------+---------------+
|
||||
|
|
||||
+--------v---------+
|
||||
| Tool Registry |
|
||||
| (Execute Cmd) |
|
||||
+--------+---------+
|
||||
|
|
||||
+--------v---------+
|
||||
| Kokoro TTS |
|
||||
| (Text→Speech) |
|
||||
+--------+---------+
|
||||
|
|
||||
+--------v---------+
|
||||
| Speaker |
|
||||
+------------------+
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- CUDA-capable GPU (recommended) or CPU
|
||||
- ~4GB disk space for models
|
||||
- Microphone and speakers
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/liampetti/fulloch.git
|
||||
cd fulloch
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
cp data/config.example.yml data/config.yml
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `data/config.yml` with your settings (see Configuration section below).
|
||||
|
||||
### 3. Download Models
|
||||
|
||||
The launch script handles model downloads automatically:
|
||||
|
||||
```bash
|
||||
./launch.sh
|
||||
```
|
||||
|
||||
Or manually download:
|
||||
- [Qwen3-4B-Instruct GGUF](https://huggingface.co/Qwen) → `data/models/`
|
||||
- Moonshine and Kokoro download automatically on first run
|
||||
|
||||
### 4. Run
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
Say your wakeword (default: "computer") followed by a command.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
```bash
|
||||
./launch.sh # Downloads models, configures GPU/CPU, starts services
|
||||
```
|
||||
|
||||
The launch script:
|
||||
1. Downloads required models if not present
|
||||
2. Detects GPU availability
|
||||
3. Starts the assistant and SearXNG search service
|
||||
|
||||
## Configuration
|
||||
|
||||
### General Settings
|
||||
|
||||
```yaml
|
||||
general:
|
||||
wakeword: "computer" # Activation phrase
|
||||
```
|
||||
|
||||
### Spotify
|
||||
|
||||
1. Create an app at [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
||||
2. Add `http://localhost:8888/callback` as redirect URI
|
||||
3. Configure:
|
||||
|
||||
```yaml
|
||||
spotify:
|
||||
client_id: "your_client_id"
|
||||
client_secret: "your_client_secret"
|
||||
redirect_uri: "http://localhost:8888/callback"
|
||||
device_id: "Your Speaker Name"
|
||||
use_avr: false # Enable Pioneer AVR integration when playing music (turn on amplifier/sound system before playing music)
|
||||
```
|
||||
|
||||
### Philips Hue
|
||||
|
||||
1. Press the button on your Hue Bridge
|
||||
2. Run the app to auto-register
|
||||
3. Configure:
|
||||
|
||||
```yaml
|
||||
philips:
|
||||
hue_hub_ip: "192.168.1.100"
|
||||
```
|
||||
|
||||
### Google Calendar
|
||||
|
||||
1. Create credentials at [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Enable Google Calendar API
|
||||
3. Download OAuth credentials JSON
|
||||
4. Configure:
|
||||
|
||||
```yaml
|
||||
google:
|
||||
cred_file: "./data/credentials.json"
|
||||
token_file: "./data/token.json"
|
||||
```
|
||||
|
||||
### BOM Australia Weather
|
||||
|
||||
```yaml
|
||||
bom:
|
||||
default: "Sydney" # Default location for weather
|
||||
```
|
||||
|
||||
### Home Assistant
|
||||
|
||||
Connect to a Home Assistant instance to control all your devices through a single integration.
|
||||
|
||||
1. Create a Long-Lived Access Token in your HA profile (`http://your-ha:8123/profile`)
|
||||
2. Configure:
|
||||
|
||||
```yaml
|
||||
home_assistant:
|
||||
enabled: true # Must be explicitly enabled
|
||||
url: "http://192.168.1.50:8123"
|
||||
token: "your_long_lived_token"
|
||||
entity_aliases: # Map friendly names to entity IDs
|
||||
living room lights: "light.living_room"
|
||||
front door: "lock.front_door"
|
||||
```
|
||||
|
||||
**Important**: The Home Assistant integration is **disabled by default**. When enabled, it registers generic tool names like `turn_on`, `turn_off`, and `toggle` which may conflict with other integrations (e.g., Philips Hue lighting tools). Only enable this if you want Home Assistant to be your primary home automation controller.
|
||||
|
||||
If you use both Home Assistant and direct integrations (like Philips Hue), keep `enabled: false` and use the direct integrations instead.
|
||||
|
||||
### Other Integrations
|
||||
|
||||
See `data/config.example.yml` for all available integrations:
|
||||
- LG ThinQ (smart appliances)
|
||||
- WebOS TV control
|
||||
- Pioneer/Onkyo AVR
|
||||
- Airtouch HVAC
|
||||
- SearXNG web search
|
||||
|
||||
## Voice Commands
|
||||
|
||||
### Basic Commands (No AI Required)
|
||||
|
||||
| Action | Examples |
|
||||
|--------|----------|
|
||||
| **Music** | "Play music", "Stop", "Pause", "Skip", "Resume" |
|
||||
| **Timers** | "Set timer for 10 minutes", "Get timers" |
|
||||
| **Time** | "What time is it?" |
|
||||
|
||||
### AI-Powered Commands
|
||||
|
||||
| Action | Examples |
|
||||
|--------|----------|
|
||||
| **Lights** | "Turn on the kitchen lights", "Dim the bedroom to 50%" |
|
||||
| **Climate** | "Set the office to 22 degrees", "Turn off the AC" |
|
||||
| **Calendar** | "What's on today?", "What events do I have this week?" |
|
||||
| **TV** | "Turn on the TV", "Movie night" |
|
||||
| **Weather** | "What's the weather forecast?" |
|
||||
| **Search** | "Search for the latest news about..." |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
fulloch/
|
||||
├── app.py # Entry point
|
||||
├── core/ # Core modules
|
||||
│ ├── audio.py # Audio capture and silence detection
|
||||
│ ├── asr.py # Moonshine speech recognition
|
||||
│ ├── tts.py # Kokoro text-to-speech
|
||||
│ ├── slm.py # Qwen language model
|
||||
│ └── assistant.py # Main orchestration
|
||||
├── tools/ # Smart home integrations
|
||||
│ ├── tool_registry.py
|
||||
│ ├── spotify.py
|
||||
│ ├── lighting.py
|
||||
│ └── ...
|
||||
├── utils/ # Utilities
|
||||
│ ├── intent_catch.py # Regex intent matching
|
||||
│ ├── intents.py # Intent handler
|
||||
│ └── system_prompts.py
|
||||
├── audio/ # Audio utilities
|
||||
│ └── beep_manager.py
|
||||
└── data/ # Configuration and models
|
||||
├── config.yml # Your configuration
|
||||
└── models/ # Downloaded models
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No audio input detected
|
||||
|
||||
- Check microphone permissions
|
||||
- Verify microphone is set as default input device
|
||||
- Adjust `SILENCE_THRESHOLD` in `core/audio.py` if too sensitive/insensitive
|
||||
|
||||
### Model loading fails
|
||||
|
||||
- Ensure sufficient disk space (~4GB)
|
||||
- Check CUDA installation if using GPU
|
||||
- Verify model files are in `data/models/`
|
||||
|
||||
### Spotify not working
|
||||
|
||||
- Run `python tools/spotify.py` to test authentication
|
||||
- Verify redirect URI matches in Spotify Dashboard
|
||||
- Check that Spotify device is active
|
||||
|
||||
### High CPU/Memory usage
|
||||
|
||||
- Use GPU acceleration if available
|
||||
- Reduce `N_CONTEXT` in `core/slm.py` for less memory
|
||||
- Disable SLM for basic commands only (set `SLM_MODEL = ""`)
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on:
|
||||
- Adding new tools
|
||||
- Code style
|
||||
- Pull request process
|
||||
|
||||
## Similar Projects
|
||||
|
||||
- [Rhasspy](https://github.com/rhasspy) - Open source voice assistant toolkit
|
||||
- [Home Assistant](https://github.com/home-assistant) - Full home automation platform
|
||||
- [OpenVoiceOS](https://github.com/OpenVoiceOS) - Community-driven voice assistant
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see [LICENSE](LICENSE) for details.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Core dependencies
|
||||
numpy==1.22.0
|
||||
setuptools==80.9.0
|
||||
xmltodict==1.0.2
|
||||
word2number==1.1
|
||||
beautifulsoup4==4.14.2
|
||||
|
||||
# Audio processing
|
||||
sounddevice==0.5.3
|
||||
soundfile==0.13.1
|
||||
torchaudio==2.8.0
|
||||
|
||||
# AI and ML
|
||||
torch==2.8.0
|
||||
transformers==4.57.1
|
||||
kokoro==0.9.4
|
||||
accelerate==1.12.0
|
||||
llama_cpp_python==0.3.16
|
||||
|
||||
# Smart home integration
|
||||
phue==1.1.0
|
||||
spotipy==2.25.1
|
||||
pyairtouch==3.1.0
|
||||
thinqconnect==1.0.8
|
||||
bscpylgtv==0.5.0
|
||||
google-auth==2.0.0
|
||||
google-auth-oauthlib==0.4.0
|
||||
google-api-python-client==2.0.0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Test suite for Fulloch voice assistant.
|
||||
"""
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Pytest configuration and fixtures for Fulloch tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Provide a temporary directory for tests."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Provide a mock configuration dictionary."""
|
||||
return {
|
||||
"general": {
|
||||
"wakeword": "hey test"
|
||||
},
|
||||
"default": "Sydney",
|
||||
"use_avr": False,
|
||||
"spotify": {
|
||||
"client_id": "test_client_id",
|
||||
"client_secret": "test_client_secret",
|
||||
"redirect_uri": "http://localhost:8888/callback",
|
||||
"device_id": "Test Device"
|
||||
},
|
||||
"philips": {
|
||||
"hue_hub_ip": "192.168.1.100"
|
||||
},
|
||||
"bom": {
|
||||
"host": "ftp.bom.gov.au",
|
||||
"path": "/anon/gen/fwo/IDN11060.xml"
|
||||
},
|
||||
"google": {
|
||||
"cred_file": "./data/credentials.json",
|
||||
"token_file": "./data/token.json"
|
||||
},
|
||||
"thinq": {
|
||||
"access_token": "test_token",
|
||||
"country_code": "AU",
|
||||
"client_id": "test_client"
|
||||
},
|
||||
"search": {
|
||||
"searxng_url": "http://localhost:8080/search"
|
||||
},
|
||||
"webos": {
|
||||
"ip_address": "192.168.1.101",
|
||||
"mac_address": "AA:BB:CC:DD:EE:FF"
|
||||
},
|
||||
"pioneer": {
|
||||
"avr_host": "192.168.1.102",
|
||||
"avr_port": 60128
|
||||
},
|
||||
"airtouch": {
|
||||
"living room": 0,
|
||||
"bedroom": 1,
|
||||
"office": 2
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_file(temp_dir, mock_config):
|
||||
"""Create a temporary config file."""
|
||||
import yaml
|
||||
|
||||
config_path = temp_dir / "config.yml"
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(mock_config, f)
|
||||
|
||||
return config_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tool_registry():
|
||||
"""Provide a fresh tool registry for testing."""
|
||||
from tools.tool_registry import ToolRegistry
|
||||
return ToolRegistry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_audio_queue():
|
||||
"""Provide a mock audio queue."""
|
||||
import queue
|
||||
return queue.Queue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_audio_chunk():
|
||||
"""Provide a sample audio chunk for testing."""
|
||||
import numpy as np
|
||||
# Generate 200ms of silence at 16kHz
|
||||
return np.zeros(3200, dtype=np.float32)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_audio_with_speech():
|
||||
"""Provide a sample audio chunk with simulated speech."""
|
||||
import numpy as np
|
||||
# Generate 200ms of noise at 16kHz (simulates speech)
|
||||
return np.random.randn(3200).astype(np.float32) * 0.1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_config(mock_config):
|
||||
"""Patch the config loading for modules that load config at import."""
|
||||
with patch("builtins.open", MagicMock()):
|
||||
with patch("yaml.safe_load", return_value=mock_config):
|
||||
yield mock_config
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Tests for the regex intent catching module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from utils.intent_catch import (
|
||||
catchAll,
|
||||
extract_after_play,
|
||||
extract_stop,
|
||||
extract_skip,
|
||||
extract_resume,
|
||||
extract_timer,
|
||||
has_time_query,
|
||||
list_timers,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractAfterPlay:
|
||||
"""Tests for play command extraction."""
|
||||
|
||||
def test_simple_play(self):
|
||||
result = extract_after_play("play some rock music")
|
||||
assert result == "some rock music"
|
||||
|
||||
def test_play_artist(self):
|
||||
result = extract_after_play("play Taylor Swift")
|
||||
assert result == "Taylor Swift"
|
||||
|
||||
def test_play_with_extra_words(self):
|
||||
result = extract_after_play("please play the Beatles")
|
||||
assert result is None # Doesn't match pattern starting with play
|
||||
|
||||
def test_no_play_command(self):
|
||||
result = extract_after_play("what's the weather")
|
||||
assert result is None
|
||||
|
||||
def test_play_case_insensitive(self):
|
||||
result = extract_after_play("PLAY jazz music")
|
||||
assert result == "jazz music"
|
||||
|
||||
|
||||
class TestExtractStop:
|
||||
"""Tests for stop/pause command extraction."""
|
||||
|
||||
def test_stop(self):
|
||||
assert extract_stop("stop") is True
|
||||
|
||||
def test_pause(self):
|
||||
assert extract_stop("pause") is True
|
||||
|
||||
def test_halt(self):
|
||||
assert extract_stop("halt") is True
|
||||
|
||||
def test_stop_with_whitespace(self):
|
||||
assert extract_stop(" stop ") is True
|
||||
|
||||
def test_stop_case_insensitive(self):
|
||||
assert extract_stop("STOP") is True
|
||||
|
||||
def test_not_stop(self):
|
||||
assert extract_stop("play music") is None
|
||||
|
||||
|
||||
class TestExtractSkip:
|
||||
"""Tests for skip command extraction."""
|
||||
|
||||
def test_skip(self):
|
||||
assert extract_skip("skip") is True
|
||||
|
||||
def test_skip_with_whitespace(self):
|
||||
assert extract_skip(" skip") is True
|
||||
|
||||
def test_not_skip(self):
|
||||
assert extract_skip("play next") is None
|
||||
|
||||
|
||||
class TestExtractResume:
|
||||
"""Tests for resume command extraction."""
|
||||
|
||||
def test_resume(self):
|
||||
assert extract_resume("resume") is True
|
||||
|
||||
def test_resume_with_whitespace(self):
|
||||
assert extract_resume(" resume") is True
|
||||
|
||||
def test_not_resume(self):
|
||||
assert extract_resume("continue playing") is None
|
||||
|
||||
|
||||
class TestHasTimeQuery:
|
||||
"""Tests for time query detection."""
|
||||
|
||||
def test_what_time_is_it(self):
|
||||
assert has_time_query("what time is it") is True
|
||||
|
||||
def test_whats_the_time(self):
|
||||
assert has_time_query("what's the time") is True
|
||||
|
||||
def test_whats_the_time_no_apostrophe(self):
|
||||
assert has_time_query("whats the time") is True
|
||||
|
||||
def test_not_time_query(self):
|
||||
assert has_time_query("set a timer") is None
|
||||
|
||||
|
||||
class TestExtractTimer:
|
||||
"""Tests for timer duration extraction."""
|
||||
|
||||
def test_start_timer_minutes(self):
|
||||
result = extract_timer("start timer ten minutes")
|
||||
assert result == "ten minutes"
|
||||
|
||||
def test_set_timer_for(self):
|
||||
result = extract_timer("set timer for 2 hours")
|
||||
assert result == "2 hours"
|
||||
|
||||
def test_start_a_timer(self):
|
||||
result = extract_timer("start a timer thirty seconds please")
|
||||
assert result == "thirty seconds"
|
||||
|
||||
def test_not_timer(self):
|
||||
result = extract_timer("what time is it")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestListTimers:
|
||||
"""Tests for list timers command."""
|
||||
|
||||
def test_get_timers(self):
|
||||
assert list_timers("get timers") is True
|
||||
|
||||
def test_get_timer(self):
|
||||
assert list_timers("get timer") is True
|
||||
|
||||
def test_not_list_timers(self):
|
||||
assert list_timers("start timer") is None
|
||||
|
||||
|
||||
class TestCatchAll:
|
||||
"""Tests for the main catchAll function."""
|
||||
|
||||
def test_catch_play(self):
|
||||
result = catchAll("play some jazz")
|
||||
assert result == {"intent": "play_song", "args": ["some jazz"]}
|
||||
|
||||
def test_catch_stop(self):
|
||||
result = catchAll("stop")
|
||||
assert result == {"intent": "pause", "args": []}
|
||||
|
||||
def test_catch_time(self):
|
||||
result = catchAll("what time is it")
|
||||
assert result == {"intent": "get_time", "args": []}
|
||||
|
||||
def test_catch_skip(self):
|
||||
result = catchAll("skip")
|
||||
assert result == {"intent": "skip", "args": []}
|
||||
|
||||
def test_catch_resume(self):
|
||||
result = catchAll("resume")
|
||||
assert result == {"intent": "resume", "args": []}
|
||||
|
||||
def test_catch_timer(self):
|
||||
result = catchAll("start timer ten minutes")
|
||||
assert result == {"intent": "start_countdown", "args": ["ten minutes"]}
|
||||
|
||||
def test_catch_list_timers(self):
|
||||
result = catchAll("get timers")
|
||||
assert result == {"intent": "list_timers", "args": []}
|
||||
|
||||
def test_no_match_returns_original(self):
|
||||
original = "tell me a joke"
|
||||
result = catchAll(original)
|
||||
assert result == original
|
||||
|
||||
def test_complex_unmatched_query(self):
|
||||
original = "what's the weather forecast for tomorrow"
|
||||
result = catchAll(original)
|
||||
assert result == original
|
||||
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Tests for the tool registry module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from tools.tool_registry import (
|
||||
ToolRegistry,
|
||||
tool,
|
||||
ParameterType,
|
||||
ParameterSchema,
|
||||
FunctionSchema,
|
||||
)
|
||||
|
||||
|
||||
class TestToolRegistry:
|
||||
"""Tests for the ToolRegistry class."""
|
||||
|
||||
def test_register_simple_function(self, mock_tool_registry):
|
||||
"""Test registering a simple function."""
|
||||
|
||||
@mock_tool_registry.register_tool
|
||||
def test_func():
|
||||
return "hello"
|
||||
|
||||
assert "test_func" in mock_tool_registry._tools
|
||||
assert mock_tool_registry.get_tool("test_func") is not None
|
||||
|
||||
def test_register_with_custom_name(self, mock_tool_registry):
|
||||
"""Test registering with a custom name."""
|
||||
|
||||
def my_function():
|
||||
return "world"
|
||||
|
||||
mock_tool_registry.register_tool(my_function, name="custom_name")
|
||||
|
||||
assert "custom_name" in mock_tool_registry._tools
|
||||
assert mock_tool_registry.get_tool("custom_name") is not None
|
||||
|
||||
def test_register_with_aliases(self, mock_tool_registry):
|
||||
"""Test registering with aliases."""
|
||||
|
||||
def greet():
|
||||
return "hi"
|
||||
|
||||
mock_tool_registry.register_tool(
|
||||
greet,
|
||||
name="greet",
|
||||
aliases=["hello", "hi"]
|
||||
)
|
||||
|
||||
assert mock_tool_registry.get_tool("greet") is not None
|
||||
assert mock_tool_registry.get_tool("hello") is not None
|
||||
assert mock_tool_registry.get_tool("hi") is not None
|
||||
|
||||
def test_execute_tool(self, mock_tool_registry):
|
||||
"""Test executing a registered tool."""
|
||||
|
||||
def add(a: int, b: int) -> int:
|
||||
return a + b
|
||||
|
||||
mock_tool_registry.register_tool(add, name="add")
|
||||
result = mock_tool_registry.execute_tool("add", kwargs={"a": 2, "b": 3})
|
||||
|
||||
assert result == 5
|
||||
|
||||
def test_execute_tool_with_args(self, mock_tool_registry):
|
||||
"""Test executing a tool with positional args."""
|
||||
|
||||
def multiply(x: int, y: int) -> int:
|
||||
return x * y
|
||||
|
||||
mock_tool_registry.register_tool(multiply, name="multiply")
|
||||
result = mock_tool_registry.execute_tool("multiply", args=[4, 5])
|
||||
|
||||
assert result == 20
|
||||
|
||||
def test_execute_unknown_tool(self, mock_tool_registry):
|
||||
"""Test executing an unknown tool raises error."""
|
||||
with pytest.raises(ValueError, match="Unknown tool"):
|
||||
mock_tool_registry.execute_tool("nonexistent")
|
||||
|
||||
def test_get_schema(self, mock_tool_registry):
|
||||
"""Test getting a function schema."""
|
||||
|
||||
def my_func(name: str, count: int = 1) -> str:
|
||||
return f"{name} x {count}"
|
||||
|
||||
mock_tool_registry.register_tool(
|
||||
my_func,
|
||||
name="my_func",
|
||||
description="Test function"
|
||||
)
|
||||
|
||||
schema = mock_tool_registry.get_schema("my_func")
|
||||
|
||||
assert schema is not None
|
||||
assert schema.name == "my_func"
|
||||
assert schema.description == "Test function"
|
||||
assert len(schema.parameters) == 2
|
||||
|
||||
def test_get_all_schemas(self, mock_tool_registry):
|
||||
"""Test getting all schemas."""
|
||||
|
||||
def func1():
|
||||
pass
|
||||
|
||||
def func2():
|
||||
pass
|
||||
|
||||
mock_tool_registry.register_tool(func1, name="func1")
|
||||
mock_tool_registry.register_tool(func2, name="func2")
|
||||
|
||||
schemas = mock_tool_registry.get_all_schemas()
|
||||
assert len(schemas) == 2
|
||||
|
||||
def test_to_openai_schema(self, mock_tool_registry):
|
||||
"""Test converting to OpenAI function calling format."""
|
||||
|
||||
def search(query: str, limit: int = 10) -> str:
|
||||
return f"Results for {query}"
|
||||
|
||||
mock_tool_registry.register_tool(
|
||||
search,
|
||||
name="search",
|
||||
description="Search for items"
|
||||
)
|
||||
|
||||
openai_schemas = mock_tool_registry.to_openai_schema()
|
||||
|
||||
assert len(openai_schemas) == 1
|
||||
schema = openai_schemas[0]
|
||||
assert schema["name"] == "search"
|
||||
assert schema["description"] == "Search for items"
|
||||
assert "parameters" in schema
|
||||
assert "query" in schema["parameters"]["properties"]
|
||||
|
||||
def test_parameter_type_detection(self, mock_tool_registry):
|
||||
"""Test that parameter types are correctly detected."""
|
||||
|
||||
def typed_func(
|
||||
text: str,
|
||||
number: int,
|
||||
decimal: float,
|
||||
flag: bool,
|
||||
items: list
|
||||
):
|
||||
pass
|
||||
|
||||
mock_tool_registry.register_tool(typed_func, name="typed_func")
|
||||
schema = mock_tool_registry.get_schema("typed_func")
|
||||
|
||||
param_types = {p.name: p.type for p in schema.parameters}
|
||||
assert param_types["text"] == ParameterType.STRING
|
||||
assert param_types["number"] == ParameterType.INTEGER
|
||||
assert param_types["decimal"] == ParameterType.FLOAT
|
||||
assert param_types["flag"] == ParameterType.BOOLEAN
|
||||
assert param_types["items"] == ParameterType.ARRAY
|
||||
|
||||
|
||||
class TestToolDecorator:
|
||||
"""Tests for the @tool decorator."""
|
||||
|
||||
def test_decorator_basic(self):
|
||||
"""Test basic decorator usage."""
|
||||
registry = ToolRegistry()
|
||||
|
||||
# Create a local decorator using the registry
|
||||
def local_tool(**kwargs):
|
||||
def decorator(func):
|
||||
return registry.register_tool(func, **kwargs)
|
||||
return decorator
|
||||
|
||||
@local_tool(name="greet", description="Greet someone")
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
assert registry.get_tool("greet") is not None
|
||||
result = registry.execute_tool("greet", kwargs={"name": "World"})
|
||||
assert result == "Hello, World!"
|
||||
|
||||
def test_decorator_with_aliases(self):
|
||||
"""Test decorator with aliases."""
|
||||
registry = ToolRegistry()
|
||||
|
||||
def local_tool(**kwargs):
|
||||
def decorator(func):
|
||||
return registry.register_tool(func, **kwargs)
|
||||
return decorator
|
||||
|
||||
@local_tool(
|
||||
name="toggle_light",
|
||||
description="Toggle a light",
|
||||
aliases=["light", "switch"]
|
||||
)
|
||||
def toggle_light(room: str) -> str:
|
||||
return f"Toggled light in {room}"
|
||||
|
||||
assert registry.get_tool("toggle_light") is not None
|
||||
assert registry.get_tool("light") is not None
|
||||
assert registry.get_tool("switch") is not None
|
||||
|
||||
|
||||
class TestParameterSchema:
|
||||
"""Tests for ParameterSchema dataclass."""
|
||||
|
||||
def test_create_parameter(self):
|
||||
param = ParameterSchema(
|
||||
name="query",
|
||||
type=ParameterType.STRING,
|
||||
description="Search query",
|
||||
required=True
|
||||
)
|
||||
|
||||
assert param.name == "query"
|
||||
assert param.type == ParameterType.STRING
|
||||
assert param.required is True
|
||||
|
||||
def test_optional_parameter(self):
|
||||
param = ParameterSchema(
|
||||
name="limit",
|
||||
type=ParameterType.INTEGER,
|
||||
description="Max results",
|
||||
required=False,
|
||||
default=10
|
||||
)
|
||||
|
||||
assert param.required is False
|
||||
assert param.default == 10
|
||||
|
||||
|
||||
class TestFunctionSchema:
|
||||
"""Tests for FunctionSchema dataclass."""
|
||||
|
||||
def test_create_schema(self):
|
||||
params = [
|
||||
ParameterSchema(
|
||||
name="text",
|
||||
type=ParameterType.STRING,
|
||||
description="Input text",
|
||||
required=True
|
||||
)
|
||||
]
|
||||
|
||||
schema = FunctionSchema(
|
||||
name="process",
|
||||
description="Process text",
|
||||
parameters=params,
|
||||
returns="string"
|
||||
)
|
||||
|
||||
assert schema.name == "process"
|
||||
assert len(schema.parameters) == 1
|
||||
assert schema.returns == "string"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Tools package for the voice assistant.
|
||||
|
||||
This package contains all the tools that can be used by the voice assistant.
|
||||
All tools are automatically registered with the tool registry when this
|
||||
package is imported.
|
||||
"""
|
||||
|
||||
# Import all tools to ensure they are registered with the tool registry
|
||||
from . import spotify
|
||||
from . import lighting
|
||||
from . import weather_time
|
||||
from . import google_calendar
|
||||
from . import airtouch
|
||||
from . import thinq
|
||||
from . import webos
|
||||
from . import search_web
|
||||
from . import pioneer_avr
|
||||
from . import home_assistant
|
||||
|
||||
# Import the tool registry
|
||||
from .tool_registry import tool_registry, tool
|
||||
|
||||
__all__ = [
|
||||
'tool_registry',
|
||||
'tool',
|
||||
'spotify',
|
||||
'lighting',
|
||||
'weather_time',
|
||||
'google_calendar',
|
||||
'airtouch',
|
||||
'thinq',
|
||||
'webos',
|
||||
'search_web',
|
||||
'pioneer_avr',
|
||||
'home_assistant'
|
||||
]
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Airtouch HVAC control tool
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
import asyncio
|
||||
import pyairtouch
|
||||
from .tool_registry import tool, tool_registry
|
||||
|
||||
# Load light names and groups from config file
|
||||
zone_ids = config['airtouch']
|
||||
|
||||
|
||||
async def get_ac():
|
||||
"""Get the air conditioner device."""
|
||||
devices = await pyairtouch.discover()
|
||||
if len(devices) > 0:
|
||||
# Connect to the first discovered device
|
||||
airtouch = devices[0]
|
||||
success = await airtouch.init()
|
||||
if success:
|
||||
return airtouch
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
async def _get_temperature(location):
|
||||
"""Get temperature for a specific location."""
|
||||
ac = await get_ac()
|
||||
if ac is not None:
|
||||
ac = ac.air_conditioners[0]
|
||||
if location.lower() in zone_ids.keys():
|
||||
zone_index = zone_ids[location.lower()]
|
||||
return f"The {location} temperature is {ac.zones[zone_index].current_temperature} degrees Celcius"
|
||||
else:
|
||||
return f"{location} not found"
|
||||
else:
|
||||
return f"No AC device found"
|
||||
|
||||
|
||||
def get_temperature(location: str):
|
||||
"""
|
||||
Get temperature for a specific location.
|
||||
|
||||
Args:
|
||||
location: The location/zone name
|
||||
|
||||
Returns:
|
||||
Current temperature information
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop, it's safe to use asyncio.run
|
||||
return asyncio.run(_get_temperature(location))
|
||||
else:
|
||||
# There is a running loop (common in Jupyter/servers).
|
||||
# Safe solution: Run as a task and get result.
|
||||
return loop.create_task(_get_temperature(location))
|
||||
|
||||
|
||||
async def _set_temperature(new_temp, location):
|
||||
"""Set temperature for a specific location."""
|
||||
if new_temp > 25:
|
||||
new_temp = 25 # Max temperature limits
|
||||
if new_temp < 17:
|
||||
new_temp = 17 # Min temperature limits
|
||||
|
||||
ac = await get_ac()
|
||||
if ac is not None:
|
||||
ac = ac.air_conditioners[0]
|
||||
if location.lower() in zone_ids.keys():
|
||||
zone_index = zone_ids[location.lower()]
|
||||
zone = ac.zones[zone_index]
|
||||
await ac.set_power(True)
|
||||
await zone.set_target_temperature(new_temp)
|
||||
return f"The {location} target temperature is now {zone.target_temperature} degrees Celcius"
|
||||
else:
|
||||
return f"{location} not found"
|
||||
else:
|
||||
return f"No AC device found"
|
||||
|
||||
|
||||
@tool(
|
||||
name="set_temperature",
|
||||
description="Set the target temperature for a specific location",
|
||||
aliases=["temperature", "set_ac_temperature"]
|
||||
)
|
||||
def set_temperature(new_temp: int, location: str):
|
||||
"""
|
||||
Set the target temperature for a specific location.
|
||||
|
||||
Args:
|
||||
new_temp: Target temperature in degrees Celsius
|
||||
location: The location/zone name
|
||||
|
||||
Returns:
|
||||
Status message about the temperature setting
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_set_temperature(new_temp, location))
|
||||
else:
|
||||
return loop.create_task(_set_temperature(new_temp, location))
|
||||
|
||||
|
||||
async def _turn_on_ac():
|
||||
"""Turn on the air conditioner."""
|
||||
ac = await get_ac()
|
||||
if ac is not None:
|
||||
ac = ac.air_conditioners[0]
|
||||
success = await ac.set_power(True)
|
||||
if success:
|
||||
return "Air conditioner turned on"
|
||||
else:
|
||||
return "Unable to turn on air conditioner"
|
||||
else:
|
||||
return f"No air conditioner found"
|
||||
|
||||
|
||||
@tool(
|
||||
name="turn_on_ac",
|
||||
description="Turn on the air conditioner",
|
||||
aliases=["ac_on", "start_ac", "turn_on_air_conditioner"]
|
||||
)
|
||||
def turn_on_ac():
|
||||
"""Turn on the air conditioner."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_turn_on_ac())
|
||||
else:
|
||||
return loop.create_task(_turn_on_ac())
|
||||
|
||||
|
||||
async def _turn_off_ac():
|
||||
"""Turn off the air conditioner."""
|
||||
ac = await get_ac()
|
||||
if ac is not None:
|
||||
ac = ac.air_conditioners[0]
|
||||
success = await ac.set_power(False)
|
||||
if success:
|
||||
return "Air conditioner turned off"
|
||||
else:
|
||||
return "Unable to turn off air conditioner"
|
||||
else:
|
||||
return f"No air conditioner found"
|
||||
|
||||
|
||||
@tool(
|
||||
name="turn_off_ac",
|
||||
description="Turn off the air conditioner",
|
||||
aliases=["ac_off", "stop_ac", "turn_off_air_conditioner"]
|
||||
)
|
||||
def turn_off_ac():
|
||||
"""Turn off the air conditioner."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_turn_off_ac())
|
||||
else:
|
||||
return loop.create_task(_turn_off_ac())
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_temperature",
|
||||
description="Get the current temperature for a specific location",
|
||||
aliases=["temperature", "current_temperature"]
|
||||
)
|
||||
def get_temperature_tool(location: str) -> str:
|
||||
"""
|
||||
Get the current temperature for a specific location.
|
||||
|
||||
Args:
|
||||
location: The location/zone name
|
||||
|
||||
Returns:
|
||||
Current temperature information
|
||||
"""
|
||||
return get_temperature(location)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Airtouch HVAC Controller")
|
||||
|
||||
# Print available tools
|
||||
print("\nAvailable tools:")
|
||||
for schema in tool_registry.get_all_schemas():
|
||||
print(f" {schema.name}: {schema.description}")
|
||||
for param in schema.parameters:
|
||||
print(f" - {param.name} ({param.type.value}): {param.description}")
|
||||
|
||||
# Test function calling
|
||||
print("\nTesting function calls:")
|
||||
result = tool_registry.execute_tool("get_temperature", kwargs={"location": "office"})
|
||||
print(f"Temperature: {result}")
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Google Calendar integration tool using the centralized tool registry.
|
||||
|
||||
This module provides calendar functionality with proper
|
||||
schema definitions and function calling support.
|
||||
"""
|
||||
import os
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv() # Load .env vars
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.discovery import build
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
from .tool_registry import tool, tool_registry
|
||||
|
||||
# If modifying SCOPES, delete the token.json file
|
||||
SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']
|
||||
|
||||
TOKEN = config['google']['token_file']
|
||||
CREDS = config['google']['cred_file']
|
||||
|
||||
|
||||
def authenticate_google_calendar():
|
||||
"""Authenticate with Google Calendar API, refreshing or acquiring a new token if needed."""
|
||||
creds = None
|
||||
if os.path.exists(TOKEN):
|
||||
creds = Credentials.from_authorized_user_file(TOKEN, SCOPES)
|
||||
# If credentials are invalid or do not exist, refresh or run OAuth flow
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except Exception:
|
||||
# Refresh failed, must re-authenticate
|
||||
flow = InstalledAppFlow.from_client_secrets_file(CREDS, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
else:
|
||||
flow = InstalledAppFlow.from_client_secrets_file(CREDS, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
with open(TOKEN, 'w') as token:
|
||||
token.write(creds.to_json())
|
||||
return build('calendar', 'v3', credentials=creds)
|
||||
|
||||
|
||||
def get_events(service, time_min, time_max):
|
||||
"""Get events from Google Calendar."""
|
||||
events_result = service.events().list(
|
||||
calendarId='primary', timeMin=time_min.isoformat() + 'Z',
|
||||
timeMax=time_max.isoformat() + 'Z', singleEvents=True,
|
||||
orderBy='startTime').execute()
|
||||
return events_result.get('items', [])
|
||||
|
||||
|
||||
def summarize_events(events):
|
||||
"""Summarize events in a readable format."""
|
||||
if not events:
|
||||
return "No events found."
|
||||
summary_lines = []
|
||||
for event in events:
|
||||
start = event['start'].get('dateTime', event['start'].get('date'))
|
||||
start_time = datetime.datetime.fromisoformat(start).strftime("%a %I:%M %p") if 'T' in start else start
|
||||
summary_lines.append(f"- {start_time}: {event.get('summary', 'No title')}")
|
||||
return "\n".join(summary_lines)
|
||||
|
||||
|
||||
def tts_friendly_summary(events):
|
||||
"""Create a TTS-friendly summary of events."""
|
||||
if not events:
|
||||
return "You have no events scheduled."
|
||||
|
||||
spoken = []
|
||||
for event in events:
|
||||
raw_start = event['start'].get('dateTime', event['start'].get('date'))
|
||||
if 'T' in raw_start:
|
||||
start_dt = datetime.datetime.fromisoformat(raw_start)
|
||||
day = start_dt.strftime("%A") # e.g., Monday
|
||||
time = start_dt.strftime("%-I %M %p").lower().replace("am", "a m").replace("pm", "p m")
|
||||
time = re.sub(r'\b00\b', "o'clock", time) # 10 00 → 10 o'clock
|
||||
spoken.append(f"At {time} on {day}, {event.get('summary', 'an event')}.")
|
||||
else:
|
||||
day = datetime.datetime.fromisoformat(raw_start).strftime("%A")
|
||||
spoken.append(f"All day on {day}, {event.get('summary', 'an event')}.")
|
||||
|
||||
return " ".join(spoken)
|
||||
|
||||
|
||||
@tool(
|
||||
name="whats_on",
|
||||
description="Get calendar events for a specific time period",
|
||||
aliases=["calendar", "events", "schedule"]
|
||||
)
|
||||
def whats_on(day: str = "today") -> str:
|
||||
"""
|
||||
Get calendar events for a specific time period.
|
||||
|
||||
Args:
|
||||
day: Time period - 'today', 'tomorrow', or 'week'
|
||||
|
||||
Returns:
|
||||
TTS-friendly summary of events
|
||||
"""
|
||||
try:
|
||||
service = authenticate_google_calendar()
|
||||
if day == 'today':
|
||||
start = datetime.datetime.combine(datetime.date.today(), datetime.time.min)
|
||||
end = datetime.datetime.combine(datetime.date.today(), datetime.time.max)
|
||||
elif day == 'tomorrow':
|
||||
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
|
||||
start = datetime.datetime.combine(tomorrow, datetime.time.min)
|
||||
end = datetime.datetime.combine(tomorrow, datetime.time.max)
|
||||
elif day == 'week':
|
||||
start = datetime.datetime.combine(datetime.date.today(), datetime.time.min)
|
||||
end = start + datetime.timedelta(days=7)
|
||||
else:
|
||||
raise ValueError("Use 'today', 'tomorrow', or 'week'.")
|
||||
|
||||
events = get_events(service, start, end)
|
||||
return tts_friendly_summary(events)
|
||||
except Exception as e:
|
||||
return "Unable to get calendar events"
|
||||
|
||||
|
||||
@tool(
|
||||
name="whats_on_today",
|
||||
description="Get calendar events for today"
|
||||
)
|
||||
def whats_on_today() -> str:
|
||||
"""Get calendar events for today."""
|
||||
return whats_on("today")
|
||||
|
||||
|
||||
@tool(
|
||||
name="whats_on_tomorrow",
|
||||
description="Get calendar events for tomorrow"
|
||||
)
|
||||
def whats_on_tomorrow() -> str:
|
||||
"""Get calendar events for tomorrow."""
|
||||
return whats_on("tomorrow")
|
||||
|
||||
|
||||
@tool(
|
||||
name="whats_on_this_week",
|
||||
description="Get calendar events for this week"
|
||||
)
|
||||
def whats_on_this_week() -> str:
|
||||
"""Get calendar events for this week."""
|
||||
return whats_on("week")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Google Calendar Integration Tool")
|
||||
|
||||
# Print available tools
|
||||
print("\nAvailable tools:")
|
||||
for schema in tool_registry.get_all_schemas():
|
||||
print(f" {schema.name}: {schema.description}")
|
||||
for param in schema.parameters:
|
||||
print(f" - {param.name} ({param.type.value}): {param.description}")
|
||||
|
||||
# Test function calling
|
||||
print("\nTesting function calls:")
|
||||
for period in ['today', 'tomorrow', 'week']:
|
||||
result = tool_registry.execute_tool("whats_on", kwargs={"day": period})
|
||||
print(f"Events for {period}: {result}")
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Home Assistant integration for Fulloch voice assistant.
|
||||
|
||||
Connects to a Home Assistant instance via REST API for home automation control.
|
||||
Requires a long-lived access token configured in data/config.yml.
|
||||
|
||||
This integration must be explicitly enabled in config.yml to prevent conflicts
|
||||
with other home automation tools (e.g., Philips Hue lighting).
|
||||
"""
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from typing import Optional
|
||||
|
||||
from .tool_registry import tool
|
||||
|
||||
# Load configuration
|
||||
try:
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
HA_CONFIG = config.get('home_assistant', {})
|
||||
except FileNotFoundError:
|
||||
HA_CONFIG = {}
|
||||
|
||||
# Check if Home Assistant integration is enabled
|
||||
HA_ENABLED = HA_CONFIG.get('enabled', False)
|
||||
|
||||
# Home Assistant connection settings
|
||||
HA_URL = HA_CONFIG.get('url', 'http://localhost:8123')
|
||||
HA_TOKEN = HA_CONFIG.get('token', '')
|
||||
TIMEOUT = HA_CONFIG.get('timeout', 10)
|
||||
|
||||
|
||||
def _noop_decorator(name=None, description=None, aliases=None):
|
||||
"""No-op decorator when Home Assistant integration is disabled."""
|
||||
def decorator(func):
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
# Use real tool decorator only when enabled
|
||||
ha_tool = tool if HA_ENABLED else _noop_decorator
|
||||
|
||||
|
||||
def _get_headers() -> dict:
|
||||
"""Return authorization headers for Home Assistant API."""
|
||||
return {
|
||||
"Authorization": f"Bearer {HA_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _call_service(domain: str, service: str, entity_id: str, data: Optional[dict] = None) -> str:
|
||||
"""Call a Home Assistant service."""
|
||||
if not HA_TOKEN:
|
||||
return "Error: Home Assistant token not configured. Add 'token' to home_assistant config."
|
||||
|
||||
url = f"{HA_URL}/api/services/{domain}/{service}"
|
||||
payload = {"entity_id": entity_id}
|
||||
if data:
|
||||
payload.update(data)
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=_get_headers(), json=payload, timeout=TIMEOUT)
|
||||
response.raise_for_status()
|
||||
return f"Successfully called {domain}.{service} on {entity_id}"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return f"Error: Could not connect to Home Assistant at {HA_URL}"
|
||||
except requests.exceptions.Timeout:
|
||||
return f"Error: Home Assistant request timed out"
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return f"Error: Home Assistant returned {e.response.status_code}: {e.response.text}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
def _get_state(entity_id: str) -> Optional[dict]:
|
||||
"""Get the state of an entity from Home Assistant."""
|
||||
if not HA_TOKEN:
|
||||
return None
|
||||
|
||||
url = f"{HA_URL}/api/states/{entity_id}"
|
||||
try:
|
||||
response = requests.get(url, headers=_get_headers(), timeout=TIMEOUT)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_entity(name: str, domain: str = None) -> str:
|
||||
"""Resolve a friendly name or alias to an entity_id.
|
||||
|
||||
Checks the entity_aliases config mapping first, then assumes
|
||||
the name is already a valid entity_id.
|
||||
"""
|
||||
aliases = HA_CONFIG.get('entity_aliases', {})
|
||||
|
||||
# Check if it's a configured alias
|
||||
if name.lower() in aliases:
|
||||
return aliases[name.lower()]
|
||||
|
||||
# If it looks like an entity_id already, return as-is
|
||||
if '.' in name:
|
||||
return name
|
||||
|
||||
# Try to construct entity_id from name and domain
|
||||
if domain:
|
||||
# Convert "living room lights" -> "light.living_room_lights"
|
||||
entity_name = name.lower().replace(' ', '_')
|
||||
return f"{domain}.{entity_name}"
|
||||
|
||||
return name
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="turn_on",
|
||||
description="Turn on a device, light, switch, or other Home Assistant entity",
|
||||
aliases=["ha_turn_on", "switch_on", "turn_on_device"]
|
||||
)
|
||||
def turn_on(entity: str, brightness: Optional[int] = None) -> str:
|
||||
"""Turn on a Home Assistant entity.
|
||||
|
||||
Args:
|
||||
entity: Entity name or ID (e.g., 'living room lights', 'light.living_room')
|
||||
brightness: Optional brightness percentage (0-100) for lights
|
||||
"""
|
||||
entity_id = _resolve_entity(entity, domain="light")
|
||||
|
||||
# Determine the domain from entity_id
|
||||
domain = entity_id.split('.')[0] if '.' in entity_id else 'homeassistant'
|
||||
|
||||
data = {}
|
||||
if brightness is not None and domain == 'light':
|
||||
# Convert percentage to 0-255 range
|
||||
data['brightness'] = int((brightness / 100) * 255)
|
||||
|
||||
return _call_service(domain, "turn_on", entity_id, data if data else None)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="turn_off",
|
||||
description="Turn off a device, light, switch, or other Home Assistant entity",
|
||||
aliases=["ha_turn_off", "switch_off", "turn_off_device"]
|
||||
)
|
||||
def turn_off(entity: str) -> str:
|
||||
"""Turn off a Home Assistant entity.
|
||||
|
||||
Args:
|
||||
entity: Entity name or ID (e.g., 'living room lights', 'light.living_room')
|
||||
"""
|
||||
entity_id = _resolve_entity(entity)
|
||||
domain = entity_id.split('.')[0] if '.' in entity_id else 'homeassistant'
|
||||
|
||||
return _call_service(domain, "turn_off", entity_id)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="toggle",
|
||||
description="Toggle a Home Assistant entity on or off",
|
||||
aliases=["ha_toggle", "toggle_device"]
|
||||
)
|
||||
def toggle(entity: str) -> str:
|
||||
"""Toggle a Home Assistant entity.
|
||||
|
||||
Args:
|
||||
entity: Entity name or ID (e.g., 'living room lights', 'light.living_room')
|
||||
"""
|
||||
entity_id = _resolve_entity(entity)
|
||||
domain = entity_id.split('.')[0] if '.' in entity_id else 'homeassistant'
|
||||
|
||||
return _call_service(domain, "toggle", entity_id)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_set_brightness",
|
||||
description="Set the brightness of a light in Home Assistant",
|
||||
aliases=["ha_brightness", "ha_dim_light"]
|
||||
)
|
||||
def set_ha_brightness(entity: str, brightness: int) -> str:
|
||||
"""Set the brightness of a light.
|
||||
|
||||
Args:
|
||||
entity: Light entity name or ID
|
||||
brightness: Brightness percentage (0-100)
|
||||
"""
|
||||
entity_id = _resolve_entity(entity, domain="light")
|
||||
|
||||
# Clamp brightness to valid range
|
||||
brightness = max(0, min(100, brightness))
|
||||
brightness_255 = int((brightness / 100) * 255)
|
||||
|
||||
return _call_service("light", "turn_on", entity_id, {"brightness": brightness_255})
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_set_color",
|
||||
description="Set the color of a light in Home Assistant using color name or RGB",
|
||||
aliases=["ha_color", "change_light_color"]
|
||||
)
|
||||
def set_color(entity: str, color: str) -> str:
|
||||
"""Set the color of a light.
|
||||
|
||||
Args:
|
||||
entity: Light entity name or ID
|
||||
color: Color name (red, green, blue, etc.) or RGB as 'r,g,b'
|
||||
"""
|
||||
entity_id = _resolve_entity(entity, domain="light")
|
||||
|
||||
# Common color name mappings
|
||||
color_map = {
|
||||
"red": [255, 0, 0],
|
||||
"green": [0, 255, 0],
|
||||
"blue": [0, 0, 255],
|
||||
"yellow": [255, 255, 0],
|
||||
"orange": [255, 165, 0],
|
||||
"purple": [128, 0, 128],
|
||||
"pink": [255, 192, 203],
|
||||
"white": [255, 255, 255],
|
||||
"warm white": [255, 244, 229],
|
||||
"cool white": [255, 255, 255],
|
||||
"cyan": [0, 255, 255],
|
||||
"magenta": [255, 0, 255],
|
||||
}
|
||||
|
||||
color_lower = color.lower().strip()
|
||||
|
||||
if color_lower in color_map:
|
||||
rgb = color_map[color_lower]
|
||||
elif ',' in color:
|
||||
# Parse RGB string like "255,128,0"
|
||||
try:
|
||||
rgb = [int(c.strip()) for c in color.split(',')]
|
||||
if len(rgb) != 3:
|
||||
return "Error: RGB color must have 3 values (e.g., '255,128,0')"
|
||||
except ValueError:
|
||||
return f"Error: Invalid RGB color format '{color}'"
|
||||
else:
|
||||
return f"Error: Unknown color '{color}'. Use a color name or RGB format."
|
||||
|
||||
return _call_service("light", "turn_on", entity_id, {"rgb_color": rgb})
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="get_entity_state",
|
||||
description="Get the current state of a Home Assistant entity",
|
||||
aliases=["ha_state", "check_state", "is_on"]
|
||||
)
|
||||
def get_entity_state(entity: str) -> str:
|
||||
"""Get the current state of a Home Assistant entity.
|
||||
|
||||
Args:
|
||||
entity: Entity name or ID
|
||||
"""
|
||||
entity_id = _resolve_entity(entity)
|
||||
state = _get_state(entity_id)
|
||||
|
||||
if state is None:
|
||||
return f"Error: Could not get state for {entity_id}"
|
||||
|
||||
entity_state = state.get('state', 'unknown')
|
||||
friendly_name = state.get('attributes', {}).get('friendly_name', entity_id)
|
||||
|
||||
# Include relevant attributes
|
||||
attrs = state.get('attributes', {})
|
||||
details = [f"{friendly_name} is {entity_state}"]
|
||||
|
||||
if 'brightness' in attrs:
|
||||
brightness_pct = int((attrs['brightness'] / 255) * 100)
|
||||
details.append(f"brightness: {brightness_pct}%")
|
||||
if 'temperature' in attrs:
|
||||
details.append(f"temperature: {attrs['temperature']}°")
|
||||
if 'current_temperature' in attrs:
|
||||
details.append(f"current temperature: {attrs['current_temperature']}°")
|
||||
|
||||
return ", ".join(details)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_service",
|
||||
description="Call any Home Assistant service with custom data",
|
||||
aliases=["call_service", "ha_call"]
|
||||
)
|
||||
def call_ha_service(domain: str, service: str, entity: str, data: Optional[str] = None) -> str:
|
||||
"""Call any Home Assistant service.
|
||||
|
||||
Args:
|
||||
domain: Service domain (e.g., 'light', 'switch', 'climate')
|
||||
service: Service name (e.g., 'turn_on', 'set_temperature')
|
||||
entity: Entity ID to target
|
||||
data: Optional JSON string with additional service data
|
||||
"""
|
||||
import json
|
||||
|
||||
entity_id = _resolve_entity(entity)
|
||||
|
||||
extra_data = None
|
||||
if data:
|
||||
try:
|
||||
extra_data = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return f"Error: Invalid JSON data: {data}"
|
||||
|
||||
return _call_service(domain, service, entity_id, extra_data)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_set_climate",
|
||||
description="Set the temperature of a climate/thermostat entity in Home Assistant",
|
||||
aliases=["ha_climate", "ha_thermostat"]
|
||||
)
|
||||
def set_climate(entity: str, temperature: float, hvac_mode: Optional[str] = None) -> str:
|
||||
"""Set climate/thermostat temperature.
|
||||
|
||||
Args:
|
||||
entity: Climate entity name or ID
|
||||
temperature: Target temperature
|
||||
hvac_mode: Optional HVAC mode (heat, cool, auto, off)
|
||||
"""
|
||||
entity_id = _resolve_entity(entity, domain="climate")
|
||||
|
||||
data = {"temperature": temperature}
|
||||
if hvac_mode:
|
||||
data["hvac_mode"] = hvac_mode.lower()
|
||||
|
||||
return _call_service("climate", "set_temperature", entity_id, data)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_lock",
|
||||
description="Lock a lock entity in Home Assistant",
|
||||
aliases=["lock_door"]
|
||||
)
|
||||
def lock(entity: str) -> str:
|
||||
"""Lock a lock entity.
|
||||
|
||||
Args:
|
||||
entity: Lock entity name or ID
|
||||
"""
|
||||
entity_id = _resolve_entity(entity, domain="lock")
|
||||
return _call_service("lock", "lock", entity_id)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_unlock",
|
||||
description="Unlock a lock entity in Home Assistant",
|
||||
aliases=["unlock_door"]
|
||||
)
|
||||
def unlock(entity: str) -> str:
|
||||
"""Unlock a lock entity.
|
||||
|
||||
Args:
|
||||
entity: Lock entity name or ID
|
||||
"""
|
||||
entity_id = _resolve_entity(entity, domain="lock")
|
||||
return _call_service("lock", "unlock", entity_id)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_open_cover",
|
||||
description="Open a cover/blind/garage in Home Assistant",
|
||||
aliases=["ha_open", "open_blind", "open_garage"]
|
||||
)
|
||||
def open_cover(entity: str) -> str:
|
||||
"""Open a cover entity (blinds, garage door, etc.).
|
||||
|
||||
Args:
|
||||
entity: Cover entity name or ID
|
||||
"""
|
||||
entity_id = _resolve_entity(entity, domain="cover")
|
||||
return _call_service("cover", "open_cover", entity_id)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_close_cover",
|
||||
description="Close a cover/blind/garage in Home Assistant",
|
||||
aliases=["ha_close", "close_blind", "close_garage"]
|
||||
)
|
||||
def close_cover(entity: str) -> str:
|
||||
"""Close a cover entity (blinds, garage door, etc.).
|
||||
|
||||
Args:
|
||||
entity: Cover entity name or ID
|
||||
"""
|
||||
entity_id = _resolve_entity(entity, domain="cover")
|
||||
return _call_service("cover", "close_cover", entity_id)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_run_script",
|
||||
description="Run a Home Assistant script or automation",
|
||||
aliases=["ha_script", "run_automation"]
|
||||
)
|
||||
def run_script(script_name: str) -> str:
|
||||
"""Run a Home Assistant script.
|
||||
|
||||
Args:
|
||||
script_name: Script entity ID or name (e.g., 'script.bedtime' or 'bedtime')
|
||||
"""
|
||||
entity_id = _resolve_entity(script_name, domain="script")
|
||||
return _call_service("script", "turn_on", entity_id)
|
||||
|
||||
|
||||
@ha_tool(
|
||||
name="ha_activate_scene",
|
||||
description="Activate a Home Assistant scene",
|
||||
aliases=["ha_scene", "set_scene"]
|
||||
)
|
||||
def activate_scene(scene_name: str) -> str:
|
||||
"""Activate a Home Assistant scene.
|
||||
|
||||
Args:
|
||||
scene_name: Scene entity ID or name (e.g., 'scene.movie_time' or 'movie time')
|
||||
"""
|
||||
entity_id = _resolve_entity(scene_name, domain="scene")
|
||||
return _call_service("scene", "turn_on", entity_id)
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Philips Hue lighting control tool
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
from phue import Bridge
|
||||
|
||||
from .tool_registry import tool, tool_registry
|
||||
|
||||
b = Bridge(config['philips']['hue_hub_ip'])
|
||||
|
||||
|
||||
@tool(
|
||||
name="turn_on_lights",
|
||||
description="Turn on lights in a specific location",
|
||||
aliases=["lights_on", "switch_on_lights", "turn_on"]
|
||||
)
|
||||
def turn_on_lights(location: str = "Downlights Office") -> str:
|
||||
"""
|
||||
Turn on lights in the specified location.
|
||||
|
||||
Args:
|
||||
location: The location/room name for the lights
|
||||
|
||||
Returns:
|
||||
Status message about the action
|
||||
"""
|
||||
location = location.title() # First Letters Capitalized
|
||||
if location in b.get_light_objects('name').keys():
|
||||
b.set_light(location, 'on', True)
|
||||
return f"{location} lights on"
|
||||
|
||||
groups = b.get_group()
|
||||
group_names = []
|
||||
for i in groups:
|
||||
group_names.append(groups[i]['name'])
|
||||
if location in group_names:
|
||||
b.set_group(location, 'on', True)
|
||||
return f"{location} on"
|
||||
else:
|
||||
return f"No lights or rooms with name {location}"
|
||||
|
||||
|
||||
@tool(
|
||||
name="turn_off_lights",
|
||||
description="Turn off lights in a specific location",
|
||||
aliases=["lights_off", "switch_off_lights", "turn_off"]
|
||||
)
|
||||
def turn_off_lights(location: str = "Downlights Office") -> str:
|
||||
"""
|
||||
Turn off lights in the specified location.
|
||||
|
||||
Args:
|
||||
location: The location/room name for the lights
|
||||
|
||||
Returns:
|
||||
Status message about the action
|
||||
"""
|
||||
try:
|
||||
location = location.title() # First Letters Capitalized
|
||||
if location in b.get_light_objects('name').keys():
|
||||
b.set_light(location, 'on', False)
|
||||
return f"{location} lights off"
|
||||
|
||||
groups = b.get_group()
|
||||
group_names = []
|
||||
for i in groups:
|
||||
group_names.append(groups[i]['name'])
|
||||
if location in group_names:
|
||||
b.set_group(location, 'on', False)
|
||||
return f"{location} off"
|
||||
else:
|
||||
return f"No lights or rooms with name {location}"
|
||||
except Exception as e:
|
||||
return f"Unable to connect to lights for {location}"
|
||||
|
||||
|
||||
@tool(
|
||||
name="set_brightness",
|
||||
description="Set brightness level for lights in a specific location",
|
||||
aliases=["brightness", "dim_lights", "brighten_lights"]
|
||||
)
|
||||
def set_brightness(percent: int = 100, location: str = "Downlights Office") -> str:
|
||||
"""
|
||||
Set brightness level for lights in the specified location.
|
||||
|
||||
Args:
|
||||
percent: Brightness percentage (0-100)
|
||||
location: The location/room name for the lights
|
||||
|
||||
Returns:
|
||||
Status message about the action
|
||||
"""
|
||||
try:
|
||||
location = location.title() # First Letters Capitalized
|
||||
if location in b.get_light_objects('name').keys():
|
||||
b.set_light(location, 'on', True)
|
||||
level = int((int(percent) / 100) * 254)
|
||||
b.set_light(location, 'bri', level)
|
||||
return f"{location} lights set to {percent} percent."
|
||||
|
||||
groups = b.get_group()
|
||||
group_names = []
|
||||
for i in groups:
|
||||
group_names.append(groups[i]['name'])
|
||||
if location in group_names:
|
||||
b.set_group(location, 'on', True)
|
||||
level = int((int(percent) / 100) * 254)
|
||||
b.set_group(location, 'bri', level)
|
||||
return f"{location} set to {percent} percent."
|
||||
else:
|
||||
return f"No lights or rooms with name {location}"
|
||||
except Exception as e:
|
||||
return f"Unable to connect to lights for {location}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Philips Hue Lighting Controller")
|
||||
|
||||
# Print available tools
|
||||
print("\nAvailable tools:")
|
||||
for schema in tool_registry.get_all_schemas():
|
||||
print(f" {schema.name}: {schema.description}")
|
||||
for param in schema.parameters:
|
||||
print(f" - {param.name} ({param.type.value}): {param.description}")
|
||||
|
||||
# Test function calling
|
||||
print("\nTesting function calling:")
|
||||
result = tool_registry.execute_tool("turn_on_lights", kwargs={"location": "kitchen"})
|
||||
print(f"Result: {result}")
|
||||
|
||||
|
||||
|
||||
Executable
+454
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
Tool for connecting to Pioneer eISCP protocol
|
||||
Communication standard used to control Pioneer and Onkyo audio/video receivers over a network
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import Optional, Dict
|
||||
|
||||
from .tool_registry import tool, tool_registry
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HOST = config['pioneer']['avr_host']
|
||||
PORT = config['pioneer']['avr_port']
|
||||
|
||||
|
||||
# Command mappings for Pioneer eISCP protocol
|
||||
COMMANDS = {
|
||||
'power_on': 'PO',
|
||||
'power_off': 'PF',
|
||||
'volume_set': '{:03d}VL',
|
||||
'mute_on': 'MO',
|
||||
'mute_off': 'MF',
|
||||
'input_select': '{}FN',
|
||||
}
|
||||
|
||||
QUERIES = {
|
||||
'power': '?P',
|
||||
'volume': '?V',
|
||||
'mute': '?M',
|
||||
'input': '?F',
|
||||
}
|
||||
|
||||
RESPONSE_PATTERNS = {
|
||||
'power': r'PWR(\d)',
|
||||
'volume': r'VOL(\d{3})',
|
||||
'mute': r'MUT(\d)',
|
||||
'input': r'FN(\d{2})',
|
||||
}
|
||||
|
||||
DEFAULT_INPUTS = {
|
||||
'00': 'PHONO', '01': 'CD', '02': 'TUNER', '03': 'CD-R/TAPE',
|
||||
'04': 'MUSIC', '05': 'TV', '10': 'VIDEO 1', '14': 'VIDEO 2',
|
||||
'19': 'HDMI 1', '20': 'HDMI 2', '21': 'HDMI 3', '22': 'HDMI 4',
|
||||
'23': 'HDMI 5', '24': 'HDMI 6', '25': 'BD',
|
||||
}
|
||||
|
||||
class AVR:
|
||||
"""Simplified Pioneer AVR IP control using eISCP protocol.
|
||||
|
||||
This class provides async methods to control and query Pioneer AV receivers
|
||||
over the network using the eISCP protocol
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int = 60128,
|
||||
input_list: Optional[Dict[str, str]] = None):
|
||||
"""Initialize AVR controller.
|
||||
|
||||
Args:
|
||||
host: IP address of the AVR
|
||||
port: Port number (default 60128 for eISCP)[1]
|
||||
input_list: Custom input mapping (optional)
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._input_list = input_list or DEFAULT_INPUTS
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._state = {'power': False, 'volume': 0, 'mute': False, 'input': '00'}
|
||||
|
||||
async def connect(self):
|
||||
"""Establish TCP connection to AVR."""
|
||||
logger.info(f"Connecting to AVR at {self.host}:{self.port}")
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self.host, self.port), timeout=5.0)
|
||||
logger.info("Connection established")
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Connection timeout to {self.host}:{self.port}")
|
||||
raise
|
||||
|
||||
async def disconnect(self):
|
||||
"""Close TCP connection to AVR."""
|
||||
if self._writer:
|
||||
logger.info("Closing connection")
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
self._writer = None
|
||||
self._reader = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
"""Async context manager exit."""
|
||||
await self.disconnect()
|
||||
|
||||
async def _send_raw(self, data: str):
|
||||
"""Send raw command string to AVR."""
|
||||
if not self._writer:
|
||||
raise RuntimeError("Not connected to AVR")
|
||||
|
||||
logger.debug(f"Sending: {data}")
|
||||
self._writer.write((data + "\r").encode('ascii'))
|
||||
await self._writer.drain()
|
||||
|
||||
async def _read_response(self, timeout: float = 2.0) -> str:
|
||||
"""Read response from AVR."""
|
||||
if not self._reader:
|
||||
raise RuntimeError("Not connected to AVR")
|
||||
|
||||
try:
|
||||
data = await asyncio.wait_for(
|
||||
self._reader.readuntil(b'\r\n'), timeout)
|
||||
response = data.decode('ascii').strip()
|
||||
logger.debug(f"Received: {response}")
|
||||
return response
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout waiting for response")
|
||||
return ""
|
||||
|
||||
async def query(self, prop: str) -> Optional[str]:
|
||||
"""Query a property from AVR."""
|
||||
if prop not in QUERIES:
|
||||
logger.warning(f"Invalid query property: {prop}")
|
||||
return None
|
||||
|
||||
await self._send_raw(QUERIES[prop])
|
||||
response = await self._read_response()
|
||||
|
||||
pattern = RESPONSE_PATTERNS.get(prop)
|
||||
if pattern:
|
||||
match = re.search(pattern, response)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
async def update_state(self):
|
||||
"""Update all state properties from AVR."""
|
||||
logger.info("Updating AVR state")
|
||||
for prop in QUERIES:
|
||||
value = await self.query(prop)
|
||||
if value is not None:
|
||||
self._parse_state(prop, value)
|
||||
else:
|
||||
break # Break loop if None returned on any query
|
||||
|
||||
def _parse_state(self, prop: str, value: str):
|
||||
"""Parse and store state value."""
|
||||
if prop == 'power':
|
||||
self._state['power'] = value == '0' # PWR0 = on[1]
|
||||
elif prop == 'volume':
|
||||
self._state['volume'] = int(value)
|
||||
elif prop == 'mute':
|
||||
self._state['mute'] = value == '1'
|
||||
elif prop == 'input':
|
||||
self._state['input'] = value
|
||||
|
||||
@property
|
||||
def power(self) -> bool:
|
||||
"""Power state (True = on, False = off)."""
|
||||
return self._state['power']
|
||||
|
||||
@property
|
||||
def volume(self) -> int:
|
||||
"""Volume level in raw value"""
|
||||
return self._state['volume']
|
||||
|
||||
@property
|
||||
def mute(self) -> bool:
|
||||
"""Mute state (True = muted)."""
|
||||
return self._state['mute']
|
||||
|
||||
@property
|
||||
def input_number(self) -> str:
|
||||
"""Current input number (two-digit string)."""
|
||||
return self._state['input']
|
||||
|
||||
@property
|
||||
def input_name(self) -> str:
|
||||
"""Current input name."""
|
||||
return self._input_list.get(self._state['input'], 'Unknown')
|
||||
|
||||
async def set_power(self, value: bool):
|
||||
"""Turn power on/off."""
|
||||
cmd = 'power_on' if value else 'power_off'
|
||||
await self._send_raw(COMMANDS[cmd])
|
||||
self._state['power'] = value
|
||||
logger.info(f"Power {'ON' if value else 'OFF'}")
|
||||
|
||||
async def set_volume(self, db_value: int):
|
||||
# Normalize input to negative dB
|
||||
clean_db = -abs(db_value)
|
||||
# Calculate raw value based on linear formula
|
||||
raw_result = 2 * clean_db + 160
|
||||
# Apply limits: Max 140, Min 0
|
||||
value = int(max(0, min(raw_result, 140)))
|
||||
await self._send_raw(COMMANDS['volume_set'].format(value))
|
||||
self._state['volume'] = value
|
||||
logger.info(f"Volume set to {value} (raw)")
|
||||
|
||||
async def set_volume_raw(self, raw_value: int):
|
||||
# Apply limits: Max 140, Min 0
|
||||
value = int(max(0, min(raw_value, 140)))
|
||||
await self._send_raw(COMMANDS['volume_set'].format(value))
|
||||
self._state['volume'] = value
|
||||
logger.info(f"Volume set to {value} (raw)")
|
||||
|
||||
async def set_mute(self, value: bool):
|
||||
"""Set mute on/off."""
|
||||
cmd = 'mute_on' if value else 'mute_off'
|
||||
await self._send_raw(COMMANDS[cmd])
|
||||
self._state['mute'] = value
|
||||
logger.info(f"Mute {'ON' if value else 'OFF'}")
|
||||
|
||||
async def set_input_number(self, number: str):
|
||||
"""Set input by number."""
|
||||
if number in self._input_list:
|
||||
await self._send_raw(COMMANDS['input_select'].format(number))
|
||||
self._state['input'] = number
|
||||
logger.info(f"Input set to {number}: {self._input_list[number]}")
|
||||
else:
|
||||
logger.warning(f"Input number {number} not in input list")
|
||||
|
||||
async def set_input_name(self, name: str):
|
||||
"""Set input by name."""
|
||||
for num, input_name in self._input_list.items():
|
||||
if input_name.lower() == name.lower():
|
||||
await self.set_input_number(num)
|
||||
return
|
||||
logger.warning(f"Input name '{name}' not found")
|
||||
|
||||
async def setup_avr(input_type: str = "Music"):
|
||||
"""
|
||||
Setup function for AVR control.
|
||||
Connects to AVR, checks power state, turns on, sets input and volume.
|
||||
"""
|
||||
if input_type.upper() == "MUSIC":
|
||||
vol = 50
|
||||
else:
|
||||
vol = 35
|
||||
|
||||
try:
|
||||
async with AVR(HOST, PORT) as avr:
|
||||
await avr.update_state()
|
||||
# Only run setup if receiver is turned off, otherwise ignore
|
||||
if not avr.power:
|
||||
await avr.set_power(True)
|
||||
await asyncio.sleep(10) # Wait for AVR to boot (slow!!!)
|
||||
input_no = next((k for k, v in DEFAULT_INPUTS.items() if v == input_type.upper()), '04')
|
||||
await avr.set_input_number(input_no)
|
||||
await asyncio.sleep(2) # Need to be gentle to the AVR
|
||||
await avr.set_volume(vol)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Connection timeout to {HOST}:{PORT}")
|
||||
sys.exit(1)
|
||||
except ConnectionRefusedError:
|
||||
logger.error(f"Connection refused by {HOST}:{PORT}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"Test failed with error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(setup_avr("Music"))
|
||||
|
||||
# Decibal Ref:
|
||||
"""
|
||||
160 = 0dB
|
||||
140 = -10dB
|
||||
120 = -20dB
|
||||
100 = -30dB
|
||||
90 = -35dB
|
||||
80 = -40dB
|
||||
70 = -45dB
|
||||
60 = -50dB
|
||||
50 = -55dB
|
||||
40 = -60dB
|
||||
"""
|
||||
|
||||
#########################
|
||||
# Tool Calls
|
||||
#########################
|
||||
|
||||
# Turn On
|
||||
async def _turn_on_sound_system():
|
||||
"""Turn on the sound system."""
|
||||
try:
|
||||
async with AVR(HOST, PORT) as avr:
|
||||
if not avr.power:
|
||||
await avr.set_power(True)
|
||||
await asyncio.sleep(5) # Wait for AVR to boot (slow)
|
||||
except Exception as e:
|
||||
logger.error(f"Pioneer power on failed with error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@tool(
|
||||
name="turn_on_sound_system",
|
||||
description="Turn on the Pioneer AVR",
|
||||
aliases=["sound_on", "sound_system_on", "turn_on_sound_system"]
|
||||
)
|
||||
def turn_on_sound_system():
|
||||
"""Turn on the sound system"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_turn_on_sound_system())
|
||||
else:
|
||||
return loop.create_task(_turn_on_sound_system())
|
||||
|
||||
|
||||
# Turn Off
|
||||
async def _turn_off_sound_system():
|
||||
"""Turn off the sound system."""
|
||||
try:
|
||||
async with AVR(HOST, PORT) as avr:
|
||||
await avr.set_power(False)
|
||||
except Exception as e:
|
||||
logger.error(f"Pioneer power off failed with error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@tool(
|
||||
name="turn_off_sound_system",
|
||||
description="Turn off the Pioneer AVR",
|
||||
aliases=["sound_off", "sound_system_off", "turn_off_sound_system"]
|
||||
)
|
||||
def turn_off_sound_system():
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_turn_off_sound_system())
|
||||
else:
|
||||
return loop.create_task(_turn_off_sound_system())
|
||||
|
||||
|
||||
# Set Input
|
||||
async def _set_input_sound_system(input_no: str = '04'):
|
||||
"""Set the sound system input."""
|
||||
try:
|
||||
async with AVR(HOST, PORT) as avr:
|
||||
await avr.set_input_number(input_no)
|
||||
except Exception as e:
|
||||
logger.error(f"Pioneer input change failed with error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@tool(
|
||||
name="set_input_sound_system",
|
||||
description="Set the input for the Pioneer AVR",
|
||||
aliases=["sound_input", "sound_system_input", "set_input_sound_system"]
|
||||
)
|
||||
def set_input_sound_system(input_type: str = "Music"):
|
||||
# Convert input type to number, defaults to music
|
||||
input_no = next((k for k, v in DEFAULT_INPUTS.items() if v == input_type.upper()), '04')
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_set_input_sound_system(input_no))
|
||||
else:
|
||||
return loop.create_task(_set_input_sound_system(input_no))
|
||||
|
||||
|
||||
# Set Volume
|
||||
async def _set_volume_sound_system(volume_no: int = 35):
|
||||
"""Set the sound system volume."""
|
||||
try:
|
||||
async with AVR(HOST, PORT) as avr:
|
||||
await avr.set_volume(volume_no)
|
||||
except Exception as e:
|
||||
logger.error(f"Pioneer volume change failed with error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@tool(
|
||||
name="set_volume_sound_system",
|
||||
description="Set the volume for the Pioneer AVR",
|
||||
aliases=["volume", "sound_volume", "sound_system_volume", "set_volume_sound_system"]
|
||||
)
|
||||
def set_volume_sound_system(volume: Optional[str] = None):
|
||||
volume_no = int(volume or 35) # Default to -35dB (90 raw)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_set_volume_sound_system(volume_no))
|
||||
else:
|
||||
return loop.create_task(_set_volume_sound_system(volume_no))
|
||||
|
||||
|
||||
# Increase Volume
|
||||
async def _increase_volume_sound_system():
|
||||
"""increase the sound system volume."""
|
||||
try:
|
||||
async with AVR(HOST, PORT) as avr:
|
||||
await avr.update_state()
|
||||
await asyncio.sleep(1)
|
||||
curr_volume = int(avr.volume or 80) # Default to -35dB (90 raw)
|
||||
await avr.set_volume(curr_volume+10)
|
||||
except Exception as e:
|
||||
logger.error(f"Pioneer volume change failed with error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@tool(
|
||||
name="increase_volume_sound_system",
|
||||
description="Increase the volume for the Pioneer AVR",
|
||||
aliases=["louder", "increase_volume", "increase_sound_volume", "increase_volume_sound_system"]
|
||||
)
|
||||
def increase_volume_sound_system():
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_increase_volume_sound_system())
|
||||
else:
|
||||
return loop.create_task(_increase_volume_sound_system())
|
||||
|
||||
|
||||
# Decrease Volume
|
||||
async def _decrease_volume_sound_system():
|
||||
"""Decrease the sound system volume."""
|
||||
try:
|
||||
async with AVR(HOST, PORT) as avr:
|
||||
await avr.update_state()
|
||||
await asyncio.sleep(1)
|
||||
curr_volume = int(avr.volume or 100) # Default to -35dB (90 raw)
|
||||
await avr.set_volume(curr_volume-10)
|
||||
except Exception as e:
|
||||
logger.error(f"Pioneer volume change failed with error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@tool(
|
||||
name="decrease_volume_sound_system",
|
||||
description="decrease the volume for the Pioneer AVR",
|
||||
aliases=["quieter", "decrease_volume", "decrease_sound_volume", "decrease_volume_sound_system"]
|
||||
)
|
||||
def decrease_volume_sound_system():
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_decrease_volume_sound_system())
|
||||
else:
|
||||
return loop.create_task(_decrease_volume_sound_system())
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Web Search using SearXNG
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
import re
|
||||
import requests
|
||||
# import utils.system_prompts
|
||||
from datetime import datetime
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .tool_registry import tool, tool_registry
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEARXNG_URL = config['search']['searxng_url']
|
||||
|
||||
def searxng_search(query, num_results=3):
|
||||
"""
|
||||
Runs a search query against the local SearxNG instance and returns top result URLs.
|
||||
"""
|
||||
payload = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'categories': 'general'
|
||||
}
|
||||
resp = requests.get(SEARXNG_URL, params=payload)
|
||||
resp.raise_for_status()
|
||||
results = resp.json().get('results', [])
|
||||
top_urls = [r['url'] for r in results[:num_results]]
|
||||
return top_urls
|
||||
|
||||
def extract_main_text(html):
|
||||
# Extract visible text from main body
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for bad in soup(["script", "style", "noscript", "footer", "header", "nav", "aside", "form"]):
|
||||
bad.decompose()
|
||||
# Combine text from all paragraphs
|
||||
p_texts = [p.get_text(" ", strip=True) for p in soup.find_all("p") if len(p.get_text(strip=True)) > 40]
|
||||
if not p_texts:
|
||||
text = soup.get_text(separator=" ", strip=True)
|
||||
else:
|
||||
text = "\n".join(p_texts)
|
||||
# Clean whitespace
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text
|
||||
|
||||
def fetch_website_summary(url, max_length=3000):
|
||||
"""
|
||||
Fetches the main text from a URL and returns a summary.
|
||||
"""
|
||||
text = ""
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
|
||||
# Extract main readable content
|
||||
text = extract_main_text(html)
|
||||
|
||||
# TODO: LLM summarization option? Bart or Pegasus?
|
||||
text = text[:max_length]
|
||||
|
||||
return text
|
||||
except Exception as e:
|
||||
return text
|
||||
|
||||
@tool(
|
||||
name="external_information",
|
||||
description="Retrieve news and current event information through web search",
|
||||
aliases=["web_search", "current_events", "fact_search"]
|
||||
)
|
||||
def external_information(query: str = "get me the latest news stories") -> str:
|
||||
"""
|
||||
Get latest information regarding news, facts and current events using SearXNG
|
||||
|
||||
Args:
|
||||
query: the web search query
|
||||
|
||||
Returns:
|
||||
LLM Response on retrieved information
|
||||
"""
|
||||
website_snippets = []
|
||||
try:
|
||||
top_urls = searxng_search(query, num_results=3)
|
||||
for url in top_urls:
|
||||
snippet = fetch_website_summary(url)
|
||||
website_snippets.append(f"\n\nFrom {url}: {snippet}...")
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to search web: {e}")
|
||||
|
||||
today = datetime.now().strftime("%B %d, %Y")
|
||||
|
||||
prompt = f"""
|
||||
Today is {today}.
|
||||
|
||||
{f"A web search has retrieved the following information:\n{chr(10).join(website_snippets)}" if len(website_snippets) > 0 else ""}
|
||||
|
||||
User question:
|
||||
{query}
|
||||
"""
|
||||
return prompt.strip()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Web Search")
|
||||
|
||||
# Print available tools
|
||||
print("\nAvailable tools:")
|
||||
for schema in tool_registry.get_all_schemas():
|
||||
print(f" {schema.name}: {schema.description}")
|
||||
for param in schema.parameters:
|
||||
print(f" - {param.name} ({param.type.value}): {param.description}")
|
||||
|
||||
# Test function calling
|
||||
print("\nTesting function calling:")
|
||||
queries = ["who is the current us president", "who is top of the formula 1 driver championship", "summarise the latest research on autism"]
|
||||
|
||||
for query in queries:
|
||||
result = tool_registry.execute_tool("external_information", kwargs={"query": query})
|
||||
print(f"Query: {query}, Result: {result}")
|
||||
|
||||
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Spotify music control tool
|
||||
"""
|
||||
import os
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv() # Load .env vars
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
import asyncio
|
||||
import spotipy
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
import json
|
||||
import re
|
||||
from typing import Optional
|
||||
import difflib
|
||||
|
||||
from .pioneer_avr import setup_avr
|
||||
from .tool_registry import tool, tool_registry
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CREDS= config['spotify']
|
||||
|
||||
SCOPE = 'user-read-playback-state user-modify-playback-state user-read-currently-playing'
|
||||
|
||||
SIMILARITY_THRESHOLD = 0.6 # How similar a user query is to a playlist, track or album
|
||||
|
||||
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(
|
||||
client_id=CREDS['client_id'],
|
||||
client_secret=CREDS['client_secret'],
|
||||
redirect_uri=CREDS['redirect_uri'],
|
||||
scope='user-read-playback-state user-modify-playback-state user-read-currently-playing'
|
||||
))
|
||||
|
||||
|
||||
def get_active_device():
|
||||
"""Get the active Spotify device ID."""
|
||||
devices = sp.devices()
|
||||
for device in devices['devices']:
|
||||
if device['name'] == CREDS['device_id']:
|
||||
return device['id']
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
name="play_song",
|
||||
description="Play a song by artist and title, or search for a song by query",
|
||||
aliases=["play", "play_music", "start_music"]
|
||||
)
|
||||
def play_song(artist_query: Optional[str] = None, song: Optional[str] = None) -> str:
|
||||
"""
|
||||
Play a song or playlist on Spotify, prioritizing user's playlist names, then songs in playlists,
|
||||
top artists, saved albums, and finally general search.
|
||||
|
||||
Args:
|
||||
artist_query: Artist name, playlist name, or search query
|
||||
song: Song title (if two arguments are provided, one is artist and one is song title)
|
||||
|
||||
Returns:
|
||||
Status message about the played song or playlist.
|
||||
"""
|
||||
# First try to setup sound system if not already on
|
||||
if config['spotify']['use_avr']:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
asyncio.run(setup_avr("Music"))
|
||||
else:
|
||||
loop.create_task(setup_avr("Music"))
|
||||
|
||||
# Ensure arguments are separated
|
||||
if artist_query and re.search(r'\s+by\s+', artist_query):
|
||||
parts = artist_query.split(' by ')
|
||||
if len(parts) == 2:
|
||||
artist_query, song = parts[1].strip(), parts[0].strip()
|
||||
|
||||
if (re.sub(r'[^A-Za-z]+', '', str(artist_query).lower()) == "music") or (artist_query is None):
|
||||
pause()
|
||||
sp.start_playback(device_id=get_active_device())
|
||||
return "Playing music on spotify"
|
||||
|
||||
playlists = sp.current_user_playlists(limit=50)['items']
|
||||
|
||||
# 1. Check if query closely matches a playlist name
|
||||
playlist_names = [pl['name'] for pl in playlists]
|
||||
# Find close matches to artist_query
|
||||
matches = difflib.get_close_matches(artist_query, playlist_names, n=1, cutoff=0.6)
|
||||
if matches:
|
||||
for playlist in playlists:
|
||||
if playlist['name'] == matches[0]:
|
||||
pause()
|
||||
sp.start_playback(device_id=get_active_device(), context_uri=playlist['uri'])
|
||||
return f"Playing your playlist \"{playlist['name']}\""
|
||||
|
||||
# Search user's top five playlists for the track
|
||||
for playlist in playlists[:5]:
|
||||
results = sp.playlist_tracks(playlist['id'])
|
||||
for item in results['items']:
|
||||
track = item['track']
|
||||
if (song and song.lower() in track['name'].lower()) or \
|
||||
(artist_query and artist_query.lower() in track['artists'][0]['name'].lower()):
|
||||
pause()
|
||||
sp.start_playback(device_id=get_active_device(), uris=[track['uri']])
|
||||
return f"Playing {track['name']} by {track['artists'][0]['name']} from your playlist \"{playlist['name']}\""
|
||||
|
||||
# Fallback to general search
|
||||
if artist_query and song:
|
||||
results = sp.search(q=f"artist:{artist_query} track:{song}", type='track', limit=1)
|
||||
elif artist_query:
|
||||
results = sp.search(q=artist_query, type='track', limit=1)
|
||||
else:
|
||||
return "Please provide either artist and song, playlist name, or a search query"
|
||||
|
||||
try:
|
||||
tracks = results.get('tracks', {}).get('items', [])
|
||||
uris = [track['uri'] for track in tracks if 'uri' in track]
|
||||
|
||||
if len(uris) == 0:
|
||||
pause()
|
||||
sp.start_playback(device_id=get_active_device())
|
||||
return "No tracks found, starting playback"
|
||||
else:
|
||||
pause()
|
||||
sp.start_playback(device_id=get_active_device(), uris=uris)
|
||||
track = tracks[0]
|
||||
return f"Playing {track['name']} by {track['artists'][0]['name']}"
|
||||
except Exception as e:
|
||||
return "Unable to play your request"
|
||||
|
||||
|
||||
def is_playing() -> bool:
|
||||
"""Check if currently playing music on Spotify."""
|
||||
playback = sp.current_playback()
|
||||
if playback and playback['is_playing']:
|
||||
return True
|
||||
return False
|
||||
|
||||
@tool(
|
||||
name="pause",
|
||||
description="Pause the currently playing music",
|
||||
aliases=["stop"]
|
||||
)
|
||||
def pause() -> str:
|
||||
"""Pause the currently playing music on Spotify."""
|
||||
playback = sp.current_playback()
|
||||
if playback and playback['is_playing']:
|
||||
sp.pause_playback()
|
||||
return "Playback paused."
|
||||
|
||||
|
||||
@tool(
|
||||
name="resume",
|
||||
description="Resume the currently paused music",
|
||||
aliases=["play", "unpause"]
|
||||
)
|
||||
def resume() -> str:
|
||||
"""Resume the currently paused music on Spotify."""
|
||||
playback = sp.current_playback()
|
||||
if playback and not playback['is_playing']:
|
||||
sp.start_playback(device_id=get_active_device())
|
||||
return "Playback resumed."
|
||||
|
||||
|
||||
@tool(
|
||||
name="skip",
|
||||
description="Skip to the next track",
|
||||
aliases=["next", "next_track"]
|
||||
)
|
||||
def skip() -> str:
|
||||
"""Skip to the next track in the playlist."""
|
||||
sp.next_track()
|
||||
return "Skipped to next track."
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Spotify Music Controller")
|
||||
print(sp.current_playback())
|
||||
|
||||
# Print available tools
|
||||
print("\nAvailable tools:")
|
||||
for schema in tool_registry.get_all_schemas():
|
||||
print(f" {schema.name}: {schema.description}")
|
||||
for param in schema.parameters:
|
||||
print(f" - {param.name} ({param.type.value}): {param.description}")
|
||||
|
||||
# Test function calling
|
||||
print("\nTesting function calling:")
|
||||
result = tool_registry.execute_tool("play_song", kwargs={"artist_query": "old mervs cellphone"})
|
||||
print(f"Result: {result}")
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
ThinQ Connect Tool - Currently only gets dishwasher status
|
||||
"""
|
||||
import os
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv() # Load .env vars
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
import asyncio
|
||||
from aiohttp import ClientSession
|
||||
from thinqconnect.thinq_api import ThinQApi
|
||||
|
||||
|
||||
from .tool_registry import tool, tool_registry
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CREDS = config['thinq']
|
||||
|
||||
async def _get_dishwasher_info():
|
||||
|
||||
async with ClientSession() as session:
|
||||
logger.debug("Created HTTP session")
|
||||
|
||||
# Initialize ThinQ API
|
||||
try:
|
||||
thinq_api = ThinQApi(
|
||||
session=session,
|
||||
access_token=CREDS['access_token'],
|
||||
country_code=CREDS['country_code'],
|
||||
client_id=CREDS['client_id']
|
||||
)
|
||||
logger.debug("ThinQ API initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize ThinQ API: {e}")
|
||||
raise
|
||||
|
||||
try:
|
||||
device_list = await thinq_api.async_get_device_list()
|
||||
|
||||
if device_list is None:
|
||||
logger.warning("API returned None for device list")
|
||||
return None, None, None
|
||||
|
||||
logger.debug(f"Device list: {device_list}")
|
||||
|
||||
# Filter for dishwasher devices
|
||||
dishwashers = [
|
||||
device for device in device_list if device.get('deviceInfo').get('deviceType') == 'DEVICE_DISH_WASHER'
|
||||
]
|
||||
|
||||
if not dishwashers:
|
||||
logger.debug("No dishwasher devices found in device list")
|
||||
return None, None, None
|
||||
|
||||
logger.debug(f"Dishwasher devices: {[d.get('deviceId') for d in dishwashers]}")
|
||||
|
||||
# Get status for each dishwasher
|
||||
for i, dishwasher in enumerate(dishwashers, 1):
|
||||
device_id = dishwasher.get('deviceId')
|
||||
device_name = dishwasher.get('alias', f"Dishwasher {device_id}")
|
||||
|
||||
logger.debug(f"Processing dishwasher {i}/{len(dishwashers)}: {device_name} (ID: {device_id})")
|
||||
|
||||
try:
|
||||
# Get device status to retrieve timer information
|
||||
logger.debug(f"Fetching status for device {device_id}")
|
||||
status_response = await thinq_api.async_get_device_status(device_id)
|
||||
|
||||
if status_response:
|
||||
logger.debug(f"Status response for {device_id}: {status_response}")
|
||||
|
||||
timer_info = status_response.get('timer')
|
||||
state_info = status_response.get('runState')
|
||||
|
||||
if timer_info is not None:
|
||||
# Extract timer information
|
||||
remain_hours = timer_info.get('remainHour')
|
||||
remain_minutes = timer_info.get('remainMinute')
|
||||
|
||||
if state_info is not None:
|
||||
run_state = state_info.get('currentState')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting status for dishwasher {device_id} ({device_name}): {e}", exc_info=True)
|
||||
return None, None, None
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving dishwasher information: {e}", exc_info=True)
|
||||
return None, None, None
|
||||
|
||||
return run_state, remain_hours, remain_minutes
|
||||
|
||||
async def _get_dishwasher_text():
|
||||
run_state, remain_hours, remain_minutes = await _get_dishwasher_info()
|
||||
|
||||
if run_state is not None:
|
||||
status = f"Dishwasher has {remain_hours} hours, {remain_minutes} minutes left. Currently {run_state}."
|
||||
else:
|
||||
status = "Dishwasher is not running"
|
||||
|
||||
return status
|
||||
|
||||
@tool(
|
||||
name="dishwasher_status",
|
||||
description="Get dishwasher status",
|
||||
aliases=["time_left_dishwasher", "dishwasher", "is_dishwasher_finished"]
|
||||
)
|
||||
def dishwasher_status():
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_get_dishwasher_text())
|
||||
else:
|
||||
return loop.create_task(_get_dishwasher_text())
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_get_dishwasher_text())
|
||||
|
||||
|
||||
|
||||
Executable
+229
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Centralized tool registry with schema definitions.
|
||||
|
||||
This module provides a unified interface for tool registration and schema management
|
||||
using the model context protocol for function calling.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from typing import Dict, List, Any, Callable, Optional, Union
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParameterType(Enum):
|
||||
"""Parameter types for function schemas."""
|
||||
STRING = "string"
|
||||
INTEGER = "integer"
|
||||
FLOAT = "number"
|
||||
BOOLEAN = "boolean"
|
||||
ARRAY = "array"
|
||||
OBJECT = "object"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParameterSchema:
|
||||
"""Schema definition for a function parameter."""
|
||||
name: str
|
||||
type: ParameterType
|
||||
description: str
|
||||
required: bool = True
|
||||
default: Optional[Any] = None
|
||||
enum: Optional[List[str]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionSchema:
|
||||
"""Schema definition for a tool function."""
|
||||
name: str
|
||||
description: str
|
||||
parameters: List[ParameterSchema]
|
||||
returns: str
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Centralized registry for all available tools."""
|
||||
|
||||
def __init__(self):
|
||||
self._tools: Dict[str, Callable] = {}
|
||||
self._schemas: Dict[str, FunctionSchema] = {}
|
||||
self._aliases: Dict[str, str] = {}
|
||||
|
||||
def register_tool(
|
||||
self,
|
||||
func: Callable,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
aliases: Optional[List[str]] = None
|
||||
) -> Callable:
|
||||
"""
|
||||
Decorator to register a tool function with schema information.
|
||||
|
||||
Args:
|
||||
func: The function to register
|
||||
name: Function name (defaults to func.__name__)
|
||||
description: Function description
|
||||
aliases: List of alias names for this function
|
||||
"""
|
||||
func_name = name or func.__name__
|
||||
|
||||
# Extract parameter information
|
||||
sig = inspect.signature(func)
|
||||
parameters = []
|
||||
|
||||
for param_name, param in sig.parameters.items():
|
||||
if param_name == 'self':
|
||||
continue
|
||||
|
||||
# Determine parameter type
|
||||
param_type = ParameterType.STRING # Default
|
||||
if param.annotation == int:
|
||||
param_type = ParameterType.INTEGER
|
||||
elif param.annotation == float:
|
||||
param_type = ParameterType.FLOAT
|
||||
elif param.annotation == bool:
|
||||
param_type = ParameterType.BOOLEAN
|
||||
elif param.annotation == list:
|
||||
param_type = ParameterType.ARRAY
|
||||
|
||||
# Get parameter description from docstring
|
||||
param_desc = f"Parameter {param_name}"
|
||||
|
||||
# Check if parameter has default
|
||||
required = param.default == inspect.Parameter.empty
|
||||
|
||||
parameters.append(ParameterSchema(
|
||||
name=param_name,
|
||||
type=param_type,
|
||||
description=param_desc,
|
||||
required=required,
|
||||
default=param.default if not required else None
|
||||
))
|
||||
|
||||
# Create function schema
|
||||
schema = FunctionSchema(
|
||||
name=func_name,
|
||||
description=description or func.__doc__ or f"Function {func_name}",
|
||||
parameters=parameters,
|
||||
returns="string"
|
||||
)
|
||||
|
||||
# Register the function
|
||||
self._tools[func_name] = func
|
||||
self._schemas[func_name] = schema
|
||||
|
||||
# Register aliases
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
self._aliases[alias] = func_name
|
||||
|
||||
logger.info(f"Registered tool: {func_name}")
|
||||
return func
|
||||
|
||||
def get_tool(self, name: str) -> Optional[Callable]:
|
||||
"""Get a tool function by name (including aliases)."""
|
||||
# Check direct name
|
||||
if name in self._tools:
|
||||
return self._tools[name]
|
||||
|
||||
# Check aliases
|
||||
if name in self._aliases:
|
||||
return self._tools[self._aliases[name]]
|
||||
|
||||
return None
|
||||
|
||||
def get_schema(self, name: str) -> Optional[FunctionSchema]:
|
||||
"""Get schema for a tool function."""
|
||||
if name in self._schemas:
|
||||
return self._schemas[name]
|
||||
|
||||
# Check aliases
|
||||
if name in self._aliases:
|
||||
return self._schemas[self._aliases[name]]
|
||||
|
||||
return None
|
||||
|
||||
def get_all_schemas(self) -> List[FunctionSchema]:
|
||||
"""Get all available function schemas."""
|
||||
return list(self._schemas.values())
|
||||
|
||||
def get_all_tools(self) -> Dict[str, Callable]:
|
||||
"""Get all available tools (including aliases)."""
|
||||
tools = self._tools.copy()
|
||||
# Add aliases
|
||||
for alias, original_name in self._aliases.items():
|
||||
tools[alias] = self._tools[original_name]
|
||||
return tools
|
||||
|
||||
def to_openai_schema(self) -> List[Dict[str, Any]]:
|
||||
"""Convert to OpenAI function calling schema format."""
|
||||
schemas = []
|
||||
|
||||
for schema in self._schemas.values():
|
||||
# Convert parameters to OpenAI format
|
||||
properties = {}
|
||||
required_params = []
|
||||
|
||||
for param in schema.parameters:
|
||||
properties[param.name] = {
|
||||
"type": param.type.value,
|
||||
"description": param.description
|
||||
}
|
||||
|
||||
if param.enum:
|
||||
properties[param.name]["enum"] = param.enum
|
||||
|
||||
if param.default is not None:
|
||||
properties[param.name]["default"] = param.default
|
||||
|
||||
if param.required:
|
||||
required_params.append(param.name)
|
||||
|
||||
openai_schema = {
|
||||
"name": schema.name,
|
||||
"description": schema.description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required_params
|
||||
}
|
||||
}
|
||||
|
||||
schemas.append(openai_schema)
|
||||
|
||||
return schemas
|
||||
|
||||
def execute_tool(self, name: str, args: List[Any] = None, kwargs: Dict[str, Any] = None) -> str:
|
||||
"""Execute a tool function."""
|
||||
tool = self.get_tool(name)
|
||||
if not tool:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
args = args or []
|
||||
kwargs = kwargs or {}
|
||||
|
||||
try:
|
||||
result = tool(*args, **kwargs)
|
||||
return result if result is not None else ""
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing tool {name}: {e}")
|
||||
return f"Error executing {name}: {str(e)}"
|
||||
|
||||
|
||||
# Global tool registry instance
|
||||
tool_registry = ToolRegistry()
|
||||
|
||||
|
||||
def tool(
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
aliases: Optional[List[str]] = None
|
||||
):
|
||||
"""Decorator to register a tool function."""
|
||||
def decorator(func):
|
||||
return tool_registry.register_tool(func, name, description, aliases)
|
||||
return decorator
|
||||
Executable
+362
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Weather and time information tool using the centralized tool registry.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
from ftplib import FTP
|
||||
import xmltodict
|
||||
import io
|
||||
from typing import Optional, Dict
|
||||
from word2number import w2n
|
||||
import threading
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
|
||||
from .tool_registry import tool, tool_registry
|
||||
|
||||
# Get the absolute path to the parent directory for importing of audio manager
|
||||
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
sys.path.append(parent_dir)
|
||||
|
||||
from audio.beep_manager import BeepManager
|
||||
# Dictionary to store active timers
|
||||
active_timers: Dict[str, threading.Timer] = {}
|
||||
|
||||
beep_manager = BeepManager()
|
||||
|
||||
|
||||
def summarize_today_tomorrow(forecast_data, location):
|
||||
"""Summarize weather forecast for today and tomorrow."""
|
||||
days = forecast_data['forecast-period'][:2] # Only first two days
|
||||
summary_lines = [f"Forecast for {location}"]
|
||||
|
||||
for day in days:
|
||||
date_str = day['@start-time-local'][:10]
|
||||
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
label = "Today" if date_obj.date() == datetime.today().date() else "Tomorrow"
|
||||
|
||||
# Normalize to list
|
||||
elements = day.get('element', [])
|
||||
if isinstance(elements, dict):
|
||||
elements = [elements]
|
||||
texts = day.get('text', [])
|
||||
if isinstance(texts, dict):
|
||||
texts = [texts]
|
||||
|
||||
element_dict = {e['@type']: e['#text'] for e in elements}
|
||||
text_dict = {t['@type']: t['#text'] for t in texts}
|
||||
|
||||
min_temp = element_dict.get('air_temperature_minimum')
|
||||
max_temp = element_dict.get('air_temperature_maximum')
|
||||
precip_range = element_dict.get('precipitation_range')
|
||||
chance_of_rain = text_dict.get('probability_of_precipitation')
|
||||
precis = text_dict.get('precis', 'No forecast.')
|
||||
|
||||
parts = [f"{label} expect {precis.replace('.', '')}"]
|
||||
if min_temp and max_temp:
|
||||
parts.append(f"{min_temp} to {max_temp} degrees Celcius")
|
||||
elif max_temp:
|
||||
parts.append(f"Maximum temperature {max_temp} degrees Celcius")
|
||||
if chance_of_rain:
|
||||
parts.append(f"with a {chance_of_rain.replace('%', ' percent')} chance of rain")
|
||||
if precip_range:
|
||||
parts.append(f"from {precip_range.replace('mm', 'millimetres')}")
|
||||
|
||||
summary_lines.append(" ".join(parts))
|
||||
|
||||
return ". ".join(summary_lines)
|
||||
|
||||
|
||||
def load_weather_config():
|
||||
try:
|
||||
return config['bom']
|
||||
except Exception as e:
|
||||
# Fallback to default values if config file is not available
|
||||
return {
|
||||
"host": "ftp.bom.gov.au",
|
||||
"path": "/anon/gen/fwo/IDN11060.xml"
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_weather_forecast",
|
||||
description="Get weather forecast for a location",
|
||||
aliases=["weather", "forecast", "get_weather"]
|
||||
)
|
||||
def get_weather_forecast(location: str = config['bom']['default']) -> str:
|
||||
"""
|
||||
Get weather forecast for the specified location.
|
||||
|
||||
Args:
|
||||
location: The location for weather forecast
|
||||
|
||||
Returns:
|
||||
Weather forecast summary
|
||||
"""
|
||||
# Load FTP configuration
|
||||
ftp_config = load_weather_config()
|
||||
|
||||
# Connect to FTP and retrieve file into memory
|
||||
ftp = FTP(ftp_config['host'])
|
||||
ftp.login() # Anonymous login
|
||||
|
||||
xml_data = io.BytesIO()
|
||||
ftp.retrbinary(f"RETR {ftp_config['path']}", xml_data.write)
|
||||
ftp.quit()
|
||||
|
||||
xml_data.seek(0)
|
||||
data = xmltodict.parse(xml_data.read())
|
||||
|
||||
# Find forecast
|
||||
areas = data['product']['forecast']['area']
|
||||
for area in areas:
|
||||
if area['@description'].lower() == location.lower():
|
||||
return summarize_today_tomorrow(area, location)
|
||||
|
||||
# Return nothing if no forecast found
|
||||
return ""
|
||||
|
||||
|
||||
@tool(
|
||||
name="get_current_time",
|
||||
description="Get the current date and time",
|
||||
aliases=["time", "what_time_is_it", "get_time"]
|
||||
)
|
||||
def get_current_time(location: Optional[str] = None) -> str:
|
||||
"""
|
||||
Get the current date and time.
|
||||
|
||||
Args:
|
||||
location: Optional location (not currently used)
|
||||
|
||||
Returns:
|
||||
Formatted current date and time
|
||||
"""
|
||||
# TODO: get location time
|
||||
# Format into natural spoken English
|
||||
text = datetime.now().strftime("%A %B %d %Y at %I %M %p") # No punctuation
|
||||
|
||||
# Optional: Remove leading zero in hour (e.g., "08" → "8")
|
||||
text = re.sub(r'\b0(\d)\b', r'\1', text)
|
||||
|
||||
# Convert AM/PM to lowercase if preferred for TTS
|
||||
text = text.replace("AM", "A M").replace("PM", "P M")
|
||||
|
||||
return text
|
||||
|
||||
|
||||
@tool(
|
||||
name="start_countdown",
|
||||
description="Start a countdown timer for specified duration",
|
||||
aliases=["timer", "countdown", "set_timer", "start_timer"]
|
||||
)
|
||||
def start_countdown(duration: str) -> str:
|
||||
"""
|
||||
Start a countdown timer for the specified duration.
|
||||
|
||||
Args:
|
||||
duration: Duration string like "ten minutes" or "two hours"
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
def parse_duration(duration_str: str) -> int:
|
||||
duration_str = duration_str.lower()
|
||||
|
||||
# Extract number words and convert to digits
|
||||
number_str = ""
|
||||
unit = ""
|
||||
for word in duration_str.split():
|
||||
try:
|
||||
val = w2n.word_to_num(word)
|
||||
number_str = str(val)
|
||||
except ValueError:
|
||||
unit += word + " "
|
||||
|
||||
if not number_str:
|
||||
# Try direct digit extraction
|
||||
numbers = re.findall(r'\d+', duration_str)
|
||||
if not numbers:
|
||||
raise ValueError("No valid duration value found")
|
||||
number_str = numbers[0]
|
||||
|
||||
value = int(number_str)
|
||||
|
||||
if "hour" in unit:
|
||||
seconds = value * 3600
|
||||
elif "minute" in unit:
|
||||
seconds = value * 60
|
||||
elif "second" in unit:
|
||||
seconds = value
|
||||
else:
|
||||
raise ValueError("Unknown duration unit")
|
||||
|
||||
return seconds
|
||||
|
||||
def on_timer_complete(timer_id: str):
|
||||
if timer_id in active_timers:
|
||||
del active_timers[timer_id]
|
||||
# Play alarm sound three times with pause when timer completes
|
||||
for i in [1,1,1]:
|
||||
beep_manager.play_beep(filename="alarm.wav")
|
||||
time.sleep(i)
|
||||
|
||||
try:
|
||||
seconds = parse_duration(duration)
|
||||
timer_id = f"timer_{len(active_timers) + 1}"
|
||||
|
||||
# Create and start timer
|
||||
timer = threading.Timer(seconds, on_timer_complete, args=[timer_id])
|
||||
timer.daemon = True
|
||||
timer.start_time = time.time() # Add this line to track start time
|
||||
timer.start()
|
||||
|
||||
# Store timer reference
|
||||
active_timers[timer_id] = timer
|
||||
|
||||
# Format response message
|
||||
if seconds >= 3600:
|
||||
hours = seconds // 3600
|
||||
return f"Timer started for {hours} {'hour' if hours == 1 else 'hours'}"
|
||||
elif seconds >= 60:
|
||||
minutes = seconds // 60
|
||||
return f"Timer started for {minutes} {'minute' if minutes == 1 else 'minutes'}"
|
||||
else:
|
||||
return f"Timer started for {seconds} {'second' if seconds == 1 else 'seconds'}"
|
||||
|
||||
except ValueError as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
|
||||
@tool(
|
||||
name="cancel_timer",
|
||||
description="Cancel an active timer",
|
||||
aliases=["stop_timer", "end_timer"]
|
||||
)
|
||||
def cancel_timer(timer_id: str) -> str:
|
||||
"""
|
||||
Cancel an active timer.
|
||||
|
||||
Args:
|
||||
timer_id: ID of timer to cancel
|
||||
|
||||
Returns:
|
||||
Confirmation message
|
||||
"""
|
||||
if timer_id in active_timers:
|
||||
timer = active_timers[timer_id]
|
||||
timer.cancel()
|
||||
del active_timers[timer_id]
|
||||
return f"Timer {timer_id} cancelled"
|
||||
return f"Timer {timer_id} not found"
|
||||
|
||||
@tool(
|
||||
name="get_timer_status",
|
||||
description="Get the status of a timer or all timers including time remaining",
|
||||
aliases=["timer_status", "check_timer", "show_timers", "get_timers", "list_timers"]
|
||||
)
|
||||
def get_timer_status(timer_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Get status of a specific timer or all timers.
|
||||
|
||||
Args:
|
||||
timer_id: Optional ID of timer to check. If None, shows all timers.
|
||||
|
||||
Returns:
|
||||
Timer status information
|
||||
"""
|
||||
def format_time_remaining(seconds: float) -> str:
|
||||
"""Format remaining time into hours, minutes and seconds."""
|
||||
remaining = int(seconds)
|
||||
if remaining >= 3600:
|
||||
hours = remaining // 3600
|
||||
minutes = (remaining % 3600) // 60
|
||||
seconds = remaining % 60
|
||||
return f"{hours} hours {minutes} minutes {seconds} seconds"
|
||||
elif remaining >= 60:
|
||||
minutes = remaining // 60
|
||||
seconds = remaining % 60
|
||||
return f"{minutes} minutes {seconds} seconds"
|
||||
else:
|
||||
return f"{remaining} seconds"
|
||||
|
||||
if not active_timers:
|
||||
return "No active timers"
|
||||
|
||||
if timer_id:
|
||||
if timer_id not in active_timers:
|
||||
return f"Timer {timer_id} not found"
|
||||
|
||||
timer = active_timers[timer_id]
|
||||
remaining = max(0, timer.interval - (time.time() - timer.start_time))
|
||||
time_str = format_time_remaining(remaining)
|
||||
return f"Timer {timer_id} has {time_str} remaining"
|
||||
|
||||
# Show status of all timers
|
||||
statuses = []
|
||||
for tid, timer in active_timers.items():
|
||||
remaining = max(0, timer.interval - (time.time() - timer.start_time))
|
||||
time_str = format_time_remaining(remaining)
|
||||
statuses.append(f"{tid}: {time_str}")
|
||||
|
||||
return "Timer status:\n" + "\n".join(statuses)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Weather and Time Information Tool")
|
||||
|
||||
# Print available tools
|
||||
print("\nAvailable tools:")
|
||||
for schema in tool_registry.get_all_schemas():
|
||||
print(f" {schema.name}: {schema.description}")
|
||||
for param in schema.parameters:
|
||||
print(f" - {param.name} ({param.type.value}): {param.description}")
|
||||
|
||||
# Test function calling
|
||||
print("\nTesting function calling:")
|
||||
result = tool_registry.execute_tool("get_current_time")
|
||||
print(f"Current time: {result}")
|
||||
|
||||
result = tool_registry.execute_tool("get_weather_forecast", kwargs={"location": "Sydney"})
|
||||
print(f"Weather forecast: {result}")
|
||||
|
||||
print("\nTesting timer functions:")
|
||||
result = tool_registry.execute_tool("start_countdown", kwargs={"duration": "ten minutes"})
|
||||
print(result)
|
||||
|
||||
result = tool_registry.execute_tool("list_timers")
|
||||
print(result)
|
||||
|
||||
result = tool_registry.execute_tool("cancel_timer", kwargs={"timer_id": "timer_1"})
|
||||
print(result)
|
||||
|
||||
print("\nTesting timer status:")
|
||||
result = tool_registry.execute_tool("start_countdown", kwargs={"duration": "5 minutes"})
|
||||
print(result)
|
||||
|
||||
time.sleep(5) # Wait for a bit to let the timer start
|
||||
|
||||
result = tool_registry.execute_tool("get_timer_status")
|
||||
print(result)
|
||||
|
||||
result = tool_registry.execute_tool("get_timer_status", kwargs={"timer_id": "timer_1"})
|
||||
print(result)
|
||||
|
||||
print("\nTesting timer completion:")
|
||||
result = tool_registry.execute_tool("start_countdown", kwargs={"duration": "3 seconds"})
|
||||
print(result)
|
||||
|
||||
# Wait for timer to complete
|
||||
print("Waiting for timer to finish...")
|
||||
time.sleep(4) # Wait slightly longer than timer duration
|
||||
|
||||
result = tool_registry.execute_tool("list_timers")
|
||||
print(f"After completion: {result}")
|
||||
|
||||
Executable
+226
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Connect to WebOS - LG TV Control
|
||||
"""
|
||||
import yaml
|
||||
|
||||
with open("./data/config.yml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
from bscpylgtv import WebOsClient
|
||||
import os
|
||||
import json
|
||||
|
||||
from .lighting import turn_off_lights
|
||||
from .pioneer_avr import setup_avr
|
||||
from .tool_registry import tool, tool_registry
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TV_IP = config['webos']["ip_address"]
|
||||
TV_MAC = config['webos']["mac_address"]
|
||||
|
||||
class LGTVController:
|
||||
def __init__(self, tv_ip, mac_address=None):
|
||||
self.tv_ip = tv_ip
|
||||
self.mac_address = mac_address
|
||||
self.client = None
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to the TV"""
|
||||
try:
|
||||
self.client = await WebOsClient.create(
|
||||
self.tv_ip,
|
||||
ping_interval=None,
|
||||
states=[]
|
||||
)
|
||||
await self.client.connect()
|
||||
logger.debug(f"✅ Connected to TV at {self.tv_ip}")
|
||||
except Exception as e:
|
||||
logger.debug(f"❌ Failed to connect to TV: {e}")
|
||||
raise
|
||||
|
||||
async def disconnect(self):
|
||||
"""Disconnect from the TV"""
|
||||
if self.client:
|
||||
await self.client.disconnect()
|
||||
logger.debug("🔌 Disconnected from TV")
|
||||
|
||||
def wake_on_lan(self):
|
||||
"""Turn on TV using Wake-on-LAN"""
|
||||
if not self.mac_address:
|
||||
logger.debug("❌ MAC address required for Wake-on-LAN")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Remove any separators from MAC address
|
||||
mac = self.mac_address.replace(':', '').replace('-', '').upper()
|
||||
|
||||
# Create magic packet
|
||||
magic_packet = 'FF' * 6 + mac * 16
|
||||
magic_packet = bytes.fromhex(magic_packet)
|
||||
|
||||
# Send magic packet
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
# Send to broadcast address
|
||||
# broadcast_ip = self.tv_ip.rsplit('.', 1)[0] + '.255'
|
||||
# sock.sendto(magic_packet, (broadcast_ip, 9))
|
||||
# Direct to TV IP
|
||||
sock.sendto(magic_packet, (self.tv_ip, 9))
|
||||
sock.close()
|
||||
|
||||
logger.debug(f"📺 Wake-on-LAN packet sent to {self.mac_address}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"❌ Failed to send Wake-on-LAN: {e}")
|
||||
return False
|
||||
|
||||
async def power_on(self):
|
||||
"""Turn on TV using Wake-on-LAN, then establish connection"""
|
||||
logger.debug("🔌 Attempting to turn on TV...")
|
||||
|
||||
# Send Wake-on-LAN packet
|
||||
if not self.wake_on_lan():
|
||||
return "Unable to turn on TV"
|
||||
|
||||
# Wait for TV to boot up
|
||||
logger.debug("⏳ Waiting for TV to start...")
|
||||
await asyncio.sleep(10) # Give TV time to boot
|
||||
|
||||
# Try to establish connection
|
||||
max_attempts = 5
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await self.connect()
|
||||
return "TV is now on"
|
||||
except Exception:
|
||||
logger.debug(f"🔄 Connection attempt {attempt + 1}/{max_attempts} failed, retrying...")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
return "TV may be on but connection failed"
|
||||
|
||||
async def power_off(self):
|
||||
"""Turn the TV off (standby)"""
|
||||
try:
|
||||
await self.connect()
|
||||
await self.client.power_off()
|
||||
return "TV is now off"
|
||||
except Exception as e:
|
||||
logger.debug(f"❌ Failed to turn off TV: {e}")
|
||||
return "Failed to turn off TV"
|
||||
|
||||
async def volume_up(self):
|
||||
"""Increase volume"""
|
||||
try:
|
||||
await self.connect()
|
||||
result = await self.client.volume_up()
|
||||
return f"Volume increased to {result}"
|
||||
except Exception as e:
|
||||
logger.debug(f"❌ Failed to increase volume: {e}")
|
||||
return "Failed to increase volume"
|
||||
|
||||
async def volume_down(self):
|
||||
"""Decrease volume"""
|
||||
try:
|
||||
await self.connect()
|
||||
result = await self.client.volume_down()
|
||||
return f"Volume decreased to {result}"
|
||||
except Exception as e:
|
||||
logger.debug(f"❌ Failed to decrease volume: {e}")
|
||||
return "Failed to decrease volume"
|
||||
|
||||
async def set_volume(self, level):
|
||||
"""Set volume to specific level (0-100)"""
|
||||
try:
|
||||
await self.connect()
|
||||
result = await self.client.set_volume(level)
|
||||
return f"Volume set to {level}"
|
||||
except Exception as e:
|
||||
logger.debug(f"❌ Failed to set volume: {e}")
|
||||
return "Failed to set volume"
|
||||
|
||||
@tool(
|
||||
name="turn_on_tv",
|
||||
description="Turn on the TV",
|
||||
aliases=["tv_on", "watch_tv", "tv"]
|
||||
)
|
||||
def turn_on_tv():
|
||||
tv = LGTVController(TV_IP, TV_MAC)
|
||||
"""Turn on the TV"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(tv.power_on())
|
||||
else:
|
||||
return loop.create_task(tv.power_on())
|
||||
|
||||
@tool(
|
||||
name="turn_off_tv",
|
||||
description="Turn off the TV",
|
||||
aliases=["tv_off", "no_tv"]
|
||||
)
|
||||
def turn_off_tv():
|
||||
tv = LGTVController(TV_IP, TV_MAC)
|
||||
"""Turn off the TV"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(tv.power_off())
|
||||
else:
|
||||
return loop.create_task(tv.power_off())
|
||||
|
||||
@tool(
|
||||
name="set_tv_volume",
|
||||
description="Set TV Volume",
|
||||
aliases=["tv_volume", "volume_tv"]
|
||||
)
|
||||
def set_tv_volume(new_volume: str):
|
||||
tv = LGTVController(TV_IP, TV_MAC)
|
||||
"""Set TV Volume"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(tv.set_volume(int(new_volume)))
|
||||
else:
|
||||
return loop.create_task(tv.set_volume(int(new_volume)))
|
||||
|
||||
async def _movie_night():
|
||||
tv = LGTVController(TV_IP, TV_MAC)
|
||||
|
||||
try:
|
||||
# Turn on sound system (do first, takes longest)
|
||||
await setup_avr("TV")
|
||||
# Turn off lights
|
||||
turn_off_lights("Living Room")
|
||||
# Turn on TV from standby
|
||||
await tv.power_on()
|
||||
except Exception as e:
|
||||
logger.debug(f"❌ Error: {e}")
|
||||
finally:
|
||||
await tv.disconnect()
|
||||
|
||||
@tool(
|
||||
name="movie_night",
|
||||
description="Turn on TV, sets up sound system and dims lights",
|
||||
aliases=["movie_night", "movie", "watch_movie"]
|
||||
)
|
||||
def movie_night():
|
||||
"""Setup for Movie Night"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(_movie_night())
|
||||
else:
|
||||
return loop.create_task(_movie_night())
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.debug("🎯 LG webOS TV Controller")
|
||||
logger.debug("=" * 30)
|
||||
|
||||
asyncio.run(_movie_night())
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Utilities package for Fulloch voice assistant.
|
||||
|
||||
Contains:
|
||||
- intent_catch: Regex-based intent detection for fast command matching
|
||||
- intents: Intent handler using the tool registry
|
||||
- system_prompts: System prompts for AI models
|
||||
"""
|
||||
|
||||
from .intent_catch import catchAll
|
||||
from .intents import handle_intent, intent_handler
|
||||
from .system_prompts import (
|
||||
getIntentSystemPrompt,
|
||||
getChatSystemPrompt,
|
||||
getPlannerSystemPrompt,
|
||||
getWebSummaryPrompt,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"catchAll",
|
||||
"handle_intent",
|
||||
"intent_handler",
|
||||
"getIntentSystemPrompt",
|
||||
"getChatSystemPrompt",
|
||||
"getPlannerSystemPrompt",
|
||||
"getWebSummaryPrompt",
|
||||
]
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
'''
|
||||
Regex Intent Catch
|
||||
|
||||
Optional functions for catching simple intent based queries quickly before sending on to LLM
|
||||
'''
|
||||
|
||||
import re
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Special regex to just directly send intent without ai check for music control and time checks
|
||||
def extract_after_play(command):
|
||||
pattern = r'play\s+(.+)$'
|
||||
match = re.search(pattern, command, re.IGNORECASE)
|
||||
if match:
|
||||
logger.debug(f"Caught Play Match: {match}")
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
def extract_stop(command):
|
||||
# pattern to match "stop", "pause", or "halt" commands
|
||||
# This will match any of these words at the start of the command
|
||||
# and ignore case sensitivity.
|
||||
# It will also ignore any leading or trailing whitespace and any punctuation.
|
||||
# Example matches: "stop", " pause ", "halt now", "stop."
|
||||
pattern = r'^\s*(stop|pause|halt)\b'
|
||||
match = re.match(pattern, command, re.IGNORECASE)
|
||||
if match:
|
||||
logger.debug(f"Caught Stop Match: {match}")
|
||||
return True
|
||||
return None
|
||||
|
||||
def extract_skip(command):
|
||||
pattern = r'^\s*skip\b'
|
||||
match = re.match(pattern, command, re.IGNORECASE)
|
||||
if match:
|
||||
logger.debug(f"Caught Skip Match: {match}")
|
||||
return True
|
||||
return None
|
||||
|
||||
def extract_resume(command):
|
||||
pattern = r'^\s*resume\b'
|
||||
match = re.match(pattern, command, re.IGNORECASE)
|
||||
if match:
|
||||
logger.debug(f"Caught Resume Match: {match}")
|
||||
return True
|
||||
return None
|
||||
|
||||
def has_time_query(text):
|
||||
pattern = r"(what time is it|what'?s the time|what time it is)"
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
logger.debug(f"Caught Time Match: {match}")
|
||||
return True
|
||||
return None
|
||||
|
||||
def extract_timer(command):
|
||||
"""Extract timer duration from commands like 'start timer ten minutes'."""
|
||||
pattern = r'(?:start|set)\s+(?:a\s+)?(?:timer|time)\s+(?:for\s+)?(.+?)(?:\s+please)?$'
|
||||
|
||||
match = re.search(pattern, command, re.IGNORECASE)
|
||||
if match:
|
||||
logger.debug(f"Caught Timer Match: {match}")
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
def list_timers(command):
|
||||
"""Get list of current timers"""
|
||||
pattern = r'get\s+(?:a\s+)?(?:timers|timer|time)(?:\s+(.*?))?(?:\s+status)?$'
|
||||
|
||||
match = re.search(pattern, command, re.IGNORECASE)
|
||||
if match:
|
||||
logger.debug(f"Caught List Timers Match: {match}")
|
||||
return True
|
||||
return None
|
||||
|
||||
def catchAll(user_message):
|
||||
"""Catch all intents from user message."""
|
||||
to_play = extract_after_play(user_message)
|
||||
if to_play is not None:
|
||||
return {"intent": "play_song", "args": [to_play]}
|
||||
|
||||
to_stop = extract_stop(user_message)
|
||||
if to_stop is not None:
|
||||
return {"intent": "pause", "args": []}
|
||||
|
||||
time_query = has_time_query(user_message)
|
||||
if time_query is not None:
|
||||
return {"intent": "get_time", "args": []}
|
||||
|
||||
to_skip = extract_skip(user_message)
|
||||
if to_skip is not None:
|
||||
return {"intent": "skip", "args": []}
|
||||
|
||||
to_resume = extract_resume(user_message)
|
||||
if to_resume is not None:
|
||||
return {"intent": "resume", "args": []}
|
||||
|
||||
timer_duration = extract_timer(user_message)
|
||||
if timer_duration is not None:
|
||||
return {"intent": "start_countdown", "args": [timer_duration]}
|
||||
|
||||
timer_list = list_timers(user_message)
|
||||
if timer_list is not None:
|
||||
return {"intent": "list_timers", "args": []}
|
||||
|
||||
return user_message
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test cases for different intents
|
||||
test_messages = [
|
||||
"play some rock music",
|
||||
"stop",
|
||||
"what time is it",
|
||||
"skip",
|
||||
"resume",
|
||||
"start timer ten minutes",
|
||||
"set timer for 2 hours",
|
||||
"start a timer thirty seconds please",
|
||||
"random message that shouldn't match",
|
||||
"get time or status",
|
||||
"get timers "
|
||||
]
|
||||
|
||||
print("Testing intent detection:")
|
||||
print("-" * 40)
|
||||
|
||||
for message in test_messages:
|
||||
result = catchAll(message)
|
||||
print(f"Input: {message!r}")
|
||||
print(f"Output: {result}")
|
||||
print("-" * 40)
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
- User: "Turn on the kitchen lights"
|
||||
Output:
|
||||
{"intent": "turn_on_lights", "args": ["kitchen"]}
|
||||
|
||||
- User: "Set brightness to 50 in the living room"
|
||||
Output:
|
||||
{"intent": "set_brightness", "args": ["50", "living room"]}
|
||||
|
||||
- User: "Play a song from the Beatles"
|
||||
Output:
|
||||
{"intent": "play_song", "args": ["the Beatles"]}
|
||||
|
||||
- User: "Louder"
|
||||
Output:
|
||||
{"intent": "increase_volume_sound_system", "args": []}
|
||||
|
||||
- User: "What is on in sydney this weekend"
|
||||
Output:
|
||||
{"intent": "external_information", "args": ["What is on in sydney this weekend"]}
|
||||
|
||||
- User: "Play some music"
|
||||
Output:
|
||||
{"intent": "play_song", "args": []}
|
||||
|
||||
- User: "Whats the time"
|
||||
Output:
|
||||
{"intent": "get_current_time", "args": []}
|
||||
|
||||
- User: "Whats the weather tomorrow"
|
||||
Output:
|
||||
{"intent": "get_weather_forecast", "args": []}
|
||||
|
||||
- User: "Whats the weather forecast"
|
||||
Output:
|
||||
{"intent": "get_weather_forecast", "args": []}
|
||||
|
||||
- User: "Can you order pizza"
|
||||
Output:
|
||||
""
|
||||
|
||||
- User: "Turn on the lights"
|
||||
Output:
|
||||
{"intent": "turn_on_lights", "args": []}
|
||||
|
||||
- User: "Set office temperature to 19 degrees Celcius."
|
||||
Output:
|
||||
{"intent": "set_temperature", "args": [19, "Office"]}
|
||||
|
||||
- User: "Set sound to TV"
|
||||
Output:
|
||||
{"intent": "set_input_sound_system", "args": ["TV"]}
|
||||
|
||||
- User: "What is the temperature upstairs"
|
||||
Output:
|
||||
{"intent": "get_temperature", "args": ["Upstairs"]}
|
||||
|
||||
- User: "Tell us a bedtime story"
|
||||
Output:
|
||||
""
|
||||
|
||||
- User: "Turn off downstairs office lights"
|
||||
Output:
|
||||
{"intent": "turn_off_lights", "args": ["downstairs office"]}
|
||||
|
||||
- User: "Who is the current us president"
|
||||
Output:
|
||||
{"intent": "external_information", "args": ["who is the current us president"]}
|
||||
|
||||
- User: "Movie night"
|
||||
Output:
|
||||
{"intent": "movie_night", "args": []}
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Intent handler using the centralized tool registry.
|
||||
|
||||
This module provides intent handling functionality using the new
|
||||
tool registry system with backward compatibility.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from tools.tool_registry import tool_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntentHandler:
|
||||
"""Centralized intent handler using the tool registry."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger("IntentHandler")
|
||||
|
||||
def get_available_functions(self):
|
||||
"""Get all available functions in OpenAI format."""
|
||||
return tool_registry.to_openai_schema()
|
||||
|
||||
def get_function_descriptions(self) -> str:
|
||||
"""Get human-readable descriptions of all available functions."""
|
||||
descriptions = []
|
||||
for schema in tool_registry.get_all_schemas():
|
||||
desc = f"- {schema.name}: {schema.description}"
|
||||
if schema.parameters:
|
||||
params = []
|
||||
for param in schema.parameters:
|
||||
param_desc = f"{param.name}"
|
||||
if not param.required:
|
||||
param_desc += " (optional)"
|
||||
if param.default is not None:
|
||||
param_desc += f" (default: {param.default})"
|
||||
params.append(param_desc)
|
||||
desc += f" - Parameters: {', '.join(params)}"
|
||||
descriptions.append(desc)
|
||||
|
||||
return "\n".join(descriptions)
|
||||
|
||||
def handle_intent(self, intent_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Handle an intent using the tool registry.
|
||||
|
||||
Args:
|
||||
intent_data: Dictionary containing function call information
|
||||
|
||||
Returns:
|
||||
Result of the function execution
|
||||
"""
|
||||
try:
|
||||
# Handle OpenAI function calling format
|
||||
if "function_call" in intent_data:
|
||||
func_call = intent_data["function_call"]
|
||||
function_name = func_call["name"]
|
||||
arguments = json.loads(func_call["arguments"])
|
||||
|
||||
# Execute the function
|
||||
result = tool_registry.execute_tool(function_name, kwargs=arguments)
|
||||
return result
|
||||
|
||||
# Handle legacy format
|
||||
elif "intent" in intent_data:
|
||||
intent_name = intent_data["intent"]
|
||||
args = intent_data.get("args", [])
|
||||
|
||||
# Execute the function
|
||||
result = tool_registry.execute_tool(intent_name, args=args)
|
||||
return result
|
||||
|
||||
else:
|
||||
self.logger.error(f"Invalid intent data format: {intent_data}")
|
||||
return ""
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception(f"Error handling intent: {e}")
|
||||
return ""
|
||||
|
||||
def validate_intent(self, intent_data: Dict[str, Any]) -> bool:
|
||||
"""Validate that an intent can be executed."""
|
||||
try:
|
||||
if "function_call" in intent_data:
|
||||
func_call = intent_data["function_call"]
|
||||
function_name = func_call["name"]
|
||||
return tool_registry.get_tool(function_name) is not None
|
||||
|
||||
elif "intent" in intent_data:
|
||||
intent_name = intent_data["intent"]
|
||||
return tool_registry.get_tool(intent_name) is not None
|
||||
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Global intent handler instance
|
||||
intent_handler = IntentHandler()
|
||||
|
||||
|
||||
def handle_intent(intent_json):
|
||||
"""
|
||||
Args:
|
||||
intent_json: Intent data in JSON format or dict
|
||||
|
||||
Returns:
|
||||
Result of the intent execution
|
||||
"""
|
||||
if isinstance(intent_json, str):
|
||||
try:
|
||||
intent_data = json.loads(intent_json)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Invalid JSON input")
|
||||
return "Sorry, I was unable to process this request"
|
||||
else:
|
||||
intent_data = intent_json
|
||||
|
||||
return intent_handler.handle_intent(intent_data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the intent handler
|
||||
print("Testing Intent Handler")
|
||||
print("=" * 50)
|
||||
|
||||
# Print available functions
|
||||
print("\nAvailable functions:")
|
||||
print(intent_handler.get_function_descriptions())
|
||||
|
||||
# Test function calling
|
||||
print("\nTesting function calls:")
|
||||
|
||||
# Test with function call format
|
||||
test_intent = {
|
||||
"function_call": {
|
||||
"name": "get_temperature",
|
||||
"arguments": '{"location": "upstairs"}'
|
||||
}
|
||||
}
|
||||
|
||||
result = intent_handler.handle_intent(test_intent)
|
||||
print(f"Function call result: {result}")
|
||||
|
||||
# Test with legacy format
|
||||
legacy_intent = {
|
||||
"intent": "get_current_time",
|
||||
"args": []
|
||||
}
|
||||
|
||||
result = intent_handler.handle_intent(legacy_intent)
|
||||
print(f"Legacy format result: {result}")
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
All System Prompts are kept in this class
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from .intents import intent_handler
|
||||
import logging
|
||||
|
||||
# Get the directory containing this module
|
||||
_MODULE_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
class PromptGenerator:
|
||||
"""Automated prompt generator using the tool registry."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger("PromptGenerator")
|
||||
|
||||
def generate_intent_prompt(self) -> str:
|
||||
"""Generate intent detection prompt automatically from available tools."""
|
||||
function_descriptions = intent_handler.get_function_descriptions()
|
||||
|
||||
intent_example = '{"intent": "intent_name", "args": ["intent_information_if_needed"]}'
|
||||
|
||||
prompt = f"""
|
||||
Given a user's natural language query, generate a JSON response matching one of the following intents and argument patterns.
|
||||
JSON response MUST be of format: {intent_example} or an empty string ""
|
||||
|
||||
Available Intents and their required arguments:
|
||||
{function_descriptions}
|
||||
|
||||
Examples:
|
||||
|
||||
{(_MODULE_DIR / 'intent_examples.txt').read_text()}
|
||||
|
||||
Output only valid JSON or an empty string.
|
||||
"""
|
||||
return prompt
|
||||
|
||||
def generate_planner_prompt(self) -> str:
|
||||
"""Generate planner prompt for knowledge graph information extraction."""
|
||||
|
||||
prompt = """You are a planning assistant that connects to a knowledge graph (KG).
|
||||
Return ONLY a JSON object with keys: lookups, new_facts, strengthen, weaken, and notes.
|
||||
- lookups: list of entities to fetch from the KG, e.g. ["Alice Johnson","Bob Johnson"].
|
||||
- new_facts: list of triples to add if included in the latest user message. Each: {"subject": str, "relation": str, "object": str, "weight": float}.
|
||||
- strengthen: list of triples to upweight if repeated/corroborated/newer. Each: {"subject": str, "relation": str, "object": str, "delta": float}.
|
||||
- weaken: list of triples to downweight if contradicted/obsolete/older. Each: {"subject": str, "relation": str, "object": str, "delta": float}.
|
||||
- notes: 1-2 short bullets on your reasoning (kept brief).
|
||||
|
||||
Do not generate any new facts unless written in the user message.
|
||||
Use common relations: parent_of, spouse_of, sibling_of, lives_at, located_in, works_as, works_at.
|
||||
When a message says something like "no longer", "not anymore", prefer weaken for affected relations.
|
||||
"""
|
||||
return prompt
|
||||
|
||||
def generate_chat_prompt(self) -> str:
|
||||
"""Generate chat prompt"""
|
||||
prompt = f"""
|
||||
You are a helpful, friendly, and engaging AI home assistant.
|
||||
|
||||
You can answer questions, chat, and help the family in a way that is friendly and appropriate for their ages.
|
||||
Be encouraging with the children, responsible and respectful with the parents.
|
||||
Do not comment on any mispronounciations, typos or errors in the query.
|
||||
|
||||
If the user provides web search information you can summarise them in your answer.
|
||||
|
||||
Always answer naturally and conversationally. If something is unsafe or not appropriate for children, gently defer or suggest asking a parent.
|
||||
Prioritize clarity, positivity, and practical help for all family members.
|
||||
|
||||
Keep final answer length to three sentences or less, unless the user specifically asks for more detail.
|
||||
"""
|
||||
return prompt
|
||||
|
||||
def generate_web_summariser_prompt(self) -> str:
|
||||
"""Generate web summariser prompt"""
|
||||
prompt = f"""
|
||||
You are a query-focused summarizer for retrieved web page snippets. Your sole task is to synthesize the provided snippets into concise, accurate notes that can be used to answer the user's query.
|
||||
|
||||
Output summary should be 2-4 sentences synthesizing the most relevant information for the query. Do not give opinions, advice or request any follow up questions.
|
||||
|
||||
You will be given:
|
||||
- The user's question (the query).
|
||||
- One or more retrieved web page snippets.
|
||||
|
||||
Core rules:
|
||||
- Use only the provided snippets. Do not add outside knowledge, speculate, or hallucinate.
|
||||
- Prioritize information directly relevant to the query; ignore unrelated content.
|
||||
- Preserve key facts: names, figures, dates, definitions, constraints, and conditions. Normalize units and expand acronyms on first use if unclear.
|
||||
- Deduplicate and highlight consensus across snippets. If claims conflict, explicitly note the contradiction and cite each source.
|
||||
- Keep caveats and scope limits explicit.
|
||||
- If the needed information is missing, output ""
|
||||
- If no snippets are provided, output ""
|
||||
|
||||
Be neutral, precise, and concise. Do not give advice, opinions, or step-by-step instructions. Do not copy long passages; quote short phrases only when essential.
|
||||
"""
|
||||
return prompt
|
||||
|
||||
|
||||
# Global prompt generator instance
|
||||
prompt_generator = PromptGenerator()
|
||||
|
||||
|
||||
def getIntentSystemPrompt():
|
||||
"""Get the intent detection system prompt."""
|
||||
return prompt_generator.generate_intent_prompt()
|
||||
|
||||
def getPlannerSystemPrompt():
|
||||
"""Get the chat system prompt with function calling."""
|
||||
return prompt_generator.generate_planner_prompt()
|
||||
|
||||
def getChatSystemPrompt():
|
||||
"""Get the chat system prompt with function calling."""
|
||||
return prompt_generator.generate_chat_prompt()
|
||||
|
||||
def getWebSummaryPrompt():
|
||||
"""Get the web summariser system prompt"""
|
||||
return prompt_generator.generate_web_summariser_prompt()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(getIntentSystemPrompt())
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
+40
@@ -0,0 +1,40 @@
|
||||
import wave, struct, math
|
||||
|
||||
sample_rate = 44100
|
||||
beep_freq = 880.0
|
||||
beep_ms = 120
|
||||
gap_ms = 80
|
||||
repeats = 5
|
||||
amplitude = 0.45
|
||||
|
||||
def ramp_env(i, total):
|
||||
ramp = int(0.015 * sample_rate) # 15 ms ramp
|
||||
a = min(1.0, i / max(1, ramp), (total - i) / max(1, ramp))
|
||||
return a
|
||||
|
||||
def tone_samples(freq, ms):
|
||||
n = int(sample_rate * (ms/1000.0))
|
||||
for i in range(n):
|
||||
t = i / sample_rate
|
||||
yield amplitude * ramp_env(i, n) * math.sin(2*math.pi*freq*t)
|
||||
|
||||
def silence_samples(ms):
|
||||
n = int(sample_rate * (ms/1000.0))
|
||||
for _ in range(n):
|
||||
yield 0.0
|
||||
|
||||
with wave.open("alarm.wav", "w") as wf:
|
||||
wf.setnchannels(1) # mono
|
||||
wf.setsampwidth(2) # 16-bit
|
||||
wf.setframerate(sample_rate)
|
||||
|
||||
frames = []
|
||||
for r in range(repeats):
|
||||
frames.extend(tone_samples(beep_freq, beep_ms))
|
||||
if r != repeats - 1:
|
||||
frames.extend(silence_samples(gap_ms))
|
||||
|
||||
# write frames
|
||||
wf.writeframes(b"".join(struct.pack("<h", int(max(-1.0, min(1.0, x)) * 32767)) for x in frames))
|
||||
|
||||
print("Wrote alarm.wav")
|
||||
Reference in New Issue
Block a user