Initial commit: Complete ECM Control system with web interface, GPIO control, and scale integration

This commit is contained in:
Marc Boivin 2025-06-18 18:36:52 -04:00
commit 388ad4f5a0
28 changed files with 2607 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.10

BIN
README.md Normal file

Binary file not shown.

43
SPECS.md Normal file
View File

@ -0,0 +1,43 @@
Using https://github.com/lucapinello/pyacaia
I want to control my coffee machine.
I have a relay on GPIO2. It's normally open. The relay closes the circuit that would be driven by the button normally.
It needs to emulate a push in order to start and stop the machine.
So my app needs 2 parts : a web app and a script that will drive the machine.
The webapp need to :
* Create simple recipe : Grams out of timeout which ever comes first. Recipe name
* A link between the 4 buttons and the recipe to trigger the right one
* Show a log of all pulled shots, mapped with what button was pressed and the assossiated recipe
* Allow to find and pair with scales and remember the current scale for the script to use
* Allow the user to drive script parameters : Scale to use, time before a shot is considered pulled (so to add it to the pulled shots log), list of buttons ont the machine and which GPIO it's bind to.
The script needs to :
* Listen for button push on all defined GPIOs
* When a registered button is pushed : Tare the scale, start the machine with the defined values from the target recipe. Take note of the start datetime start a timer
* Look for the set grams out or timeout from the recipe and stop the machine when the first parameter is reached
* Stop the machine
* Keep reading the scale weigth until it goes down significantly or it goes up for less then 0.1g in a second. At that time note the highest gram value and add the shot to the log. Also note the time at which you stopped the machine, the time when the outpout weigth has changed or stopped climbing, the button pressed, the recipe linked to.
If the user pushes the button before the shot is done, and the shot time is lower then the set timeout, log the shot but add a "cancelled" value. If it's above, log it as a succesful shot.
# Technical details
* Use Python
* Use SQLite
* Make the webapp use HTMX, FastAPI
* Create a systemd unit to handle the script
* Have logs and verbosity level for both the script and the webapp. Make sure the script has a DEBUG verbosity level to help with debugging
* Write a Readme
* Use Git
* Use UV

30
ecm-control.service Normal file
View File

@ -0,0 +1,30 @@
[Unit]
Description=ECM Control GPIO Service
After=network.target
Wants=network.target
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/ecm-control
Environment=PATH=/home/pi/ecm-control/.venv/bin
ExecStart=/home/pi/ecm-control/.venv/bin/python -m ecm_control.gpio
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security settings
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/home/pi/ecm-control
# Environment variables
Environment=PYTHONPATH=/home/pi/ecm-control/src
Environment=LOG_LEVEL=INFO
[Install]
WantedBy=multi-user.target

6
main.py Normal file
View File

@ -0,0 +1,6 @@
def main():
print("Hello from ecm-control!")
if __name__ == "__main__":
main()

13
pyproject.toml Normal file
View File

@ -0,0 +1,13 @@
[project]
name = "ecm-control"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115.13",
"gpiozero>=2.0.1",
"jinja2>=3.1.6",
"python-multipart>=0.0.20",
"uvicorn>=0.34.3",
]

View File

@ -0,0 +1,24 @@
"""
ECM Control - Espresso Coffee Machine Control System
A comprehensive system for controlling espresso machines with GPIO relays,
scale integration, and web-based recipe management.
"""
__version__ = "1.0.0"
__author__ = "ECM Control"
__description__ = "Espresso Coffee Machine Control System"
from .database import DatabaseManager
from .models import Recipe, Button, Shot, ShotStatus
from .utils.logging_config import setup_logging, get_logger
__all__ = [
'DatabaseManager',
'Recipe',
'Button',
'Shot',
'ShotStatus',
'setup_logging',
'get_logger'
]

156
src/ecm_control/__main__.py Normal file
View File

@ -0,0 +1,156 @@
"""
ECM Control CLI entry point
"""
import asyncio
import argparse
import sys
from pathlib import Path
from .utils.logging_config import setup_logging, get_logger
from .database import DatabaseManager
def main():
"""Main CLI entry point"""
parser = argparse.ArgumentParser(
description='ECM Control - Espresso Coffee Machine Control System',
prog='ecm-control'
)
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# Web server command
web_parser = subparsers.add_parser('web', help='Start web application')
web_parser.add_argument('--host', default='0.0.0.0', help='Host to bind to')
web_parser.add_argument('--port', default=8000, type=int, help='Port to bind to')
web_parser.add_argument('--reload', action='store_true', help='Enable auto-reload')
# GPIO control command
gpio_parser = subparsers.add_parser('gpio', help='Start GPIO control service')
gpio_parser.add_argument('--mock', action='store_true', help='Use mock GPIO for testing')
# Database management commands
db_parser = subparsers.add_parser('db', help='Database management')
db_subparsers = db_parser.add_subparsers(dest='db_command')
db_subparsers.add_parser('init', help='Initialize database')
db_subparsers.add_parser('reset', help='Reset database (WARNING: deletes all data)')
# Global options
parser.add_argument('--log-level', default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
help='Logging level')
parser.add_argument('--log-file', help='Log file path')
args = parser.parse_args()
# Setup logging
setup_logging(
log_level=args.log_level,
log_file=args.log_file,
console_output=True
)
logger = get_logger('main')
if not args.command:
parser.print_help()
return 1
try:
if args.command == 'web':
return run_web_server(args)
elif args.command == 'gpio':
return asyncio.run(run_gpio_service(args))
elif args.command == 'db':
return run_db_command(args)
else:
logger.error(f"Unknown command: {args.command}")
return 1
except KeyboardInterrupt:
logger.info("Interrupted by user")
return 0
except Exception as e:
logger.error(f"Fatal error: {e}")
return 1
def run_web_server(args):
"""Run the web application"""
import uvicorn
from .web import app
logger = get_logger('web')
logger.info(f"Starting web server on {args.host}:{args.port}")
uvicorn.run(
"ecm_control.web:app",
host=args.host,
port=args.port,
reload=args.reload
)
return 0
async def run_gpio_service(args):
"""Run the GPIO control service"""
from .gpio import ECMController
from .utils.logging_config import configure_logging_from_settings
logger = get_logger('gpio')
logger.info("Starting GPIO control service")
# Configure logging from database settings
db = DatabaseManager()
configure_logging_from_settings(db)
controller = ECMController(use_mock=args.mock)
try:
await controller.initialize()
await controller.run()
except Exception as e:
logger.error(f"GPIO service error: {e}")
raise
finally:
await controller.cleanup()
return 0
def run_db_command(args):
"""Run database management commands"""
logger = get_logger('db')
if args.db_command == 'init':
logger.info("Initializing database...")
db = DatabaseManager()
logger.info("Database initialized successfully")
return 0
elif args.db_command == 'reset':
import os
db_file = "ecm_control.db"
if os.path.exists(db_file):
confirm = input(f"This will delete all data in {db_file}. Continue? (y/N): ")
if confirm.lower() == 'y':
os.remove(db_file)
logger.info(f"Deleted {db_file}")
# Reinitialize
db = DatabaseManager()
logger.info("Database reset and reinitialized")
return 0
else:
logger.info("Reset cancelled")
return 0
else:
logger.info("Database file not found, initializing new database")
db = DatabaseManager()
return 0
else:
logger.error(f"Unknown database command: {args.db_command}")
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@ -0,0 +1,52 @@
import sqlite3
import logging
from pathlib import Path
from typing import Optional, Dict, List, Any
logger = logging.getLogger(__name__)
class DatabaseManager:
def __init__(self, db_path: str = "ecm_control.db"):
self.db_path = db_path
self.init_database()
def init_database(self):
"""Initialize the database with schema"""
schema_path = Path(__file__).parent / "schema.sql"
with sqlite3.connect(self.db_path) as conn:
conn.execute("PRAGMA foreign_keys = ON")
with open(schema_path, 'r') as f:
schema = f.read()
conn.executescript(schema)
conn.commit()
logger.info(f"Database initialized at {self.db_path}")
def get_connection(self) -> sqlite3.Connection:
"""Get a database connection with row factory"""
conn = sqlite3.connect(self.db_path)
conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = sqlite3.Row
return conn
def execute_query(self, query: str, params: tuple = ()) -> List[Dict[str, Any]]:
"""Execute a SELECT query and return results as list of dictionaries"""
with self.get_connection() as conn:
cursor = conn.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
def execute_update(self, query: str, params: tuple = ()) -> int:
"""Execute an INSERT/UPDATE/DELETE query and return affected rows"""
with self.get_connection() as conn:
cursor = conn.execute(query, params)
conn.commit()
return cursor.rowcount
def execute_insert(self, query: str, params: tuple = ()) -> int:
"""Execute an INSERT query and return the last row ID"""
with self.get_connection() as conn:
cursor = conn.execute(query, params)
conn.commit()
return cursor.lastrowid

