From 388ad4f5a027cfc30e27aed2aaa486426fb4dced Mon Sep 17 00:00:00 2001 From: Marc Boivin Date: Wed, 18 Jun 2025 18:36:52 -0400 Subject: [PATCH] Initial commit: Complete ECM Control system with web interface, GPIO control, and scale integration --- .gitignore | 10 + .python-version | 1 + README.md | Bin 0 -> 7432 bytes SPECS.md | 43 +++ ecm-control.service | 30 ++ main.py | 6 + pyproject.toml | 13 + src/ecm_control/__init__.py | 24 ++ src/ecm_control/__main__.py | 156 ++++++++ src/ecm_control/database/__init__.py | 52 +++ src/ecm_control/database/schema.sql | 59 +++ src/ecm_control/gpio/__init__.py | 475 ++++++++++++++++++++++++ src/ecm_control/models/__init__.py | 63 ++++ src/ecm_control/utils/__init__.py | 1 + src/ecm_control/utils/logging_config.py | 193 ++++++++++ src/ecm_control/utils/scale.py | 233 ++++++++++++ src/ecm_control/web/__init__.py | 203 ++++++++++ static/css/style.css | 116 ++++++ static/js/app.js | 153 ++++++++ templates/base.html | 32 ++ templates/buttons.html | 62 ++++ templates/index.html | 68 ++++ templates/partials/button_list.html | 39 ++ templates/partials/recipe_list.html | 31 ++ templates/recipes.html | 58 +++ templates/settings.html | 53 +++ templates/shots.html | 50 +++ uv.lock | 383 +++++++++++++++++++ 28 files changed, 2607 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 SPECS.md create mode 100644 ecm-control.service create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 src/ecm_control/__init__.py create mode 100644 src/ecm_control/__main__.py create mode 100644 src/ecm_control/database/__init__.py create mode 100644 src/ecm_control/database/schema.sql create mode 100644 src/ecm_control/gpio/__init__.py create mode 100644 src/ecm_control/models/__init__.py create mode 100644 src/ecm_control/utils/__init__.py create mode 100644 src/ecm_control/utils/logging_config.py create mode 100644 src/ecm_control/utils/scale.py create mode 100644 src/ecm_control/web/__init__.py create mode 100644 static/css/style.css create mode 100644 static/js/app.js create mode 100644 templates/base.html create mode 100644 templates/buttons.html create mode 100644 templates/index.html create mode 100644 templates/partials/button_list.html create mode 100644 templates/partials/recipe_list.html create mode 100644 templates/recipes.html create mode 100644 templates/settings.html create mode 100644 templates/shots.html create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d211078ce2c5dc5eca270d2a8e5b6f3a909e3cc1 GIT binary patch literal 7432 zcma)BZExeo5f1v1pZl>N2Jiv3T}$%41WnzhfKOKLT==b$eMu1n_KI9d%u!s1yR=V9 z^C$X?`^@YvN!d8#k`G6dyR)3YYn<7b$DK+b=)+=4w{9cpYSf|&t zFf~5Nnj$Zkq%GE1Yw&Wh(3;jNUFD^=^pLw1y}LTSq*@nhV-Luts?d~|PA_Zaa#J3V zDl>Y}^TAwMonc{`S1h7bOTE^m8z#xl4t=MUYj8-C3}}Ddm8qRbJ5CjLh&?Rv=C?(ooiVW3uGnpD(vOs{t3u4s-6GTXx-b>nW>1cqdXt^E zCgY>VH7usZxZakeKe<|^Z1Z5`;2)>zrFL*ZzFaxk;PSOLjT7N-8|Sd&9JYk3#n!5# zYPGSjY(aDRY{?n274%r-X_W5GuriL$%yJnvkGJBf@E^!5NEJn2L*)=?l?y6n+Nr!i zbgZ?j^VEiGt75>a!uw_JOdXCm<49e%uUL3@^&n6+&a7e5l&mvyGq0`0cP6pbbK6yI zvPLX7fK{^%SzX`fspceDK=Yz<@WroW@h0ySNOb*TOm+h4__Ys1%KXrph~45 zk;EaFYrLHO`z%LLz7|uRme#@R64+ka%OQ;mQ~Ic#Y1dRqa(8zJU|1!~+)*kY|55A8 zSa$ek(A330lQbipt_QfEpcZy`aj%AOq?|z~y3%E)%QR;Qi86QE@6a}1P>EPog?BMb z!`kUANpT1b&g^VKE9WYEG#V+3R4RscHEQlhVI}^e{}MD!I*YrgGmxGiOKplR-+G`g`cxtGf*dE7x@ zlFX3mj@(K!-bxVb`sfSK;T?m)%2-E-L-`+n3YdL!czEcKKTcDWf%X7Kc|mAWj_%lY zFkN8)8!YCC_jwEK5Ubh(dJFbP2$}u;6S#kF0LlzLm-n1{`hl}LR3rGwHCBKN01s4z z6O4-(!PUfefOtSU%bhPenJzS*%K<_GQ3t}t(;VJ5c&DuFY*`t$v;Z#}1mG%HO#=k35WO(KR8j-Z%;t(LGU)X5oA*&w* z@;$~;fV5sXpy&ddw!lj25^6{TLocVtPeuQ8V=h7GskW$zaU1P^e>}Z7y?A$oi*V2&+)%Ih^WO6| zfJit>clkMfTrzgZQ-wz#fQ6q+rv$+E&T#AjBPCQ4KnR{C^X?>4ZbPKp@v@C+>ORW< z+4jiB`SF;-;^YkgxQ%r%5nThsYlW(-Yh2kWHs)M3Hne*(d3*EjfKD&IyF8%a*8`eNrV&mzK_O*m0R=isMNNtj6p_ z)C|uQm~kX9xSn9oE82xb&!Cu74CxxRNm>QQ@TPzPQL8|tgzDIQ-GDA=3G?k%0}(y1 zfw7*BlC&de9MCD`Obd_p_fbm1ng0ul&R~62;QiMhD8BnJ9`nMBllBds@y1{6;&L>; zJjbW;R-Y43P4hZ!P>RJET?_?lz?IP)Yr|ewAb?(9D6idv)@2(UzML&<)0Ei|HQhHO zHT0#bDT+Z;F|0@(v2xGJC&g(fsqrR?_16xXybR><LjUq$>}PfyISfN6Jiu zgiCP+$OPUL1UypFtC2dc2pXLSx`e{0ED`;?kDk`|Z}WJCKj;aWWmsNmu<@ur)`9YT zRtx`m>!6lwGSiyD1%US5%fR&kdt9TzRzsU3{1BuyIPid54x6LJrp9vR=6r?(geCTT z+E9^y)KsF!`{en9JSMCyw*1CGf+ZLol=RKv-{nvL?Y0E^i2Vl{`mwwmq9*tSeiO)| zG_0c_g(b<6w-w-1`1lh}>GyXlQGIV$XxpI5d#0~1n0-F8=!d~nqq97{q8I4kjMGv} z{f6I@-y{7%fVX|PkMMJDn0ON5&Dol(RzYTvd0|`9A zt?2r0G_VSDisuOkldwpfz-z8SKFJ3n;%5g{T(p)WA{gzas$}3rtG!%m245aea0AdX z`n_(ehwEJwU4WM0KBpU;tukh~h#c<9WKI+{?(O!pb~i#hN|Wae+=MX+-izi2+tc%A z84G%ng#66pZKS`<3|gLza~qTuR1(81sb|?|SzxdjL=J>SkPrs1hnO#ga!di+gXHdj zm^*$LiAF;_-U-9yCPvJQa6xYZN&{W+&a=L7&mhYR^v<|@s8-6S*AhT)xtd~&A1-iu zV+tV5Di8$Vt~8o6S7T6tL77BR(nFF_@~5x9B3q}UUiUak{(wB;54X46Mc&>H0m$ut z!1K_;^GE4VJwA53IBB2k8lRma#vJNfWe_s$A(2jx+7s~5qwzLf>9rEb?dE^&ZqAOv zZtdmWSDQ?gJjr3{%rLnBi3%cUA8n8mze59me03@@-5V0N+c*TC?A!5f3;``##(?e; zLqz-$25nsaL|JHigV!)6qK7kvr($s?+TD0hT*dP*W;>5>3?UeEo>BYVI~FmKr%!Zd z1C3|1(Z6O)H#L|hJ6poE?}PBX!`Ve#9R0dp__TGGmjal=pe&*i?-9|}2Jp5XTHe5)N>-&hkMj2 z3%Rpv*?`fH1R@YG5LKB9AfxWyO|EGaXz?A;@C;>2db{wB*IZpfPJdBg(Nb;AuQ~1L!azkg8;o-8iaS92E9c+4g6bKbptiSqW1Veh5YNF zE_jchd5Sg+H#~XpngA`GZS~;4D9kGYjf*X3yCJ-2_i$&7Q1gu`n~&9sFCOa^-qXFp z^qWth>uuEbrwV{x>3W?D76SE77t9-oC)e_^YCjT2tEOgOZ}CV}*&66T14V;(Bq0Cd zW;U6kl5y0d;gOV{1M!f~UD;{7^T6Zj)Y^upi0HvS=sI6)dd+lOTr*D`{jVJ;6}e3f z$PkZCxqT0GPCoYw9!RCzdYK3+4omKUqYzy;Y(U z{rvg@@`Ph>Novgn$k<|=EX_03pD{>455i1$(DeBw4Hl3b_Hhvo(_m)b1fQ|N9ULF_#L6Rb#6Ne z6Q9;yF&LiAb};x&OcY<^g{48E_-=7ZJIv-$GymgSj(ilG zBZxq~KT3Df&9x;&gk^wgFZ@qmXmC}TuDF{&SD@RW{`)B!kJQBfA>A{RHqv8CAO1C_ zL(O~*dvo&$5cmcDkhTnXhlwc}oxqCW4y^iaFYZgEB@!PZ0 z@#JDQk+LB%=V=kQrO4*v^kZorFh*bcK?iUK-K?b_tBv9xz<%0|Gj$C3SIwLofKkz; z3KJ4t6lN6G?TKj57MX>HUuPihZIc)H^@@LKjAfk%qWn{!>|q_ZxGVMuV#Gqu=0.115.13", + "gpiozero>=2.0.1", + "jinja2>=3.1.6", + "python-multipart>=0.0.20", + "uvicorn>=0.34.3", +] diff --git a/src/ecm_control/__init__.py b/src/ecm_control/__init__.py new file mode 100644 index 0000000..92d3ea5 --- /dev/null +++ b/src/ecm_control/__init__.py @@ -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' +] \ No newline at end of file diff --git a/src/ecm_control/__main__.py b/src/ecm_control/__main__.py new file mode 100644 index 0000000..53e016e --- /dev/null +++ b/src/ecm_control/__main__.py @@ -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()) \ No newline at end of file diff --git a/src/ecm_control/database/__init__.py b/src/ecm_control/database/__init__.py new file mode 100644 index 0000000..d9392ca --- /dev/null +++ b/src/ecm_control/database/__init__.py @@ -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 \ No newline at end of file diff --git a/src/ecm_control/database/schema.sql b/src/ecm_control/database/schema.sql new file mode 100644 index 0000000..eb8d1d4 --- /dev/null +++ b/src/ecm_control/database/schema.sql @@ -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); \ No newline at end of file diff --git a/src/ecm_control/gpio/__init__.py b/src/ecm_control/gpio/__init__.py new file mode 100644 index 0000000..9fcb294 --- /dev/null +++ b/src/ecm_control/gpio/__init__.py @@ -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()) \ No newline at end of file diff --git a/src/ecm_control/models/__init__.py b/src/ecm_control/models/__init__.py new file mode 100644 index 0000000..537149c --- /dev/null +++ b/src/ecm_control/models/__init__.py @@ -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 \ No newline at end of file diff --git a/src/ecm_control/utils/__init__.py b/src/ecm_control/utils/__init__.py new file mode 100644 index 0000000..cb8f501 --- /dev/null +++ b/src/ecm_control/utils/__init__.py @@ -0,0 +1 @@ +# ECM Control Utilities \ No newline at end of file diff --git a/src/ecm_control/utils/logging_config.py b/src/ecm_control/utils/logging_config.py new file mode 100644 index 0000000..116c564 --- /dev/null +++ b/src/ecm_control/utils/logging_config.py @@ -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") \ No newline at end of file diff --git a/src/ecm_control/utils/scale.py b/src/ecm_control/utils/scale.py new file mode 100644 index 0000000..6368f0c --- /dev/null +++ b/src/ecm_control/utils/scale.py @@ -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 \ No newline at end of file diff --git a/src/ecm_control/web/__init__.py b/src/ecm_control/web/__init__.py new file mode 100644 index 0000000..a96623d --- /dev/null +++ b/src/ecm_control/web/__init__.py @@ -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) \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..fb922bb --- /dev/null +++ b/static/css/style.css @@ -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; +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..911e982 --- /dev/null +++ b/static/js/app.js @@ -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} + + `; + + 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(); + } +}); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..dc69ccd --- /dev/null +++ b/templates/base.html @@ -0,0 +1,32 @@ + + + + + + {% block title %}ECM Control{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + \ No newline at end of file diff --git a/templates/buttons.html b/templates/buttons.html new file mode 100644 index 0000000..e0c097e --- /dev/null +++ b/templates/buttons.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} + +{% block title %}Buttons - ECM Control{% endblock %} + +{% block content %} +
+
+
+

Button Configuration

+ +
+
+
+ +
+
+
+ {% include "partials/button_list.html" %} +
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..5ad1a7e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - ECM Control{% endblock %} + +{% block content %} +
+
+

ECM Control Dashboard

+
+
+ +
+
+
+
+
Quick Stats
+
+
+

Recipes: {{ recipes|length }}

+

Buttons: {{ buttons|length }}

+

Total Shots: {{ recent_shots|length }}

+
+
+
+ +
+
+
+
Button Status
+
+
+ {% for button in buttons %} +
+ {{ button.name }} (GPIO {{ button.gpio_pin }}) + + {{ button.recipe_name or 'No Recipe' }} + +
+ {% endfor %} +
+
+
+ +
+
+
+
Recent Shots
+
+
+ {% for shot in recent_shots %} +
+
{{ shot.button_name }} - {{ shot.recipe_name }}
+
+ {{ shot.start_time }} - + + {{ shot.status }} + +
+ {% if shot.actual_grams %} +
{{ shot.actual_grams }}g in {{ shot.actual_duration_seconds }}s
+ {% endif %} +
+ {% endfor %} +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/partials/button_list.html b/templates/partials/button_list.html new file mode 100644 index 0000000..690d0d3 --- /dev/null +++ b/templates/partials/button_list.html @@ -0,0 +1,39 @@ +
+ + + + + + + + + + + {% for button in buttons %} + + + + + + + {% endfor %} + +
NameGPIO PinRecipeActions
{{ button.name }}GPIO {{ button.gpio_pin }} + + + + {{ 'Configured' if button.recipe_name else 'Not Configured' }} + +
+
\ No newline at end of file diff --git a/templates/partials/recipe_list.html b/templates/partials/recipe_list.html new file mode 100644 index 0000000..00de579 --- /dev/null +++ b/templates/partials/recipe_list.html @@ -0,0 +1,31 @@ +
+ + + + + + + + + + + + {% for recipe in recipes %} + + + + + + + + {% endfor %} + +
NameTarget GramsTimeout (s)CreatedActions
{{ recipe.name }}{{ recipe.grams_out }}g{{ recipe.timeout_seconds }}s{{ recipe.created_at }} + +
+
\ No newline at end of file diff --git a/templates/recipes.html b/templates/recipes.html new file mode 100644 index 0000000..a4e4650 --- /dev/null +++ b/templates/recipes.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}Recipes - ECM Control{% endblock %} + +{% block content %} +
+
+
+

