From 409e6152ae18407f7f0856332a7786d59be87d78 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Fri, 12 May 2023 14:07:58 -0700 Subject: [PATCH 1/7] Add basic logic for creating a module using flit. --- .gitignore | 2 ++ pnpdevice/__init__.py | 2 ++ pnpdevice/main.py | 2 ++ pyproject.toml | 24 ++++++++++++++++++++++++ 4 files changed, 30 insertions(+) create mode 100644 pnpdevice/__init__.py create mode 100644 pnpdevice/main.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index 5d381cc..64b39f0 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,5 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# Virtual env +ve/ diff --git a/pnpdevice/__init__.py b/pnpdevice/__init__.py new file mode 100644 index 0000000..7b192e9 --- /dev/null +++ b/pnpdevice/__init__.py @@ -0,0 +1,2 @@ +"Software for controlling a pool and pump device." +__version__ = "0.1" diff --git a/pnpdevice/main.py b/pnpdevice/main.py new file mode 100644 index 0000000..bc1f03a --- /dev/null +++ b/pnpdevice/main.py @@ -0,0 +1,2 @@ +def main(): + print("Hello, world") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..79e4d59 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "pnpdevice" +authors = [ + { name = "Eli Ribble", email = "eli@theribbles.org"} +] +dependencies = [ + "zeroconf" +] +dynamic = ["version", "description"] + +[project.scripts] +pnpdevice = "pnpdevice:main.main" + +[project.urls] +Home = "https://source.theribbles.org/eliribble/pnpdevice" + +[project.optional-dependencies] +dev = [ + "pre-commit", +] + +[build-system] +build-backend = "flit_core.buildapi" +requires = ["flit_core >=3.2,<4"] From bc79e842e73cd6b719f404a6d14ffae3c66b58f2 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Fri, 12 May 2023 14:13:27 -0700 Subject: [PATCH 2/7] Create a basic async loop. --- pnpdevice/main.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/pnpdevice/main.py b/pnpdevice/main.py index bc1f03a..bdab544 100644 --- a/pnpdevice/main.py +++ b/pnpdevice/main.py @@ -1,2 +1,25 @@ +import argparse +import asyncio +import logging + + +LOGGER = logging.getLogger(__name__) + +async def run(): + while True: + await asyncio.sleep(1) + LOGGER.info("Tick.") + def main(): - print("Hello, world") + parser = argparse.ArgumentParser() + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging") + args = parser.parse_args() + + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) + + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(run()) + except KeyboardInterrupt: + LOGGER.info("Shutting down") + From 9f3de33337b4b5769933ea7ec5952d80c43aa6ac Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Fri, 12 May 2023 14:31:45 -0700 Subject: [PATCH 3/7] Add coroutine for handling mDNS query responses. We can now discover our device. Maybe. I'm really not sure if I'm doing the protocol correctly, but it looks pretty good. *shrug*. --- pnpdevice/discovery.py | 37 +++++++++++++++++++++++++++++++++++++ pnpdevice/main.py | 2 ++ 2 files changed, 39 insertions(+) create mode 100644 pnpdevice/discovery.py diff --git a/pnpdevice/discovery.py b/pnpdevice/discovery.py new file mode 100644 index 0000000..a17f906 --- /dev/null +++ b/pnpdevice/discovery.py @@ -0,0 +1,37 @@ +import logging +import socket + +from zeroconf import IPVersion +from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf + +LOGGER = logging.getLogger(__name__) + +# From https://stackoverflow.com/questions/166506/finding-local-ip-addresses-using-pythons-stdlib/ +def get_ip() -> str: + "Get the primary IP" + LOGGER.info("Determining IP address") + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0) + try: + # doesn't even have to be reachable + s.connect(('10.254.254.254', 1)) + ip = s.getsockname()[0] + except Exception: + ip = '127.0.0.1' + finally: + s.close() + LOGGER.info("IP address seems to be %s", ip) + return ip + +async def handle(): + "Handle requests for discovery" + ip = get_ip() + info = AsyncServiceInfo( + "_http._tcp.local.", + "pnpdevice._http._tcp.local.", + addresses=[socket.inet_aton("127.0.0.1")], + port=13344, + server=ip, + ) + aiozc = AsyncZeroconf(ip_version=IPVersion.V4Only) + await aiozc.async_register_service(info) diff --git a/pnpdevice/main.py b/pnpdevice/main.py index bdab544..68df5b0 100644 --- a/pnpdevice/main.py +++ b/pnpdevice/main.py @@ -2,10 +2,12 @@ import argparse import asyncio import logging +import pnpdevice.discovery LOGGER = logging.getLogger(__name__) async def run(): + asyncio.ensure_future(pnpdevice.discovery.handle()) while True: await asyncio.sleep(1) LOGGER.info("Tick.") From 0d1020a3d5a74006bf1f3b8879f61ca286f2eb10 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Fri, 12 May 2023 14:47:42 -0700 Subject: [PATCH 4/7] Add aiohttp web server. We switch to having the server kick off the mDNS discovery process because aiohttp recommends it and it fits well enough that I don't mind. --- pnpdevice/discovery.py | 4 ++-- pnpdevice/main.py | 14 ++------------ pnpdevice/server.py | 14 ++++++++++++++ pyproject.toml | 3 ++- 4 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 pnpdevice/server.py diff --git a/pnpdevice/discovery.py b/pnpdevice/discovery.py index a17f906..2792724 100644 --- a/pnpdevice/discovery.py +++ b/pnpdevice/discovery.py @@ -23,14 +23,14 @@ def get_ip() -> str: LOGGER.info("IP address seems to be %s", ip) return ip -async def handle(): +async def handle(*args): "Handle requests for discovery" ip = get_ip() info = AsyncServiceInfo( "_http._tcp.local.", "pnpdevice._http._tcp.local.", addresses=[socket.inet_aton("127.0.0.1")], - port=13344, + port=80, server=ip, ) aiozc = AsyncZeroconf(ip_version=IPVersion.V4Only) diff --git a/pnpdevice/main.py b/pnpdevice/main.py index 68df5b0..ecc00d3 100644 --- a/pnpdevice/main.py +++ b/pnpdevice/main.py @@ -3,15 +3,10 @@ import asyncio import logging import pnpdevice.discovery +import pnpdevice.server LOGGER = logging.getLogger(__name__) -async def run(): - asyncio.ensure_future(pnpdevice.discovery.handle()) - while True: - await asyncio.sleep(1) - LOGGER.info("Tick.") - def main(): parser = argparse.ArgumentParser() parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging") @@ -19,9 +14,4 @@ def main(): logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) - loop = asyncio.get_event_loop() - try: - loop.run_until_complete(run()) - except KeyboardInterrupt: - LOGGER.info("Shutting down") - + pnpdevice.server.run() diff --git a/pnpdevice/server.py b/pnpdevice/server.py new file mode 100644 index 0000000..b4c42b9 --- /dev/null +++ b/pnpdevice/server.py @@ -0,0 +1,14 @@ +from aiohttp import web + +import pnpdevice.discovery + +async def hello(request): + return web.Response(text="Hello, world") + +def run(): + "Run the embedded web server" + app = web.Application() + app.on_startup.append(pnpdevice.discovery.handle) + app.add_routes([web.get("/", hello)]) + web.run_app(app) + diff --git a/pyproject.toml b/pyproject.toml index 79e4d59..31a23db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,8 @@ authors = [ { name = "Eli Ribble", email = "eli@theribbles.org"} ] dependencies = [ - "zeroconf" + "aiohttp", + "zeroconf", ] dynamic = ["version", "description"] From 320591277f5e2773bfc1780220724b4409d1544d Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Fri, 12 May 2023 15:09:15 -0700 Subject: [PATCH 5/7] Add sketch of relays, jinja templating. --- pnpdevice/main.py | 8 ++++++-- pnpdevice/relays.py | 9 +++++++++ pnpdevice/server.py | 20 ++++++++++++++++---- pyproject.toml | 1 + templates/index.template.html | 3 +++ 5 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 pnpdevice/relays.py create mode 100644 templates/index.template.html diff --git a/pnpdevice/main.py b/pnpdevice/main.py index ecc00d3..872da9e 100644 --- a/pnpdevice/main.py +++ b/pnpdevice/main.py @@ -2,7 +2,6 @@ import argparse import asyncio import logging -import pnpdevice.discovery import pnpdevice.server LOGGER = logging.getLogger(__name__) @@ -10,8 +9,13 @@ LOGGER = logging.getLogger(__name__) def main(): parser = argparse.ArgumentParser() parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging") + parser.add_argument("-s", "--simulate", action="store_true", help="When present, simulate the state of the relays") args = parser.parse_args() logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) - pnpdevice.server.run() + if args.simulate: + relays = pnpdevice.relays.RelaysFake() + else: + relays = pnpdevice.relays.RelaysReal() + pnpdevice.server.run(relays) diff --git a/pnpdevice/relays.py b/pnpdevice/relays.py new file mode 100644 index 0000000..3d69d46 --- /dev/null +++ b/pnpdevice/relays.py @@ -0,0 +1,9 @@ + +class Relays: + "Class for interacting with relays." + +class RelaysFake(Relays): + "Class for fake relays, useful for testing." + +class RelaysReal(Relays): + "Class for controlling real relays." diff --git a/pnpdevice/server.py b/pnpdevice/server.py index b4c42b9..13abca3 100644 --- a/pnpdevice/server.py +++ b/pnpdevice/server.py @@ -1,14 +1,26 @@ from aiohttp import web +import aiohttp_jinja2 +import jinja2 import pnpdevice.discovery +import pnpdevice.relays -async def hello(request): - return web.Response(text="Hello, world") +def html(text: str) -> web.Response: + return web.Response(content_type="text/html", text=f"{text}") -def run(): +@aiohttp_jinja2.template("index.template.html") +async def index(request): + return {} + +async def status(request): + return html("Status") + +def run(relays: pnpdevice.relays.Relays): "Run the embedded web server" app = web.Application() + aiohttp_jinja2.setup(app, + loader=jinja2.FileSystemLoader("templates")) app.on_startup.append(pnpdevice.discovery.handle) - app.add_routes([web.get("/", hello)]) + app.add_routes([web.get("/", index)]) web.run_app(app) diff --git a/pyproject.toml b/pyproject.toml index 31a23db..b913c3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ authors = [ ] dependencies = [ "aiohttp", + "aiohttp_jinja2", "zeroconf", ] dynamic = ["version", "description"] diff --git a/templates/index.template.html b/templates/index.template.html new file mode 100644 index 0000000..2bf74db --- /dev/null +++ b/templates/index.template.html @@ -0,0 +1,3 @@ + +

