Skill: dodeja/anova-skill (version 0.1.0)

--- name: anova-oven description: Control Anova Precision Ovens and Precision Cookers (sous vide) via WiFi WebSocket API. Start cooking modes (sous vide, roasting, steam), set temperatures, monitor status, and stop cooking remotely. license: Apache-2.0 compatibility: Requires Python 3.7+, websockets library, and internet access to Anova cloud API metadata: author: Akshay Dodeja version: "1.0.0" repository: https://github.com/dodeja/anova-skill --- # Anova Oven & Precision Cooker Control Control Anova WiFi devices including Precision Ovens (APO) and Precision Cookers (APC) via WebSocket API. ## Prerequisites 1. **Personal Access Token** from Anova app - Download Anova Oven app (iOS/Android) - Go to: More → Developer → Personal Access Tokens - Create token (starts with `anova-`) - Store in `~/.config/anova/token` 2. **Python dependencies** ```bash pip3 install websockets ``` 3. **Device Setup** - Anova device connected to WiFi - Paired with your Anova account ## Installation ```bash # Install Python dependency pip3 install websockets # Store your token mkdir -p ~/.config/anova echo "anova-YOUR_TOKEN_HERE" > ~/.config/anova/token chmod 600 ~/.config/anova/token ``` ## Usage ### List Devices ```bash python3 scripts/anova.py list ``` ### Basic Cooking ```bash # Simple cook at 350°F for 30 minutes python3 scripts/anova.py cook --temp 350 --duration 30 # Cook at 175°C for 45 minutes python3 scripts/anova.py cook --temp 175 --unit C --duration 45 ``` ### Advanced Controls **Custom Elements:** ```bash # Rear element only (low-temp slow cook) python3 scripts/anova.py cook --temp 225 --elements rear --duration 180 # Bottom + rear (standard roasting) python3 scripts/anova.py cook --temp 375 --elements bottom,rear --duration 45 # All elements (maximum heat) python3 scripts/anova.py cook --temp 450 --elements top,bottom,rear --duration 20 ``` **Custom Fan Speed:** ```bash # Low fan (gentle cooking) python3 scripts/anova.py cook --temp 250 --fan-speed 25 --duration 120 # High fan (fast heat circulation) python3 scripts/anova.py cook --temp 400 --fan-speed 100 --duration 30 ``` **Probe Cooking:** ```bash # Cook to internal temperature (not time-based) python3 scripts/anova.py cook --temp 350 --probe-temp 165 # Low-temp probe cook python3 scripts/anova.py cook --temp 225 --elements rear --fan-speed 25 --probe-temp 135 ``` **Combined Advanced Settings:** ```bash # Precision low-temp cook python3 scripts/anova.py cook --temp 225 --elements rear --fan-speed 25 --duration 180 # High-heat sear python3 scripts/anova.py cook --temp 500 --elements top,bottom,rear --fan-speed 100 --duration 5 ``` ### Stop Cooking ```bash python3 scripts/anova.py stop ``` ### Monitor (Real-time Stream) ```bash python3 scripts/anova.py monitor --monitor-duration 60 ``` ## Natural Language Examples **Agent prompts:** - "Preheat the oven to 375°F for roasting" - "Start sous vide at 135°F for 2 hours" - "What's the current oven temperature?" - "Stop cooking" - "Steam vegetables at 212°F for 15 minutes" ## Features ### Anova Precision Oven (APO) - Sous vide cooking (wet bulb mode) - Roasting (dry bulb mode) - Steam cooking with humidity control - Temperature control (C/F) - Real-time status monitoring - Telemetry export ### Anova Precision Cooker (APC) - Sous vide cooking - Temperature control - Timer management - Real-time status ## API Reference **WebSocket Endpoint:** Via Anova cloud service **Authentication:** Personal Access Token (Bearer token) **Protocol:** WebSocket with JSON messages ## Configuration **Token file:** `~/.config/anova/token` **Default device:** First device found (or specify with `--device-id`) ## Troubleshooting **"No token found":** ```bash echo "anova-YOUR_TOKEN" > ~/.config/anova/token ``` **"No devices found":** - Check device is online in Anova app - Verify WiFi connection - Generate new token **"Connection failed":** - Check internet connection - Verify token is valid - Ensure device is paired with account ## Safety Notes - Always verify temperature before starting long cooks - Use timers to prevent overcooking - Monitor remotely but check in-person for safety - Default timeout: 4 hours max ## References - [Anova Developer Portal](https://developer.anovaculinary.com) - [GitHub: anova-wifi-device-controller](https://github.com/anova-culinary/developer-project-wifi)