View File

@ -0,0 +1,59 @@
-- ECM Control Database Schema
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
grams_out REAL NOT NULL,
timeout_seconds INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS buttons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
gpio_pin INTEGER NOT NULL UNIQUE,
recipe_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (recipe_id) REFERENCES recipes (id)
);
CREATE TABLE IF NOT EXISTS shots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
button_id INTEGER NOT NULL,
recipe_id INTEGER NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
target_grams REAL NOT NULL,
actual_grams REAL,
timeout_seconds INTEGER NOT NULL,
actual_duration_seconds INTEGER,
status TEXT NOT NULL CHECK (status IN ('completed', 'cancelled', 'timeout')),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (button_id) REFERENCES buttons (id),
FOREIGN KEY (recipe_id) REFERENCES recipes (id)
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert default settings
INSERT OR IGNORE INTO settings (key, value, description) VALUES
('scale_address', '', 'Paired Acaia scale Bluetooth address'),
('shot_completion_threshold', '0.1', 'Weight change threshold (g/s) to consider shot complete'),
('weight_stabilize_time', '3', 'Time to wait for weight to stabilize after shot'),
('log_level', 'INFO', 'Logging level (DEBUG, INFO, WARNING, ERROR)');
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_shots_start_time ON shots(start_time);
CREATE INDEX IF NOT EXISTS idx_shots_button_id ON shots(button_id);
CREATE INDEX IF NOT EXISTS idx_shots_recipe_id ON shots(recipe_id);
CREATE INDEX IF NOT EXISTS idx_buttons_gpio_pin ON buttons(gpio_pin);

View File