Pools'n'Pumps Device

+ From c36a32179a740b0b3d9e7324b6f5f1b98d82155a Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Mon, 15 May 2023 14:46:58 -0700 Subject: [PATCH 6/7] This is just work-in-progress. Switching underlying frameworks... I wanna go FastAPI. --- pnpdevice/main.py | 9 ++- pnpdevice/relays.py | 101 +++++++++++++++++++++++++++ pnpdevice/server.py | 23 +++++- pyproject.toml | 1 + templates/index.template.html | 13 +++- templates/relay-create.template.html | 12 ++++ 6 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 templates/relay-create.template.html diff --git a/pnpdevice/main.py b/pnpdevice/main.py index 872da9e..b9b5267 100644 --- a/pnpdevice/main.py +++ b/pnpdevice/main.py @@ -1,6 +1,7 @@ import argparse import asyncio import logging +import pathlib import pnpdevice.server @@ -8,14 +9,16 @@ LOGGER = logging.getLogger(__name__) def main(): parser = argparse.ArgumentParser() - parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging") + parser.add_argument("-c", "--config", default="/etc/pnpdevice/config.toml", type=pathlib.Path, help="The config file to use.") parser.add_argument("-s", "--simulate", action="store_true", help="When present, simulate the state of the relays") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging") args = parser.parse_args() logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) if args.simulate: - relays = pnpdevice.relays.RelaysFake() + relays = pnpdevice.relays.RelaysFake(args.config) else: - relays = pnpdevice.relays.RelaysReal() + relays = pnpdevice.relays.RelaysFake(args.config) + #relays = pnpdevice.relays.RelaysReal(args.config) pnpdevice.server.run(relays) diff --git a/pnpdevice/relays.py b/pnpdevice/relays.py index 3d69d46..5b19ac1 100644 --- a/pnpdevice/relays.py +++ b/pnpdevice/relays.py @@ -1,9 +1,110 @@ +import dataclasses +import logging +import os +import pathlib +import tomllib +from typing import Dict, List, Union + +import tomli_w + +LOGGER = logging.getLogger(__name__) + +@dataclasses.dataclass +class Pin: + chip: str + number: int + name: str + +@dataclasses.dataclass +class Relay: + pin: Pin + name: str + state: bool + +def _deserialize(data: List[Dict[str, str]]) -> List[Relay]: + "Deserialize a list of relays from the config." + return [Relay( + name=relay["name"], + pin=Pin( + chip=relay["chip"], + number=int(relay["pinnumber"]), + name=relay["pinname"], + )) for relay in data] + +def _serialize(data: List[Relay]) -> List[Dict[str, str]]: + "Serialize a list of relays to the config." + return [{ + "chip": relay.pin.chip, + "name": relay.name, + "pinnumber": str(relay.pin.number), + "pinname": relay.pin.name, + } for relay in data] class Relays: "Class for interacting with relays." + def __init__(self, configpath: pathlib.Path): + self.configpath = configpath + try: + with open(configpath, "rb") as f: + content = tomllib.load(f) + self.config = content + except Exception as e: + LOGGER.info("Unable to load config file: %s", e) + self.config = { + "relays": [], + } + self.relays = _deserialize(self.config["relays"]) + + + def chips_list(self) -> List[str]: + raise NotImplementedError() + + def pins_list(self) -> List[Pin]: + raise NotImplementedError() + + def relay_add(self, chip: str, pinnumber: int, name: str) -> None: + self.config["relays"].append({ + "chip": chip, + "name": name, + "pinnumber": pinnumber, + }) + with open(self.configpath, "wb") as f: + tomli_w.dump({ + "relays": _serialize(self.config["relays"]), + }, f) + LOGGER.info("Wrote config file %s", self.configpath) + + def relays_list(self) -> List[Relay]: + return self.relays + + def relay_on(self, name: str) -> bool: + raise NotImplementedError() + + def relay_set(self, name: str, state: bool) -> None: + raise NotImplementedError() class RelaysFake(Relays): "Class for fake relays, useful for testing." + def chips_list(self) -> List[str]: + return ["chipA", "chipB"] + + def pins_list(self) -> List[Pin]: + return [ + Pin(chip="chipA", number=1, name="CON2-P1"), + Pin(chip="chipA", number=2, name="CON2-P2"), + Pin(chip="chipA", number=3, name="CON2-P10"), + Pin(chip="chipB", number=1, name="CON2-P3"), + Pin(chip="chipB", number=2, name="CON2-P7"), + ] + + def relays_list(self) -> List[Relay]: + raise NotImplementedError() + + def relay_on(self, name: str) -> bool: + raise NotImplementedError() + + def relay_set(self, name: str, state: bool) -> None: + raise NotImplementedError() class RelaysReal(Relays): "Class for controlling real relays." diff --git a/pnpdevice/server.py b/pnpdevice/server.py index 13abca3..e88b7e7 100644 --- a/pnpdevice/server.py +++ b/pnpdevice/server.py @@ -10,17 +10,36 @@ def html(text: str) -> web.Response: @aiohttp_jinja2.template("index.template.html") async def index(request): - return {} + relays = request.app["relays"] + return { + "relays": relays.relays_list(), + } -async def status(request): +@aiohttp_jinja2.template("relay-create.template.html") +async def relay_create_get(request: Request): + "Get the form to create a new relay." + relays = request.app["relays"] + pins = relays.pins_list() + return {"pins": pins} + +async def relay_create_post(request: Request, chip_and_number: str, name: str): + "Create a new relay." + chip, number = chip_and_number.partition("-") + LOGGER.info("Creating relay %s %s with name %s", chip, number, name) + return RedirectResponse(status_code=303, url="/") + +async def status(request: Request): return html("Status") def run(relays: pnpdevice.relays.Relays): "Run the embedded web server" app = web.Application() + app["relays"] = relays aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader("templates")) app.on_startup.append(pnpdevice.discovery.handle) app.add_routes([web.get("/", index)]) + app.add_routes([web.get("/relay/create", relay_create_get)]) + app.add_routes([web.post("/relay/create", relay_create_post)]) web.run_app(app) diff --git a/pyproject.toml b/pyproject.toml index b913c3a..9f1e6a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ authors = [ dependencies = [ "aiohttp", "aiohttp_jinja2", + "tomli-w", "zeroconf", ] dynamic = ["version", "description"] diff --git a/templates/index.template.html b/templates/index.template.html index 2bf74db..cbb0d9d 100644 --- a/templates/index.template.html +++ b/templates/index.template.html @@ -1,3 +1,14 @@ -