path: AGENTS.md
content: # Agent Instructions

You can control an Anova Precision Oven via shell commands.

## Commands

```bash
# List devices
python3 scripts/anova.py list

# Basic cooking
python3 scripts/anova.py cook --temp <number> --unit <F|C> --duration <minutes>

# Advanced controls
python3 scripts/anova.py cook --temp <number> --elements <top,bottom,rear> --fan-speed <0-100> --duration <minutes>

# Probe cooking
python3 scripts/anova.py cook --temp <number> --probe-temp <number>

# Stop cooking
python3 scripts/anova.py stop

# Monitor status
python3 scripts/anova.py monitor --monitor-duration <seconds>
```

## Examples

| User says | Command |
|-----------|---------|
| "Cook at 350°F for 30 minutes" | `python3 scripts/anova.py cook --temp 350 --duration 30` |
| "Low-temp roast at 225°F for 3 hours, rear element only" | `python3 scripts/anova.py cook --temp 225 --elements rear --fan-speed 25 --duration 180` |
| "Roast chicken at 375°F until probe reaches 165°F" | `python3 scripts/anova.py cook --temp 375 --probe-temp 165` |
| "High heat at 450°F with all elements" | `python3 scripts/anova.py cook --temp 450 --elements top,bottom,rear --fan-speed 100 --duration 20` |
| "What's the oven temperature?" | `python3 scripts/anova.py monitor --monitor-duration 5` |
| "Stop cooking" | `python3 scripts/anova.py stop` |

## Temperature Guide

- Sous vide steak (medium-rare): 135°F / 57°C
- Sous vide chicken: 145°F / 63°C
- Roasting: 375-425°F
- Steaming: 212°F / 100°C


path: README.md
content: # Anova Oven & Precision Cooker Skill

An Agent Skill for controlling Anova Precision Ovens (APO) and Precision Cookers (APC) via their WiFi WebSocket API.

**Example prompts:**
- "Start sous vide at 135°F for 2 hours"
- "Preheat to 375°F for roasting"
- "What's the oven temperature?"
- "Stop cooking"

See [AGENTS.md](AGENTS.md) for agent instructions. Works with Claude, Cursor, ChatGPT, and other LLMs.

## Prerequisites

1. **Anova Device**
   - Anova Precision Oven (APO) or Precision Cooker (APC)
   - Connected to WiFi and paired with your Anova account

2. **Personal Access Token**
   - Download the Anova Oven app (iOS/Android)
   - Navigate to: More → Developer → Personal Access Tokens
   - Generate a new token (starts with `anova-`)
3. **Python Environment**
   - Python 3.7 or higher
   - `websockets` library: `pip3 install websockets`

## Installation

1. **Store your Anova token:**
   ```bash
   mkdir -p ~/.config/anova
   echo "anova-YOUR_TOKEN_HERE" > ~/.config/anova/token
   chmod 600 ~/.config/anova/token
   ```

2. **Install Python dependencies:**
   ```bash
   pip3 install websockets
   ```

3. **Test the connection:**
   ```bash
   python3 scripts/anova.py list
   ```

## Usage

See [SKILL.md](SKILL.md) for complete usage instructions and examples.

**Quick examples:**
```bash
# List your devices
python3 scripts/anova.py list

# Basic cooking
python3 scripts/anova.py cook --temp 350 --duration 30

# Advanced: Custom elements and fan speed
python3 scripts/anova.py cook --temp 225 --elements rear --fan-speed 25 --duration 180

# Probe cooking (cook to internal temperature)
python3 scripts/anova.py cook --temp 350 --probe-temp 165

# Monitor real-time status
python3 scripts/anova.py monitor --monitor-duration 60

# Stop cooking
python3 scripts/anova.py stop
```