Recipes

+ +
+
+
+ +
+
+
+ {% include "partials/recipe_list.html" %} +
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..85edcdd --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} + +{% block title %}Settings - ECM Control{% endblock %} + +{% block content %} +
+
+

Settings

+
+
+ +
+
+
+
+ {% for setting in settings %} +
+ + {% if setting.description %} +
{{ setting.description }}
+ {% endif %} + +
+ {% endfor %} +
+
+
+ +
+
+
+
Setting Descriptions
+
+
+ +

Scale Address: Bluetooth MAC address of your Acaia scale

+

Shot Completion Threshold: Weight change rate (g/s) to detect shot completion

+

Weight Stabilize Time: Seconds to wait for scale stabilization

+

Log Level: Logging verbosity (DEBUG, INFO, WARNING, ERROR)

+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/shots.html b/templates/shots.html new file mode 100644 index 0000000..1d48c5a --- /dev/null +++ b/templates/shots.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block title %}Shot History - ECM Control{% endblock %} + +{% block content %} +
+
+

Shot History

+
+
+ +
+
+
+ + + + + + + + + + + + + + + {% for shot in shots %} + + + + + + + + + + + {% endfor %} + +
Date/TimeButtonRecipeTargetActualDurationStatusNotes
{{ shot.start_time }}{{ shot.button_name }}{{ shot.recipe_name }}{{ shot.target_grams }}g{{ shot.actual_grams or '-' }}g{{ shot.actual_duration_seconds or '-' }}s + + {{ shot.status }} + + {{ shot.notes or '-' }}
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a197f3d --- /dev/null +++ b/uv.lock @@ -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 }, +]