Initial commit: Complete ECM Control system with web interface, GPIO control, and scale integration
This commit is contained in:
commit
388ad4f5a0
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.10
|
||||
43
SPECS.md
Normal file
43
SPECS.md
Normal 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
30
ecm-control.service
Normal 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
6
main.py
Normal file
@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from ecm-control!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
pyproject.toml
Normal file
13
pyproject.toml
Normal 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",
|
||||
]
|
||||
24
src/ecm_control/__init__.py
Normal file
24
src/ecm_control/__init__.py
Normal 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
156
src/ecm_control/__main__.py
Normal 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())
|
||||
52
src/ecm_control/database/__init__.py
Normal file
52
src/ecm_control/database/__init__.py
Normal 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
|
||||
59
src/ecm_control/database/schema.sql
Normal file
59
src/ecm_control/database/schema.sql
Normal 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);
|
||||
475
src/ecm_control/gpio/__init__.py
Normal file
475
src/ecm_control/gpio/__init__.py
Normal 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())
|
||||
63
src/ecm_control/models/__init__.py
Normal file
63
src/ecm_control/models/__init__.py
Normal 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
|
||||
1
src/ecm_control/utils/__init__.py
Normal file
1
src/ecm_control/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# ECM Control Utilities
|
||||
193
src/ecm_control/utils/logging_config.py
Normal file
193
src/ecm_control/utils/logging_config.py
Normal 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")
|
||||
233
src/ecm_control/utils/scale.py
Normal file
233
src/ecm_control/utils/scale.py
Normal 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
|
||||
203
src/ecm_control/web/__init__.py
Normal file
203
src/ecm_control/web/__init__.py
Normal 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
116
static/css/style.css
Normal 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
153
static/js/app.js
Normal 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
32
templates/base.html
Normal 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
62
templates/buttons.html
Normal 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
68
templates/index.html
Normal 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 %}
|
||||
39
templates/partials/button_list.html
Normal file
39
templates/partials/button_list.html
Normal 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>
|
||||
31
templates/partials/recipe_list.html
Normal file
31
templates/partials/recipe_list.html
Normal 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
58
templates/recipes.html
Normal 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
53
templates/settings.html
Normal 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
50
templates/shots.html
Normal 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
383
uv.lock
generated
Normal 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 },
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user