## Advanced Controls

The skill supports full control over:
- **Temperature**: Any value in °F or °C
- **Heating Elements**: Individual control (top, bottom, rear) or combinations
- **Fan Speed**: 0-100 for precise air circulation control
- **Cooking Mode**: Timer-based or probe-based (cook to internal temperature)

This enables advanced techniques like:
- Low-temp slow roasting (rear element only, low fan)
- High-heat searing (all elements, high fan)
- Probe-based cooking (stop when meat reaches target temp)

## Safety Notes

- Always verify temperatures before starting long cooks
- Monitor cooking remotely but check in person for safety
- Use timers to prevent overcooking
- Default timeout: 4 hours maximum

## Testing

Tested January 2026 with Anova Precision Oven (APO).

| Feature | Status |
|---------|--------|
| Device discovery | ✓ |
| Sous-vide mode | ✓ |
| Roast mode | ✓ |
| Steam mode | ✓ |
| Stop cooking | ✓ |
| Real-time monitoring | ✓ |
| Temperature units (F/C) | ✓ |
| APC (Precision Cooker) | Not tested |

## License

Apache 2.0 - See [LICENSE](LICENSE) for details.

## Credits

Built with the [Anova Developer API](https://developer.anovaculinary.com).

## Contributing

Issues and pull requests welcome! This skill is part of the [Agent Skills](https://agentskills.io) ecosystem.


path: requirements.txt
content: websockets>=10.0


path: scripts/anova.py
content: #!/usr/bin/env python3
"""Anova Oven Control - Fixed command structure"""
import asyncio, websockets, json, argparse, sys, uuid
from pathlib import Path
from datetime import datetime

TOKEN_FILE = Path.home() / ".config" / "anova" / "token"

class AnovaDevice:
    def __init__(self, token):
        self.[REDACTED_SECRET])
        self.ws = None
        self.devices = []
        self.current_device = None
        self.message_history = []
        
    def generate_uuid(self):
        return str(uuid.uuid4())
        
    async def connect(self):
        uri = f"wss://devices.anovaculinary.io?[REDACTED_SECRET],APO"
        try:
            self.ws = await websockets.connect(uri)
            await self.wait_for_discovery()
        except Exception as e:
            print(f"Connection error: {e}")
            raise
    
    async def wait_for_discovery(self):
        timeout = 5
        start = asyncio.get_event_loop().time()
        while (asyncio.get_event_loop().time() - start) < timeout:
            try:
                message = await asyncio.wait_for(self.ws.recv(), timeout=1.0)
                data = json.loads(message)
                self.message_history.append(data)
                
                if data.get("command") == "EVENT_APC_WIFI_LIST" and "payload" in data:
                    for device in data["payload"]:
                        if not any(d["id"] == device["cookerId"] for d in self.devices):
                            self.devices.append({
                                "id": device["cookerId"],
                                "name": device.get("name", "Anova Precision Cooker"),
                                "type": "APC",
                                "device_type": device.get("type", "unknown"),
                                "data": device
                            })
                
                elif data.get("command") == "EVENT_APO_WIFI_LIST" and "payload" in data:
                    for device in data["payload"]:
                        if not any(d["id"] == device["cookerId"] for d in self.devices):
                            self.devices.append({
                                "id": device["cookerId"],
                                "name": device.get("name", "Anova Precision Oven"),
                                "type": "APO",
                                "device_type": device.get("type", "unknown"),
                                "data": device
                            })
                
                if self.devices:
                    break
            except asyncio.TimeoutError:
                continue
            except json.JSONDecodeError:
                continue
    
    async def discover_devices(self):
        await self.connect()
        return self.devices
    
    async def get_working_elements(self):
        """Detect which heating elements are working (not failed)"""
        # Wait for a state message with element status
        for _ in range(20):
            try:
                message = await asyncio.wait_for(self.ws.recv(), timeout=0.5)
                data = json.loads(message)
                self.message_history.append(data)
                
                if data.get("command") == "EVENT_APO_STATE":
                    payload = data.get("payload", {})
                    nodes = payload.get("state", {}).get("nodes", {})
                    elements = nodes.get("heatingElements", {})
                    
                    working = []
                    for element in ["top", "bottom", "rear"]:
                        if element in elements and not elements[element].get("failed", False):
                            working.append(element)
                    
                    if working:
                        return working
            except (asyncio.TimeoutError, json.JSONDecodeError):
                continue
        
        # Fallback: return bottom + rear (safe default)
        return ["bottom", "rear"]
    
    async def send_command(self, command):
        await self.ws.send(json.dumps(command))
        try:
            response = await asyncio.wait_for(self.ws.recv(), timeout=5.0)
            return json.loads(response)
        except asyncio.TimeoutError:
            return None
    
    async def start_cook(self, temp, unit="F", duration=None, elements=None, fan_speed=None, 
                        humidity=None, probe_temp=None):
        """Start cooking - simplified to match official Anova structure"""
        temp_c = temp if unit == "C" else (temp - 32) * 5/9
        temp_f = temp if unit == "F" else (temp * 9/5) + 32
        
        # Default settings - auto-detect working elements
        if elements is None:
            # Try to detect which elements are working
            working_elements = await self.get_working_elements()
            elements = working_elements
            print(f"Auto-detected working elements: {', '.join(elements)}")
        if fan_speed is None:
            fan_speed = 50
            
        heating_elements = {
            "top": {"on": "top" in elements},
            "bottom": {"on": "bottom" in elements},
            "rear": {"on": "rear" in elements}
        }
        
        # Build temperature bulbs - always use DRY mode
        temp_bulbs = {
            "mode": "dry",
            "dry": {
                "setpoint": {
                    "celsius": temp_c,
                    "fahrenheit": temp_f
                }
            }
        }
        
        # Preheat stage
        preheat_stage = {
            "stepType": "stage",
            "id": self.generate_uuid(),
            "title": "",
            "description": "",
            "type": "preheat",
            "userActionRequired": False,
            "temperatureBulbs": temp_bulbs,
            "heatingElements": heating_elements,
            "fan": {"speed": fan_speed},
            "vent": {"open": False},
            "rackPosition": 3,
            "stageTransitionType": "automatic"
        }
        
        # Add steam if humidity specified
        if humidity is not None:
            preheat_stage["steamGenerators"] = {
                "mode": "relative-humidity",
                "relativeHumidity": {"setpoint": humidity}
            }
        
        # Cook stage
        cook_stage = {
            "stepType": "stage",
            "id": self.generate_uuid(),
            "title": "",
            "description": "",
            "type": "cook",
            "userActionRequired": False,
            "temperatureBulbs": temp_bulbs,
            "heatingElements": heating_elements,
            "fan": {"speed": fan_speed},
            "vent": {"open": False},
            "rackPosition": 3,
            "stageTransitionType": "automatic"
        }
        
        if humidity is not None:
            cook_stage["steamGenerators"] = {
                "mode": "relative-humidity",
                "relativeHumidity": {"setpoint": humidity}
            }
        
        # Timer or probe
        if probe_temp is not None:
            probe_temp_c = probe_temp if unit == "C" else (probe_temp - 32) * 5/9
            probe_temp_f = probe_temp if unit == "F" else (probe_temp * 9/5) + 32
            cook_stage["temperatureProbe"] = {
                "setpoint": {
                    "celsius": probe_temp_c,
                    "fahrenheit": probe_temp_f
                }
            }
        elif duration:
            cook_stage["timer"] = {
                "initial": duration * 60
            }
        
        command = {
            "command": "CMD_APO_START",
            "payload": {
                "id": self.current_device["id"],
                "payload": {
                    "cookId": self.generate_uuid(),
                    "cookerId": self.current_device["id"],
                    "cookableId": "",
                    "title": "",
                    "type": self.current_device["device_type"],
                    "originSource": "api",
                    "cookableType": "manual",
                    "stages": [preheat_stage, cook_stage]
                },
                "type": "CMD_APO_START"
            },
            "requestId": self.generate_uuid()
        }
        
        return await self.send_command(command)
    
    async def stop_cook(self):
        command = {
            "command": "CMD_APO_STOP",
            "payload": {
                "id": self.current_device["id"],
                "type": "CMD_APO_STOP"
            },
            "requestId": self.generate_uuid()
        }
        return await self.send_command(command)
    
    async def monitor(self, duration=60):
        start = datetime.now()
        print(f"Monitoring device for {duration} seconds...\n")
        try:
            while (datetime.now() - start).seconds < duration:
                try:
                    message = await asyncio.wait_for(self.ws.recv(), timeout=1.0)
                    data = json.loads(message)
                    self.message_history.append(data)
                    if data.get("command") == f"EVENT_{self.current_device['type']}_STATE":
                        timestamp = datetime.now().strftime("%H:%M:%S")
                        payload = data.get("payload", {})
                        print(f"[{timestamp}] {json.dumps(payload, indent=2)}")
                except asyncio.TimeoutError:
                    continue
                except json.JSONDecodeError:
                    continue
        except KeyboardInterrupt:
            print("\nMonitoring stopped")
    
    async def close(self):
        if self.ws:
            await self.ws.close()

async def main():
    parser = argparse.ArgumentParser(description="Anova Oven Control")
    parser.add_argument("action", choices=["list", "cook", "stop", "monitor"])
    parser.add_argument("--temp", type=float)
    parser.add_argument("--unit", choices=["C", "F"], default="F")
    parser.add_argument("--duration", type=int, help="Minutes")
    parser.add_argument("--elements", help="Comma-separated: top,bottom,rear")
    parser.add_argument("--fan-speed", type=int, help="0-100")
    parser.add_argument("--humidity", type=int, help="0-100")
    parser.add_argument("--probe-temp", type=float)
    parser.add_argument("--monitor-duration", type=int, default=60)
    args = parser.parse_args()
    
    if not TOKEN_FILE.exists():
        print(f"Error: Token file not found at {TOKEN_FILE}")
        sys.exit(1)
    
    [REDACTED_SECRET]).strip()
    device = AnovaDevice(token)
    
    try:
        if args.action == "list":
            devices = await device.discover_devices()
            if not devices:
                print("No devices found")
                sys.exit(1)
            print(f"Found {len(devices)} device(s):\n")
            for i, dev in enumerate(devices):
                print(f"{i+1}. {dev['name']} ({dev['type']})")
                print(f"   ID: {dev['id']}\n")
        else:
            devices = await device.discover_devices()
            if not devices:
                print("No devices found")
                sys.exit(1)
            device.current_device = devices[0]
            print(f"Using device: {device.current_device['name']}\n")
            
            if args.action == "cook":
                if args.temp is None:
                    print("Error: --temp required")
                    sys.exit(1)
                if not args.duration and not args.probe_temp:
                    print("Error: Either --duration or --probe-temp required")
                    sys.exit(1)
                
                elements = None
                if args.elements:
                    elements = [e.strip() for e in args.elements.split(",")]
                
                desc = f"Cooking at {args.temp}°{args.unit}"
                if args.duration:
                    desc += f" for {args.duration} min"
                if args.probe_temp:
                    desc += f" until probe {args.probe_temp}°{args.unit}"
                if args.humidity:
                    desc += f" with {args.humidity}% humidity"
                if args.elements:
                    desc += f" [elements: {args.elements}]"
                if args.fan_speed:
                    desc += f" [fan: {args.fan_speed}]"
                print(f"{desc}...")
                
                response = await device.start_cook(
                    temp=args.temp,
                    unit=args.unit,
                    duration=args.duration,
                    elements=elements,
                    fan_speed=args.fan_speed,
                    humidity=args.humidity,
                    probe_temp=args.probe_temp
                )
                print("✓ Cook started!")
                if response:
                    print(f"Response: {json.dumps(response, indent=2)}")
            
            elif args.action == "stop":
                print("Stopping cook...")
                response = await device.stop_cook()
                print("✓ Stopped!")
                if response:
                    print(f"Response: {json.dumps(response, indent=2)}")
            
            elif args.action == "monitor":
                await device.monitor(args.monitor_duration)
    
    except Exception as e:
        print(f"Error: {e}")
        import traceback
        traceback.print_exc()
        sys.exit(1)
    finally:
        await device.close()

if __name__ == "__main__":
    asyncio.run(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)