Pools'n'Pumps Device

+

Pools'n'Pumps Device

+{% if not relays %} +

No relays found.

+{% else %} +

Relays

+
    + {% for relay in relays %} +
  • {{ relay.name }}
  • + {% endfor %} +
+{% endif %} +Create Relay diff --git a/templates/relay-create.template.html b/templates/relay-create.template.html new file mode 100644 index 0000000..5fa4ddf --- /dev/null +++ b/templates/relay-create.template.html @@ -0,0 +1,12 @@ + +

Relay Creation

+
+ + + +
+ From 57d8ff0b39eabae75e1b8e549360897c18421238 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Tue, 16 May 2023 19:21:11 -0700 Subject: [PATCH 7/7] Checkpoint, switching computers. --- pnpdevice/config.py | 8 ++++++++ pnpdevice/server.py | 25 ++++++++++++++----------- pyproject.toml | 4 ++-- 3 files changed, 24 insertions(+), 13 deletions(-) create mode 100644 pnpdevice/config.py diff --git a/pnpdevice/config.py b/pnpdevice/config.py new file mode 100644 index 0000000..4d74568 --- /dev/null +++ b/pnpdevice/config.py @@ -0,0 +1,8 @@ +from starlette.config import Config + +config = Config(".env") + +DEBUG = config("DEBUG", cast=bool, default=False) +DATABASE = config("DATABASE_URL", cast=databases.DatabaseURL) +SECRET_KEY = config("SECRET_KEY", cast=Secret) +ALLOWED_HOSTS = config("ALLOWED_HOSTS", cast=CommaSeparatedStrings) diff --git a/pnpdevice/server.py b/pnpdevice/server.py index e88b7e7..03b8515 100644 --- a/pnpdevice/server.py +++ b/pnpdevice/server.py @@ -1,26 +1,29 @@ -from aiohttp import web -import aiohttp_jinja2 +from fastapi import FastAPI, Request +from fastapi.templating import Jinja2Templates +from starlette.responses import RedirectResponse + import jinja2 +from pnpdevice.config import config import pnpdevice.discovery import pnpdevice.relays -def html(text: str) -> web.Response: - return web.Response(content_type="text/html", text=f"{text}") +app = FastAPI(debug=config.DEBUG) +templates = Jinja2Templates(directory="templates") -@aiohttp_jinja2.template("index.template.html") -async def index(request): +@app.get("/") +def index(): relays = request.app["relays"] - return { + return templates.TemplateResponse("index.template.html", { "relays": relays.relays_list(), - } + }) -@aiohttp_jinja2.template("relay-create.template.html") -async def relay_create_get(request: Request): +@app.get("/relay/create") +def relay_create_get(request: Request): "Get the form to create a new relay." relays = request.app["relays"] pins = relays.pins_list() - return {"pins": pins} + return templates.TemplateResponse("relay-create.template.html", {"pins": pins}) async def relay_create_post(request: Request, chip_and_number: str, name: str): "Create a new relay." diff --git a/pyproject.toml b/pyproject.toml index 9f1e6a4..c6ef362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ authors = [ { name = "Eli Ribble", email = "eli@theribbles.org"} ] dependencies = [ - "aiohttp", - "aiohttp_jinja2", + "fastapi", "tomli-w", + "uvicorn[standard]", "zeroconf", ] dynamic = ["version", "description"]