@ -0,0 +1,475 @@
import logging
import asyncio
import time
from datetime import datetime
from typing import Dict, List, Optional, Callable
from gpiozero import Button as GPIOButton, LED
from signal import pause
from ..database import DatabaseManager
from ..utils.scale import ScaleManager, ScaleReading
from ..models import ShotStatus
logger = logging.getLogger(__name__)
class MockGPIO:
"""Mock GPIO implementation for development on non-Raspberry Pi systems"""
def __init__(self, pin: int):
self.pin = pin
self.when_pressed = None
self.when_released = None
self._pressed = False
def wait_for_press(self):
"""Mock wait for press - does nothing in mock"""
pass
def is_pressed(self):
return self._pressed
def mock_press(self):
"""Simulate button press for testing"""
self._pressed = True
if self.when_pressed:
self.when_pressed()
def mock_release(self):
"""Simulate button release for testing"""
self._pressed = False
if self.when_released:
self.when_released()
class MockRelay:
"""Mock relay implementation for development"""
def __init__(self, pin: int):
self.pin = pin
self.is_active = False
logger.info(f"Mock relay initialized on pin {pin}")
def on(self):
"""Turn relay on (close circuit)"""
self.is_active = True
logger.info(f"Mock relay pin {self.pin} turned ON")
def off(self):
"""Turn relay off (open circuit)"""
self.is_active = False
logger.info(f"Mock relay pin {self.pin} turned OFF")
class CoffeeMachineController:
"""Controls the coffee machine via GPIO relay"""
def __init__(self, relay_pin: int = 2, use_mock: bool = False):
self.relay_pin = relay_pin
self.use_mock = use_mock
try:
if use_mock:
self.relay = MockRelay(relay_pin)
else:
self.relay = LED(relay_pin)
logger.info(f"Coffee machine controller initialized on GPIO {relay_pin}")
except Exception as e:
logger.warning(f"Failed to initialize GPIO {relay_pin}, using mock: {e}")
self.relay = MockRelay(relay_pin)
self.use_mock = True
def start_brewing(self):
"""Start the coffee machine (close relay)"""
self.relay.on()
logger.info("Coffee machine started")
def stop_brewing(self):
"""Stop the coffee machine (open relay)"""
self.relay.off()
logger.info("Coffee machine stopped")
def pulse_button(self, duration: float = 0.1):
"""Simulate button press by pulsing relay"""
self.start_brewing()
time.sleep(duration)
self.stop_brewing()
logger.info(f"Button pulsed for {duration}s")
class ButtonManager:
"""Manages physical buttons and their GPIO pins"""
def __init__(self, use_mock: bool = False):
self.use_mock = use_mock
self.buttons: Dict[int, GPIOButton | MockGPIO] = {}
self.button_handlers: Dict[int, Callable] = {}
def add_button(self, button_id: int, gpio_pin: int, handler: Callable):
"""Add a button with its handler"""
try:
if self.use_mock:
button = MockGPIO(gpio_pin)
else:
button = GPIOButton(gpio_pin, pull_up=True, bounce_time=0.2)
button.when_pressed = lambda: handler(button_id)
self.buttons[button_id] = button
self.button_handlers[button_id] = handler
logger.info(f"Button {button_id} added on GPIO {gpio_pin}")
except Exception as e:
logger.error(f"Failed to add button {button_id} on GPIO {gpio_pin}: {e}")
# Fall back to mock
button = MockGPIO(gpio_pin)
button.when_pressed = lambda: handler(button_id)
self.buttons[button_id] = button
self.button_handlers[button_id] = handler
def remove_button(self, button_id: int):
"""Remove a button"""
if button_id in self.buttons:
del self.buttons[button_id]
del self.button_handlers[button_id]
logger.info(f"Button {button_id} removed")
def simulate_button_press(self, button_id: int):
"""Simulate button press for testing"""
if button_id in self.buttons and hasattr(self.buttons[button_id], 'mock_press'):
self.buttons[button_id].mock_press()
class ECMController:
"""Main controller for the ECM system"""
def __init__(self, use_mock: bool = False):
self.use_mock = use_mock
self.db = DatabaseManager()
self.scale_manager: Optional[ScaleManager] = None
self.machine_controller = CoffeeMachineController(relay_pin=2, use_mock=use_mock)
self.button_manager = ButtonManager(use_mock=use_mock)
self.active_shots: Dict[int, dict] = {}
self.running = False
async def initialize(self):
"""Initialize the controller"""
logger.info("Initializing ECM Controller...")
# Initialize scale
scale_address = self._get_setting('scale_address', '')
if scale_address:
self.scale_manager = ScaleManager(scale_address)
if not await self.scale_manager.initialize():
logger.warning("Scale initialization failed, continuing without scale")
else:
logger.info("No scale address configured")
# Load buttons from database
await self._load_buttons()
logger.info("ECM Controller initialized successfully")
def _get_setting(self, key: str, default: str = '') -> str:
"""Get setting value from database"""
try:
result = self.db.execute_query("SELECT value FROM settings WHERE key = ?", (key,))
return result[0]['value'] if result else default
except Exception as e:
logger.error(f"Error getting setting {key}: {e}")
return default
async def _load_buttons(self):
"""Load button configuration from database"""
try:
buttons = self.db.execute_query("""
SELECT b.id, b.name, b.gpio_pin, b.recipe_id, r.name as recipe_name,
r.grams_out, r.timeout_seconds
FROM buttons b
LEFT JOIN recipes r ON b.recipe_id = r.id
WHERE b.recipe_id IS NOT NULL
""")
for button in buttons:
self.button_manager.add_button(
button['id'],
button['gpio_pin'],
self._handle_button_press
)
logger.info(f"Loaded button: {button['name']} -> {button['recipe_name']}")
except Exception as e:
logger.error(f"Error loading buttons: {e}")
async def _handle_button_press(self, button_id: int):
"""Handle physical button press"""
logger.info(f"Button {button_id} pressed")
try:
# Get button and recipe info
button_info = self.db.execute_query("""
SELECT b.*, r.name as recipe_name, r.grams_out, r.timeout_seconds
FROM buttons b
JOIN recipes r ON b.recipe_id = r.id
WHERE b.id = ?
""", (button_id,))
if not button_info:
logger.warning(f"No recipe configured for button {button_id}")
return
button = button_info[0]
# Check if shot is already in progress for this button
if button_id in self.active_shots:
# Button pressed again - cancel current shot
await self._cancel_shot(button_id)
return
# Start new shot
await self._start_shot(button_id, button)
except Exception as e:
logger.error(f"Error handling button press: {e}")
async def _start_shot(self, button_id: int, button_info: dict):
"""Start a new espresso shot"""
logger.info(f"Starting shot: {button_info['recipe_name']}")
try:
# Create shot record
shot_id = self.db.execute_insert("""
INSERT INTO shots (button_id, recipe_id, start_time, target_grams,
timeout_seconds, status)
VALUES (?, ?, ?, ?, ?, ?)
""", (
button_id,
button_info['recipe_id'],
datetime.now().isoformat(),
button_info['grams_out'],
button_info['timeout_seconds'],
ShotStatus.COMPLETED # Will be updated based on outcome
))
# Store active shot info
self.active_shots[button_id] = {
'shot_id': shot_id,
'start_time': time.time(),
'target_grams': button_info['grams_out'],
'timeout_seconds': button_info['timeout_seconds'],
'button_info': button_info
}
# Tare scale if available
if self.scale_manager:
await self.scale_manager.tare_scale()
# Start coffee machine
self.machine_controller.start_brewing()
# Monitor shot in background
asyncio.create_task(self._monitor_shot(button_id))
except Exception as e:
logger.error(f"Error starting shot: {e}")
if button_id in self.active_shots:
del self.active_shots[button_id]
async def _monitor_shot(self, button_id: int):
"""Monitor shot progress"""
if button_id not in self.active_shots:
return
shot_info = self.active_shots[button_id]
shot_id = shot_info['shot_id']
target_grams = shot_info['target_grams']
timeout_seconds = shot_info['timeout_seconds']
start_time = shot_info['start_time']
logger.info(f"Monitoring shot {shot_id}: target={target_grams}g, timeout={timeout_seconds}s")
try:
if self.scale_manager:
# Monitor with scale
target_reached, final_weight, duration = await self.scale_manager.monitor_weight_during_shot(
target_grams, timeout_seconds
)
# Stop machine
self.machine_controller.stop_brewing()
# Wait for weight to stabilize
stable_weight = await self.scale_manager.wait_for_stable_weight(3.0)
final_weight = stable_weight if stable_weight is not None else final_weight
# Determine shot status
if target_reached:
status = ShotStatus.COMPLETED
else:
status = ShotStatus.TIMEOUT
else:
# Monitor without scale (time-based only)
await asyncio.sleep(timeout_seconds)
self.machine_controller.stop_brewing()
duration = time.time() - start_time
final_weight = None
status = ShotStatus.TIMEOUT # Without scale, we assume timeout
# Update shot record
self.db.execute_update("""
UPDATE shots
SET end_time = ?, actual_grams = ?, actual_duration_seconds = ?, status = ?
WHERE id = ?
""", (
datetime.now().isoformat(),
final_weight,
int(duration),
status.value,
shot_id
))
logger.info(f"Shot {shot_id} completed: {status.value}, {final_weight}g, {duration:.1f}s")
except Exception as e:
logger.error(f"Error monitoring shot {shot_id}: {e}")
# Ensure machine is stopped
self.machine_controller.stop_brewing()
# Mark shot as cancelled due to error
self.db.execute_update("""
UPDATE shots
SET end_time = ?, status = ?, notes = ?
WHERE id = ?
""", (
datetime.now().isoformat(),
ShotStatus.CANCELLED.value,
f"Error: {str(e)}",
shot_id
))
finally:
# Remove from active shots
if button_id in self.active_shots:
del self.active_shots[button_id]
async def _cancel_shot(self, button_id: int):
"""Cancel active shot"""
if button_id not in self.active_shots:
return
shot_info = self.active_shots[button_id]
shot_id = shot_info['shot_id']
start_time = shot_info['start_time']
logger.info(f"Cancelling shot {shot_id}")
# Stop machine
self.machine_controller.stop_brewing()
# Calculate duration
duration = time.time() - start_time
# Get final weight if scale available
final_weight = None
if self.scale_manager:
reading = await self.scale_manager.get_current_weight()
if reading:
final_weight = reading.weight
# Update shot record
self.db.execute_update("""
UPDATE shots
SET end_time = ?, actual_grams = ?, actual_duration_seconds = ?, status = ?
WHERE id = ?
""", (
datetime.now().isoformat(),
final_weight,
int(duration),
ShotStatus.CANCELLED.value,
shot_id
))
# Remove from active shots
del self.active_shots[button_id]
logger.info(f"Shot {shot_id} cancelled after {duration:.1f}s")
async def reload_buttons(self):
"""Reload button configuration from database"""
logger.info("Reloading button configuration...")
# Clear existing buttons
self.button_manager.buttons.clear()
self.button_manager.button_handlers.clear()
# Reload from database
await self._load_buttons()
async def run(self):
"""Run the controller"""
self.running = True
logger.info("ECM Controller started")
try:
while self.running:
await asyncio.sleep(1) # Main loop
except KeyboardInterrupt:
logger.info("Received interrupt signal")
except Exception as e:
logger.error(f"Unexpected error in main loop: {e}")
finally:
await self.cleanup()
async def stop(self):
"""Stop the controller"""
self.running = False
logger.info("ECM Controller stop requested")
async def cleanup(self):
"""Clean up resources"""
logger.info("Cleaning up ECM Controller...")
# Stop any active shots
for button_id in list(self.active_shots.keys()):
await self._cancel_shot(button_id)
# Ensure machine is stopped
self.machine_controller.stop_brewing()
# Clean up scale
if self.scale_manager:
await self.scale_manager.cleanup()
logger.info("ECM Controller cleaned up")
# CLI entry point
async def main():
"""Main entry point for the GPIO control script"""
import argparse
parser = argparse.ArgumentParser(description='ECM Control GPIO Script')
parser.add_argument('--mock', action='store_true', help='Use mock GPIO for testing')
parser.add_argument('--log-level', default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
help='Logging level')
args = parser.parse_args()
# Setup logging
logging.basicConfig(
level=getattr(logging, args.log_level),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
controller = ECMController(use_mock=args.mock)
try:
await controller.initialize()
await controller.run()
except KeyboardInterrupt:
logger.info("Shutting down...")
except Exception as e:
logger.error(f"Fatal error: {e}")
finally:
await controller.cleanup()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,63 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
class ShotStatus(str, Enum):
COMPLETED = "completed"
CANCELLED = "cancelled"
TIMEOUT = "timeout"
class Recipe(BaseModel):
id: Optional[int] = None
name: str = Field(..., min_length=1, max_length=100)
grams_out: float = Field(..., gt=0, le=100)
timeout_seconds: int = Field(..., gt=0, le=300)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Button(BaseModel):
id: Optional[int] = None
name: str = Field(..., min_length=1, max_length=50)
gpio_pin: int = Field(..., ge=0, le=40)
recipe_id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Shot(BaseModel):
id: Optional[int] = None
button_id: int
recipe_id: int
start_time: datetime
end_time: Optional[datetime] = None
target_grams: float
actual_grams: Optional[float] = None
timeout_seconds: int
actual_duration_seconds: Optional[int] = None
status: ShotStatus
notes: Optional[str] = None
created_at: Optional[datetime] = None
class Setting(BaseModel):
id: Optional[int] = None
key: str
value: str
description: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class ShotCreate(BaseModel):
button_id: int
recipe_id: int
target_grams: float
timeout_seconds: int
class RecipeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
grams_out: float = Field(..., gt=0, le=100)
timeout_seconds: int = Field(..., gt=0, le=300)
class ButtonCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
gpio_pin: int = Field(..., ge=0, le=40)
recipe_id: Optional[int] = None

View File

@ -0,0 +1 @@
# ECM Control Utilities

View File

@ -0,0 +1,193 @@
import logging
import logging.handlers
import os
from pathlib import Path
from typing import Optional
def setup_logging(
log_level: str = "INFO",
log_file: Optional[str] = None,
max_bytes: int = 10 * 1024 * 1024, # 10MB
backup_count: int = 5,
console_output: bool = True
):
"""
Setup comprehensive logging configuration for the ECM Control system
Args:
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
log_file: Path to log file (optional)
max_bytes: Maximum size of each log file
backup_count: Number of backup log files to keep
console_output: Whether to output logs to console
"""
# Convert string level to logging constant
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
# Create formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Get root logger
root_logger = logging.getLogger()
root_logger.setLevel(numeric_level)
# Clear existing handlers
root_logger.handlers.clear()
# Console handler
if console_output:
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
console_handler.setLevel(numeric_level)
root_logger.addHandler(console_handler)
# File handler with rotation
if log_file:
# Ensure log directory exists
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=max_bytes,
backupCount=backup_count
)
file_handler.setFormatter(formatter)
file_handler.setLevel(numeric_level)
root_logger.addHandler(file_handler)
# Set specific logger levels for external libraries
logging.getLogger('urllib3').setLevel(logging.WARNING)
logging.getLogger('asyncio').setLevel(logging.WARNING)
logging.getLogger('gpiozero').setLevel(logging.WARNING)
# Create logger for our application
app_logger = logging.getLogger('ecm_control')
app_logger.setLevel(numeric_level)
return app_logger
def get_logger(name: str) -> logging.Logger:
"""Get a logger with the given name"""
return logging.getLogger(f'ecm_control.{name}')
class PerformanceLogger:
"""Logger for performance metrics and shot statistics"""
def __init__(self, log_file: str = "logs/performance.log"):
self.logger = logging.getLogger('ecm_control.performance')
# Setup dedicated performance log file
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=5 * 1024 * 1024, # 5MB
backupCount=3
)
formatter = logging.Formatter(
'%(asctime)s,%(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler.setFormatter(formatter)
self.logger.addHandler(handler)
self.logger.setLevel(logging.INFO)
self.logger.propagate = False # Don't propagate to root logger
def log_shot(self, shot_data: dict):
"""Log shot performance data"""
# Create CSV-like log entry
entry = f"SHOT,{shot_data.get('shot_id')},{shot_data.get('recipe_name')},{shot_data.get('button_name')},{shot_data.get('target_grams')},{shot_data.get('actual_grams')},{shot_data.get('duration')},{shot_data.get('status')}"
self.logger.info(entry)
def log_system_event(self, event_type: str, details: str):
"""Log system events"""
entry = f"SYSTEM,{event_type},{details}"
self.logger.info(entry)
class DebugLogger:
"""Enhanced debug logging for troubleshooting"""
def __init__(self, enabled: bool = False):
self.enabled = enabled
self.logger = logging.getLogger('ecm_control.debug')
if enabled:
self.logger.setLevel(logging.DEBUG)
# Create debug log file
debug_log = Path("logs/debug.log")
debug_log.parent.mkdir(parents=True, exist_ok=True)
handler = logging.handlers.RotatingFileHandler(
debug_log,
maxBytes=20 * 1024 * 1024, # 20MB
backupCount=2
)
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler.setFormatter(formatter)
self.logger.addHandler(handler)
self.logger.propagate = False
def log_gpio_event(self, pin: int, event: str, details: str = ""):
"""Log GPIO events with detailed timing"""
if self.enabled:
self.logger.debug(f"GPIO_{pin}_{event}: {details}")
def log_scale_reading(self, weight: float, timestamp: float, stable: bool = False):
"""Log scale readings for debugging"""
if self.enabled:
self.logger.debug(f"SCALE_READING: weight={weight}g, time={timestamp}, stable={stable}")
def log_shot_phase(self, shot_id: int, phase: str, data: dict):
"""Log detailed shot phases"""
if self.enabled:
data_str = ", ".join([f"{k}={v}" for k, v in data.items()])
self.logger.debug(f"SHOT_{shot_id}_{phase}: {data_str}")
# Global logger instances
performance_logger = PerformanceLogger()
debug_logger = DebugLogger()
def configure_logging_from_settings(db_manager):
"""Configure logging based on database settings"""
try:
# Get log level from settings
result = db_manager.execute_query("SELECT value FROM settings WHERE key = 'log_level'")
log_level = result[0]['value'] if result else 'INFO'
# Determine if debug logging should be enabled
debug_enabled = log_level.upper() == 'DEBUG'
# Setup main logging
setup_logging(
log_level=log_level,
log_file="logs/ecm-control.log",
console_output=True
)
# Update debug logger
global debug_logger
debug_logger = DebugLogger(enabled=debug_enabled)
logger = get_logger('config')
logger.info(f"Logging configured: level={log_level}, debug_enabled={debug_enabled}")
except Exception as e:
# Fallback to default logging
setup_logging()
logger = get_logger('config')
logger.error(f"Failed to configure logging from settings: {e}")
logger.info("Using default logging configuration")

View File

@ -0,0 +1,233 @@
import logging
import time
import asyncio
from typing import Optional, Callable
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class ScaleReading:
weight: float
timestamp: float
stable: bool = False
class MockAcaiaScale:
"""Mock implementation of Acaia scale for development and testing"""
def __init__(self, address: str = ""):
self.address = address
self.connected = False
self.current_weight = 0.0
self.is_tared = False
self._weight_callback: Optional[Callable[[ScaleReading], None]] = None
self._mock_shot_in_progress = False
async def connect(self) -> bool:
"""Connect to the scale"""
if not self.address:
logger.warning("No scale address configured")
return False
# Simulate connection delay
await asyncio.sleep(1)
self.connected = True
logger.info(f"Connected to mock scale at {self.address}")
return True
async def disconnect(self):
"""Disconnect from the scale"""
self.connected = False
logger.info("Disconnected from scale")
def is_connected(self) -> bool:
"""Check if scale is connected"""
return self.connected
async def tare(self):
"""Tare the scale"""
if not self.connected:
raise RuntimeError("Scale not connected")
self.current_weight = 0.0
self.is_tared = True
logger.info("Scale tared")
async def get_weight(self) -> ScaleReading:
"""Get current weight reading"""
if not self.connected:
raise RuntimeError("Scale not connected")
# Simulate weight fluctuation
import random
fluctuation = random.uniform(-0.1, 0.1)
# Simulate espresso extraction if shot in progress
if self._mock_shot_in_progress:
# Simulate gradual weight increase during extraction
self.current_weight = min(self.current_weight + random.uniform(0.1, 0.5), 50.0)
weight = max(0.0, self.current_weight + fluctuation)
return ScaleReading(
weight=weight,
timestamp=time.time(),
stable=abs(fluctuation) < 0.05
)
def start_monitoring(self, callback: Callable[[ScaleReading], None]):
"""Start monitoring weight changes"""
self._weight_callback = callback
logger.info("Started weight monitoring")
def stop_monitoring(self):
"""Stop monitoring weight changes"""
self._weight_callback = None
logger.info("Stopped weight monitoring")
def start_mock_shot(self):
"""Start simulating an espresso shot (for testing)"""
self._mock_shot_in_progress = True
logger.info("Started mock espresso shot simulation")
def stop_mock_shot(self):
"""Stop simulating an espresso shot"""
self._mock_shot_in_progress = False
logger.info("Stopped mock espresso shot simulation")
class ScaleManager:
"""Manages scale connection and operations"""
def __init__(self, scale_address: str = ""):
self.scale_address = scale_address
self.scale: Optional[MockAcaiaScale] = None
self._monitoring = False
async def initialize(self) -> bool:
"""Initialize and connect to scale"""
try:
# For now, use mock scale. In production, this would use pyacaia
self.scale = MockAcaiaScale(self.scale_address)
if await self.scale.connect():
logger.info("Scale initialized successfully")
return True
else:
logger.error("Failed to connect to scale")
return False
except Exception as e:
logger.error(f"Error initializing scale: {e}")
return False
async def tare_scale(self):
"""Tare the scale"""
if not self.scale or not self.scale.is_connected():
raise RuntimeError("Scale not available")
await self.scale.tare()
logger.info("Scale tared successfully")
async def get_current_weight(self) -> Optional[ScaleReading]:
"""Get current weight reading"""
if not self.scale or not self.scale.is_connected():
return None
try:
return await self.scale.get_weight()
except Exception as e:
logger.error(f"Error reading scale: {e}")
return None
async def wait_for_stable_weight(self, timeout: float = 5.0) -> Optional[float]:
"""Wait for weight to stabilize and return the stable weight"""
if not self.scale or not self.scale.is_connected():
return None
start_time = time.time()
stable_readings = 0
last_weight = 0.0
while time.time() - start_time < timeout:
reading = await self.get_current_weight()
if not reading:
continue
# Check if weight is stable (similar to previous reading)
if abs(reading.weight - last_weight) < 0.1:
stable_readings += 1
if stable_readings >= 3: # 3 consecutive stable readings
logger.info(f"Weight stabilized at {reading.weight}g")
return reading.weight
else:
stable_readings = 0
last_weight = reading.weight
await asyncio.sleep(0.2) # Check every 200ms
logger.warning("Weight did not stabilize within timeout")
return last_weight if last_weight > 0 else None
async def monitor_weight_during_shot(self,
target_weight: float,
timeout: float,
callback: Optional[Callable[[ScaleReading], None]] = None) -> tuple[bool, float, float]:
"""Monitor weight during shot extraction
Returns:
tuple: (target_reached, final_weight, duration)
"""
if not self.scale or not self.scale.is_connected():
raise RuntimeError("Scale not available")
start_time = time.time()
max_weight = 0.0
target_reached = False
# Start mock shot simulation for testing
if hasattr(self.scale, 'start_mock_shot'):
self.scale.start_mock_shot()
try:
while time.time() - start_time < timeout:
reading = await self.get_current_weight()
if not reading:
await asyncio.sleep(0.1)
continue
max_weight = max(max_weight, reading.weight)
# Call callback if provided
if callback:
callback(reading)
# Check if target weight reached
if reading.weight >= target_weight:
target_reached = True
logger.info(f"Target weight {target_weight}g reached at {reading.weight}g")
break
await asyncio.sleep(0.1) # Check every 100ms
duration = time.time() - start_time
# Stop mock shot
if hasattr(self.scale, 'stop_mock_shot'):
self.scale.stop_mock_shot()
if not target_reached:
logger.info(f"Shot timed out after {duration:.1f}s, max weight: {max_weight}g")
return target_reached, max_weight, duration
except Exception as e:
logger.error(f"Error during shot monitoring: {e}")
if hasattr(self.scale, 'stop_mock_shot'):
self.scale.stop_mock_shot()
return False, max_weight, time.time() - start_time
async def cleanup(self):
"""Clean up scale connection"""
if self.scale:
await self.scale.disconnect()
self.scale = None

View File

@ -0,0 +1,203 @@
from fastapi import FastAPI, Request, Depends, HTTPException, Form
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from typing import List, Optional
import logging
from pathlib import Path
from ..database import DatabaseManager
from ..models import Recipe, Button, Shot, Setting, RecipeCreate, ButtonCreate, ShotStatus
logger = logging.getLogger(__name__)
app = FastAPI(title="ECM Control", description="Espresso Coffee Machine Control System")
# Setup static files and templates
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# Database dependency
def get_db():
return DatabaseManager()
@app.get("/", response_class=HTMLResponse)
async def home(request: Request, db: DatabaseManager = Depends(get_db)):
"""Home page with dashboard"""
recipes = db.execute_query("SELECT * FROM recipes ORDER BY name")
buttons = db.execute_query("""
SELECT b.*, r.name as recipe_name
FROM buttons b
LEFT JOIN recipes r ON b.recipe_id = r.id
ORDER BY b.name
""")
recent_shots = db.execute_query("""
SELECT s.*, r.name as recipe_name, b.name as button_name
FROM shots s
JOIN recipes r ON s.recipe_id = r.id
JOIN buttons b ON s.button_id = b.id
ORDER BY s.start_time DESC
LIMIT 10
""")
return templates.TemplateResponse("index.html", {
"request": request,
"recipes": recipes,
"buttons": buttons,
"recent_shots": recent_shots
})
# Recipe routes
@app.get("/recipes", response_class=HTMLResponse)
async def list_recipes(request: Request, db: DatabaseManager = Depends(get_db)):
"""List all recipes"""
recipes = db.execute_query("SELECT * FROM recipes ORDER BY name")
return templates.TemplateResponse("recipes.html", {
"request": request,
"recipes": recipes
})
@app.post("/recipes", response_class=HTMLResponse)
async def create_recipe(
request: Request,
name: str = Form(...),
grams_out: float = Form(...),
timeout_seconds: int = Form(...),
db: DatabaseManager = Depends(get_db)
):
"""Create a new recipe"""
try:
recipe_id = db.execute_insert(
"INSERT INTO recipes (name, grams_out, timeout_seconds) VALUES (?, ?, ?)",
(name, grams_out, timeout_seconds)
)
recipes = db.execute_query("SELECT * FROM recipes ORDER BY name")
return templates.TemplateResponse("partials/recipe_list.html", {
"request": request,
"recipes": recipes
})
except Exception as e:
logger.error(f"Error creating recipe: {e}")
raise HTTPException(status_code=400, detail="Failed to create recipe")
@app.delete("/recipes/{recipe_id}")
async def delete_recipe(recipe_id: int, db: DatabaseManager = Depends(get_db)):
"""Delete a recipe"""
try:
db.execute_update("DELETE FROM recipes WHERE id = ?", (recipe_id,))
return {"status": "success"}
except Exception as e:
logger.error(f"Error deleting recipe: {e}")
raise HTTPException(status_code=400, detail="Failed to delete recipe")
# Button routes
@app.get("/buttons", response_class=HTMLResponse)
async def list_buttons(request: Request, db: DatabaseManager = Depends(get_db)):
"""List all buttons"""
buttons = db.execute_query("""
SELECT b.*, r.name as recipe_name
FROM buttons b
LEFT JOIN recipes r ON b.recipe_id = r.id
ORDER BY b.name
""")
recipes = db.execute_query("SELECT * FROM recipes ORDER BY name")
return templates.TemplateResponse("buttons.html", {
"request": request,
"buttons": buttons,
"recipes": recipes
})
@app.post("/buttons", response_class=HTMLResponse)
async def create_button(
request: Request,
name: str = Form(...),
gpio_pin: int = Form(...),
recipe_id: Optional[int] = Form(None),
db: DatabaseManager = Depends(get_db)
):
"""Create a new button"""
try:
button_id = db.execute_insert(
"INSERT INTO buttons (name, gpio_pin, recipe_id) VALUES (?, ?, ?)",
(name, gpio_pin, recipe_id if recipe_id else None)
)
buttons = db.execute_query("""
SELECT b.*, r.name as recipe_name
FROM buttons b
LEFT JOIN recipes r ON b.recipe_id = r.id
ORDER BY b.name
""")
recipes = db.execute_query("SELECT * FROM recipes ORDER BY name")
return templates.TemplateResponse("partials/button_list.html", {
"request": request,
"buttons": buttons,
"recipes": recipes
})
except Exception as e:
logger.error(f"Error creating button: {e}")
raise HTTPException(status_code=400, detail="Failed to create button")
@app.put("/buttons/{button_id}")
async def update_button(
button_id: int,
recipe_id: Optional[int] = Form(None),
db: DatabaseManager = Depends(get_db)
):
"""Update button recipe mapping"""
try:
db.execute_update(
"UPDATE buttons SET recipe_id = ? WHERE id = ?",
(recipe_id if recipe_id else None, button_id)
)
return {"status": "success"}
except Exception as e:
logger.error(f"Error updating button: {e}")
raise HTTPException(status_code=400, detail="Failed to update button")
# Shot history routes
@app.get("/shots", response_class=HTMLResponse)
async def list_shots(request: Request, db: DatabaseManager = Depends(get_db)):
"""List shot history"""
shots = db.execute_query("""
SELECT s.*, r.name as recipe_name, b.name as button_name
FROM shots s
JOIN recipes r ON s.recipe_id = r.id
JOIN buttons b ON s.button_id = b.id
ORDER BY s.start_time DESC
LIMIT 100
""")
return templates.TemplateResponse("shots.html", {
"request": request,
"shots": shots
})
# Settings routes
@app.get("/settings", response_class=HTMLResponse)
async def list_settings(request: Request, db: DatabaseManager = Depends(get_db)):
"""List all settings"""
settings = db.execute_query("SELECT * FROM settings ORDER BY key")
return templates.TemplateResponse("settings.html", {
"request": request,
"settings": settings
})
@app.put("/settings/{setting_id}")
async def update_setting(
setting_id: int,
value: str = Form(...),
db: DatabaseManager = Depends(get_db)
):
"""Update a setting"""
try:
db.execute_update(
"UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(value, setting_id)
)
return {"status": "success"}
except Exception as e:
logger.error(f"Error updating setting: {e}")
raise HTTPException(status_code=400, detail="Failed to update setting")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

116
static/css/style.css Normal file
View File

@ -0,0 +1,116 @@
body {
background-color: #f8f9fa;
}
.navbar-brand {
font-weight: bold;
font-size: 1.5rem;
}
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border: 1px solid rgba(0, 0, 0, 0.125);
}
.table {
background-color: white;
}
.badge {
font-size: 0.75em;
}
.btn-sm {
font-size: 0.875rem;
}
.form-select-sm {
font-size: 0.875rem;
}
.modal-header {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.text-muted {
color: #6c757d !important;
}
.small {
font-size: 0.875em;
}
/* Custom styles for dashboard cards */
.card-header h5 {
margin-bottom: 0;
font-weight: 600;
}
/* Button status indicators */
.bg-success {
background-color: #198754 !important;
}
.bg-warning {
background-color: #ffc107 !important;
color: #000 !important;
}
.bg-danger {
background-color: #dc3545 !important;
}
/* Table improvements */
.table-striped > tbody > tr:nth-of-type(odd) > td {
background-color: #f8f9fa;
}
.table th {
font-weight: 600;
border-top: none;
background-color: #e9ecef;
}
/* Loading indicator for HTMX requests */
.htmx-request {
opacity: 0.5;
transition: opacity 0.3s ease;
}
/* Responsive improvements */
@media (max-width: 768px) {
.table-responsive {
font-size: 0.875rem;
}
.btn {
font-size: 0.875rem;
}
}
/* Form enhancements */
.form-control:focus, .form-select:focus {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.form-label {
font-weight: 500;
}
/* Success/error notifications */
.alert {
border: none;
border-radius: 0.375rem;
}
.alert-success {
background-color: #d1edff;
color: #0a58ca;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
}

153
static/js/app.js Normal file
View File

@ -0,0 +1,153 @@
// ECM Control Frontend JavaScript
document.addEventListener('DOMContentLoaded', function() {
// Auto-hide Bootstrap modals after successful HTMX requests
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
// Find any open modals and hide them
const openModals = document.querySelectorAll('.modal.show');
openModals.forEach(modal => {
const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) {
modalInstance.hide();
}
});
// Show success notification
showNotification('Operation completed successfully', 'success');
} else {
// Show error notification
showNotification('Operation failed. Please try again.', 'error');
}
});
// Clear form fields when modal is hidden
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('hidden.bs.modal', function() {
const form = this.querySelector('form');
if (form) {
form.reset();
}
});
});
});
// Show notification function
function showNotification(message, type = 'info') {
const alertClass = type === 'success' ? 'alert-success' :
type === 'error' ? 'alert-danger' : 'alert-info';
const notification = document.createElement('div');
notification.className = `alert ${alertClass} alert-dismissible fade show position-fixed`;
notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}
// Confirm deletion dialogs
document.body.addEventListener('click', function(event) {
const deleteBtn = event.target.closest('[hx-delete]');
if (deleteBtn && deleteBtn.hasAttribute('hx-confirm')) {
const confirmMessage = deleteBtn.getAttribute('hx-confirm');
if (!confirm(confirmMessage)) {
event.preventDefault();
event.stopPropagation();
}
}
});
// Auto-refresh dashboard every 30 seconds
if (window.location.pathname === '/') {
setInterval(() => {
// Only refresh if page is visible
if (!document.hidden) {
window.location.reload();
}
}, 30000);
}
// Form validation helpers
function validateRecipeForm(form) {
const name = form.querySelector('[name="name"]').value.trim();
const gramsOut = parseFloat(form.querySelector('[name="grams_out"]').value);
const timeoutSeconds = parseInt(form.querySelector('[name="timeout_seconds"]').value);
if (!name) {
showNotification('Recipe name is required', 'error');
return false;
}
if (gramsOut <= 0 || gramsOut > 100) {
showNotification('Grams out must be between 0 and 100', 'error');
return false;
}
if (timeoutSeconds <= 0 || timeoutSeconds > 300) {
showNotification('Timeout must be between 1 and 300 seconds', 'error');
return false;
}
return true;
}
function validateButtonForm(form) {
const name = form.querySelector('[name="name"]').value.trim();
const gpioPin = parseInt(form.querySelector('[name="gpio_pin"]').value);
if (!name) {
showNotification('Button name is required', 'error');
return false;
}
if (gpioPin < 0 || gpioPin > 40) {
showNotification('GPIO pin must be between 0 and 40', 'error');
return false;
}
return true;
}
// Add form validation to modals
document.addEventListener('submit', function(event) {
const form = event.target;
if (form.matches('#addRecipeModal form')) {
if (!validateRecipeForm(form)) {
event.preventDefault();
}
}
if (form.matches('#addButtonModal form')) {
if (!validateButtonForm(form)) {
event.preventDefault();
}
}
});
// Keyboard shortcuts
document.addEventListener('keydown', function(event) {
// Ctrl/Cmd + N to add new recipe on recipes page
if ((event.ctrlKey || event.metaKey) && event.key === 'n' && window.location.pathname === '/recipes') {
event.preventDefault();
const modal = new bootstrap.Modal(document.getElementById('addRecipeModal'));
modal.show();
}
// Ctrl/Cmd + B to add new button on buttons page
if ((event.ctrlKey || event.metaKey) && event.key === 'b' && window.location.pathname === '/buttons') {
event.preventDefault();
const modal = new bootstrap.Modal(document.getElementById('addButtonModal'));
modal.show();
}
});

