Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Liam Pettigrew
2026-01-23 17:06:44 +11:00
co-authored by Claude Opus 4.5
commit 464d049433
54 changed files with 8800 additions and 0 deletions
+85
View File
@@ -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
+22
View File
@@ -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
View File
@@ -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
+136
View File
@@ -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
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+45
View File
@@ -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"]
Executable
+9
View File
@@ -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
View File
@@ -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.
+51
View File
@@ -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()
+10
View File
@@ -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"]
+37
View File
@@ -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
View File
@@ -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: {}
+42
View File
@@ -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: {}
+40
View File
@@ -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
View File
@@ -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
+214
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+132
View File
@@ -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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

+80
View File
@@ -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
View File
@@ -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
View File
@@ -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",
]
+290
View File
@@ -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.
+28
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""
Test suite for Fulloch voice assistant.
"""
+122
View File
@@ -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
+184
View File
@@ -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
+259
View File
@@ -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"
+37
View File
@@ -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'
]
+202
View File
@@ -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}")
+173
View File
@@ -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}")
+415
View File
@@ -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)
+136
View File
@@ -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}")
+454
View File
@@ -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())
+127
View File
@@ -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}")
+194
View File
@@ -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
View File
@@ -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())
+229
View File
@@ -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
+362
View File
@@ -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
View File
@@ -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())
+27
View File
@@ -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",
]
+134
View File
@@ -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)
+71
View File
@@ -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": []}
+156
View File
@@ -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}")
+122
View File
@@ -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())
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
+40
View File
@@ -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")