32
templates/base.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ECM Control{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">ECM Control</a>
<div class="navbar-nav">
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link" href="/recipes">Recipes</a>
<a class="nav-link" href="/buttons">Buttons</a>
<a class="nav-link" href="/shots">Shot History</a>
<a class="nav-link" href="/settings">Settings</a>
</div>
</div>
</nav>
<div class="container mt-4">
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

62
templates/buttons.html Normal file
View File

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Buttons - ECM Control{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="d-flex justify-content-between align-items-center">
<h1>Button Configuration</h1>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addButtonModal">
Add Button
</button>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div id="button-list">
{% include "partials/button_list.html" %}
</div>
</div>
</div>
<!-- Add Button Modal -->
<div class="modal fade" id="addButtonModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New Button</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form hx-post="/buttons" hx-target="#button-list" hx-on="htmx:afterRequest: htmx.closest(this, '.modal').style.display='none'">
<div class="modal-body">
<div class="mb-3">
<label for="name" class="form-label">Button Name</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="mb-3">
<label for="gpio_pin" class="form-label">GPIO Pin</label>
<input type="number" class="form-control" id="gpio_pin" name="gpio_pin"
min="0" max="40" required>
</div>
<div class="mb-3">
<label for="recipe_id" class="form-label">Recipe</label>
<select class="form-select" id="recipe_id" name="recipe_id">
<option value="">Select a recipe (optional)</option>
{% for recipe in recipes %}
<option value="{{ recipe.id }}">{{ recipe.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Add Button</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

68
templates/index.html Normal file
View File

@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Dashboard - ECM Control{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<h1>ECM Control Dashboard</h1>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5>Quick Stats</h5>
</div>
<div class="card-body">
<p><strong>Recipes:</strong> {{ recipes|length }}</p>
<p><strong>Buttons:</strong> {{ buttons|length }}</p>
<p><strong>Total Shots:</strong> {{ recent_shots|length }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5>Button Status</h5>
</div>
<div class="card-body">
{% for button in buttons %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ button.name }} (GPIO {{ button.gpio_pin }})</span>
<span class="badge bg-{{ 'success' if button.recipe_name else 'warning' }}">
{{ button.recipe_name or 'No Recipe' }}
</span>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5>Recent Shots</h5>
</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
{% for shot in recent_shots %}
<div class="mb-2">
<div><strong>{{ shot.button_name }}</strong> - {{ shot.recipe_name }}</div>
<div class="text-muted small">
{{ shot.start_time }} -
<span class="badge bg-{{ 'success' if shot.status == 'completed' else 'warning' }}">
{{ shot.status }}
</span>
</div>
{% if shot.actual_grams %}
<div class="text-muted small">{{ shot.actual_grams }}g in {{ shot.actual_duration_seconds }}s</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,39 @@
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>GPIO Pin</th>
<th>Recipe</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for button in buttons %}
<tr>
<td>{{ button.name }}</td>
<td>GPIO {{ button.gpio_pin }}</td>
<td>
<select class="form-select form-select-sm"
hx-put="/buttons/{{ button.id }}"
hx-trigger="change"
name="recipe_id">
<option value="">No Recipe</option>
{% for recipe in recipes %}
<option value="{{ recipe.id }}"
{% if button.recipe_id == recipe.id %}selected{% endif %}>
{{ recipe.name }}
</option>
{% endfor %}
</select>
</td>
<td>
<span class="badge bg-{{ 'success' if button.recipe_name else 'warning' }}">
{{ 'Configured' if button.recipe_name else 'Not Configured' }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View File

@ -0,0 +1,31 @@
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Target Grams</th>
<th>Timeout (s)</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for recipe in recipes %}
<tr>
<td>{{ recipe.name }}</td>
<td>{{ recipe.grams_out }}g</td>
<td>{{ recipe.timeout_seconds }}s</td>
<td>{{ recipe.created_at }}</td>
<td>
<button class="btn btn-sm btn-danger"
hx-delete="/recipes/{{ recipe.id }}"
hx-target="#recipe-list"
hx-confirm="Are you sure you want to delete this recipe?">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

58
templates/recipes.html Normal file
View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Recipes - ECM Control{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="d-flex justify-content-between align-items-center">
<h1>Recipes</h1>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addRecipeModal">
Add Recipe
</button>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div id="recipe-list">
{% include "partials/recipe_list.html" %}
</div>
</div>
</div>
<!-- Add Recipe Modal -->
<div class="modal fade" id="addRecipeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New Recipe</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form hx-post="/recipes" hx-target="#recipe-list" hx-on="htmx:afterRequest: htmx.closest(this, '.modal').style.display='none'">
<div class="modal-body">
<div class="mb-3">
<label for="name" class="form-label">Recipe Name</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="mb-3">
<label for="grams_out" class="form-label">Target Grams Out</label>
<input type="number" class="form-control" id="grams_out" name="grams_out"
min="1" max="100" step="0.1" required>
</div>
<div class="mb-3">
<label for="timeout_seconds" class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" id="timeout_seconds" name="timeout_seconds"
min="1" max="300" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Add Recipe</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

53
templates/settings.html Normal file
View File

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Settings - ECM Control{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<h1>Settings</h1>
</div>
</div>
<div class="row mt-4">
<div class="col-md-8">
<div class="card">
<div class="card-body">
{% for setting in settings %}
<div class="mb-3">
<label for="setting_{{ setting.id }}" class="form-label">
<strong>{{ setting.key.replace('_', ' ').title() }}</strong>
</label>
{% if setting.description %}
<div class="form-text">{{ setting.description }}</div>
{% endif %}
<input type="text"
class="form-control"
id="setting_{{ setting.id }}"
value="{{ setting.value }}"
hx-put="/settings/{{ setting.id }}"
hx-trigger="change"
name="value">
</div>
{% endfor %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h6>Setting Descriptions</h6>
</div>
<div class="card-body">
<small>
<p><strong>Scale Address:</strong> Bluetooth MAC address of your Acaia scale</p>
<p><strong>Shot Completion Threshold:</strong> Weight change rate (g/s) to detect shot completion</p>
<p><strong>Weight Stabilize Time:</strong> Seconds to wait for scale stabilization</p>
<p><strong>Log Level:</strong> Logging verbosity (DEBUG, INFO, WARNING, ERROR)</p>
</small>
</div>
</div>
</div>
</div>
{% endblock %}

50
templates/shots.html Normal file
View File

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Shot History - ECM Control{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<h1>Shot History</h1>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Date/Time</th>
<th>Button</th>
<th>Recipe</th>
<th>Target</th>
<th>Actual</th>
<th>Duration</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{% for shot in shots %}
<tr>
<td>{{ shot.start_time }}</td>
<td>{{ shot.button_name }}</td>
<td>{{ shot.recipe_name }}</td>
<td>{{ shot.target_grams }}g</td>
<td>{{ shot.actual_grams or '-' }}g</td>
<td>{{ shot.actual_duration_seconds or '-' }}s</td>
<td>
<span class="badge bg-{{ 'success' if shot.status == 'completed' else 'warning' if shot.status == 'cancelled' else 'danger' }}">
{{ shot.status }}
</span>
</td>
<td>{{ shot.notes or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

383
uv.lock generated Normal file
View File

@ -0,0 +1,383 @@
version = 1
revision = 1
requires-python = ">=3.10"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
name = "anyio"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "colorzero"
version = "2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/688824a06e8c4d04c7d2fd2af2d8da27bed51af20ee5f094154e1d680334/colorzero-2.0.tar.gz", hash = "sha256:e7d5a5c26cd0dc37b164ebefc609f388de24f8593b659191e12d85f8f9d5eb58", size = 25382 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/a6/ddd0f130e44a7593ac6c55aa93f6e256d2270fd88e9d1b64ab7f22ab8fde/colorzero-2.0-py2.py3-none-any.whl", hash = "sha256:0e60d743a6b8071498a56465f7719c96a5e92928f858bab1be2a0d606c9aa0f8", size = 26573 },
]
[[package]]
name = "ecm-control"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "gpiozero" },
{ name = "jinja2" },
{ name = "python-multipart" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115.13" },
{ name = "gpiozero", specifier = ">=2.0.1" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "uvicorn", specifier = ">=0.34.3" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 },
]
[[package]]
name = "fastapi"
version = "0.115.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/64/ec0788201b5554e2a87c49af26b77a4d132f807a0fa9675257ac92c6aa0e/fastapi-0.115.13.tar.gz", hash = "sha256:55d1d25c2e1e0a0a50aceb1c8705cd932def273c102bff0b1c1da88b3c6eb307", size = 295680 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/4a/e17764385382062b0edbb35a26b7cf76d71e27e456546277a42ba6545c6e/fastapi-0.115.13-py3-none-any.whl", hash = "sha256:0a0cab59afa7bab22f5eb347f8c9864b681558c278395e94035a741fc10cd865", size = 95315 },
]
[[package]]
name = "gpiozero"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorzero" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/334b8db8a981eca9a0fb1e7e48e1997a5eaa8f40bb31c504299dcca0e6ff/gpiozero-2.0.1.tar.gz", hash = "sha256:d4ea1952689ec7e331f9d4ebc9adb15f1d01c2c9dcfabb72e752c9869ab7e97e", size = 136176 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/eb/6518a1b00488d48995034226846653c382d676cf5f04be62b3c3fae2c6a1/gpiozero-2.0.1-py3-none-any.whl", hash = "sha256:8f621de357171d574c0b7ea0e358cb66e560818a47b0eeedf41ce1cdbd20c70b", size = 150818 },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 },
{ url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 },
{ url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 },
{ url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 },
{ url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 },
{ url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 },
{ url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 },
{ url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 },
{ url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 },
{ url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 },
{ url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 },
{ url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 },
{ url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 },
{ url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 },
{ url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 },
{ url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 },
{ url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 },
{ url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 },
{ url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 },
{ url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 },
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 },
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 },
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 },
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 },
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 },
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 },
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 },
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 },
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 },
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
]
[[package]]
name = "pydantic"
version = "2.11.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782 },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817 },
{ url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357 },
{ url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011 },
{ url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730 },
{ url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178 },
{ url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462 },
{ url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652 },
{ url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306 },
{ url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720 },
{ url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915 },
{ url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884 },
{ url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496 },
{ url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019 },
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 },
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 },
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 },
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 },
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 },
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 },
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 },
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 },
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 },
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 },
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 },
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 },
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 },
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 },
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 },
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 },
{ url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982 },
{ url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412 },
{ url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749 },
{ url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527 },
{ url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225 },
{ url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490 },
{ url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525 },
{ url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446 },
{ url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678 },
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 },
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 },
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 },
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 },
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 },
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 },
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 },
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 },
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 },
]
[[package]]
name = "setuptools"
version = "80.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
[[package]]
name = "starlette"
version = "0.46.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 },
]
[[package]]
name = "typing-extensions"
version = "4.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552 },
]
[[package]]
name = "uvicorn"
version = "0.34.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431 },
]