Created all the basic routes. Still requires some testing and cleaning

This commit is contained in:
Jurn Wubben 2025-08-28 09:46:56 +02:00
parent 33715cb34e
commit 2ae520f6b3
3 changed files with 293 additions and 53 deletions

245
app.py
View file

@ -7,12 +7,15 @@ from sqlmodel import SQLModel, Session, create_engine, delete, select
import netbrite as nb import netbrite as nb
from db import ( from db import (
MessageDB, MessageDB,
MessagePublic,
NetBriteBase, NetBriteBase,
NetBriteDB, NetBriteDB,
NetBritePublic, NetBritePublic,
NetBriteUpdate,
ZoneBase, ZoneBase,
ZoneDB, ZoneDB,
ZonePublic, ZonePublic,
ZoneUpdate,
) )
DB_URL = "sqlite:///devices.db" DB_URL = "sqlite:///devices.db"
@ -37,24 +40,22 @@ async def lifespan(_: FastAPI):
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
active_devices: dict[int, nb.NetBrite] = {} active_devices: dict[int, nb.NetBrite] = {}
device_status: dict[int, bool] = {}
# ---------- helper ---------- # ---------- helper ----------
def load_devices_from_db() -> None: def load_devices_from_db() -> None:
with Session(engine) as session: with Session(engine) as session:
for device in session.exec(select(NetBriteDB)).all(): for device in session.exec(select(NetBriteDB)).all():
load_device(device, session) load_device(device)
def load_device(device: NetBriteDB, session: SessionDep): def load_device(device: NetBriteDB):
id = device.id or 0 id = device.id or 0
try: try:
active_devices[id] = nb.NetBrite(device.address, device.port) active_devices[id] = nb.NetBrite(device.address, device.port)
device_status[id] = True
load_zones_id(session, id, active_devices[id]) load_zones(device.zones, active_devices[id])
except nb.NetbriteConnectionException as exc: except nb.NetbriteConnectionException as exc:
device_status[id] = False
print(f"Could not connect to {device.address}:{device.port}{exc}") print(f"Could not connect to {device.address}:{device.port}{exc}")
@ -64,8 +65,19 @@ def load_zones_id(session: Session, device_id: int, net_dev: nb.NetBrite):
load_zones(zones, net_dev) load_zones(zones, net_dev)
def load_empty(net_dev: nb.NetBrite):
message = nb.Message("clearing...")
zone = nb.Zone(0, 0, 150, 7)
net_dev.zones({"empty": zone})
net_dev.message(message, "empty")
def load_zones(zones_in: list[ZoneDB], net_dev: nb.NetBrite) -> None: def load_zones(zones_in: list[ZoneDB], net_dev: nb.NetBrite) -> None:
load_empty(net_dev)
zones: dict[str, nb.Zone] = {} zones: dict[str, nb.Zone] = {}
messages: dict[str, nb.Message] = {}
for zone in zones_in: for zone in zones_in:
msg = zone.default_message msg = zone.default_message
@ -94,18 +106,18 @@ def load_zones(zones_in: list[ZoneDB], net_dev: nb.NetBrite) -> None:
default_color=zone.default_color, default_color=zone.default_color,
initial_text=default_msg, initial_text=default_msg,
) )
messages[zone.name] = default_msg
if zones: if zones:
net_dev.zones(zones) net_dev.zones(zones)
for zone, message in messages.items():
net_dev.message(message, zone)
def create_default_zone(session: Session, device_id: int) -> None: def create_default_zone(session: Session, device_id: int) -> None:
zone = ZoneDB( zone = ZoneDB(
name="0", name="0",
x=0,
y=0,
width=120,
height=7,
netbrite_id=device_id, netbrite_id=device_id,
) )
msg = MessageDB( msg = MessageDB(
@ -135,7 +147,9 @@ def create_device(device: NetBriteBase, session: SessionDep):
session.commit() session.commit()
session.refresh(db_device) session.refresh(db_device)
load_device(db_device, session) create_default_zone(session, db_device.id or 0)
load_device(db_device)
return db_device return db_device
@ -145,40 +159,27 @@ def get_devices(session: SessionDep):
for device in session.exec(select(NetBriteDB)).all(): for device in session.exec(select(NetBriteDB)).all():
device = NetBritePublic.model_validate( device = NetBritePublic.model_validate(
device, update={"active": device_status.get(device.id or 0) or False} device, update={"active": (device.id or 0) in active_devices}
) )
devices.append(device) devices.append(device)
return devices # FIXME: implement active return devices
@app.post("/api/devices/{device_id}", response_model=NetBritePublic) @app.post("/api/devices/{device_id}", response_model=NetBritePublic)
def update_device(device_id: int, updated_device: NetBriteBase, session: SessionDep): def edit_device(device_id: int, updated_device: NetBriteUpdate, session: SessionDep):
db_dev = session.get(NetBriteDB, device_id) db_dev = session.get(NetBriteDB, device_id)
if not db_dev: if not db_dev:
raise HTTPException(404, "Device not found") raise HTTPException(404, "Device not found")
db_dev.port = updated_device.port dev_data = updated_device.model_dump(exclude_unset=True)
db_dev.address = updated_device.address _ = db_dev.sqlmodel_update(dev_data)
return 200 session.add(db_dev)
session.commit()
session.refresh(db_dev)
return db_dev # TODO: update device
# TODO: implement me
@app.post("/api/devices/{device_id}/reconnect")
def reconnect_device(device_id: int, session: SessionDep):
db_dev = session.get(NetBriteDB, device_id)
if not db_dev:
raise HTTPException(404, "Device not found")
try:
active_devices[device_id] = nb.NetBrite(db_dev.address, db_dev.port)
device_status[device_id] = True
load_zones_id(session, device_id, active_devices[device_id])
return 200
except nb.NetbriteConnectionException as exc:
device_status[device_id] = False
raise HTTPException(400, str(exc))
@app.delete("/api/devices/{device_id}") @app.delete("/api/devices/{device_id}")
@ -204,13 +205,93 @@ def delete_device(device_id: int, session: SessionDep):
return 200 return 200
@app.post("/api/devices/{device_id}/zones", response_model=ZonePublic) @app.post("/api/devices/{device_id}/reconnect")
def create_zone(device_id: int, body: ZoneBase, session: SessionDep): def reconnect_device(device_id: int, session: SessionDep):
db_dev = session.get(NetBriteDB, device_id)
if not db_dev:
raise HTTPException(404, "Device not found")
try:
active_devices[device_id] = nb.NetBrite(db_dev.address, db_dev.port)
load_zones_id(session, device_id, active_devices[device_id])
return 200
except nb.NetbriteConnectionException as exc:
raise HTTPException(400, str(exc))
@app.post(
"/api/devices/{device_id}/sync",
description="**NOTE**: This will recreate the zones on the device.",
)
def sync_device(
device_id: int,
session: SessionDep,
):
if device_id not in active_devices:
raise HTTPException(500, "Device not active, try reconnecting")
db_dev = session.get(NetBriteDB, device_id)
if not db_dev:
raise HTTPException(404, "Device not found")
try:
load_zones(db_dev.zones, active_devices[device_id])
except nb.NetbriteTransferException:
del active_devices[device_id]
raise HTTPException(500, "Failed to send zones. Device inactive now.")
return 200
@app.post(
"/api/devices/{device_id}/restart",
)
def restart_device(
device_id: int,
session: SessionDep,
):
if device_id not in active_devices:
raise HTTPException(500, "Device not active, try reconnecting")
try:
active_devices[device_id].reboot()
del active_devices[device_id]
except nb.NetbriteTransferException:
raise HTTPException(500, "Failed to send zones. Device inactive now.")
return 200
@app.post(
"/api/devices/{device_id}/reload",
description="**NOTE**: Will remove all zones and reload them with **default** messages",
)
def reload_device(device_id: int, session: SessionDep):
if device_id not in active_devices:
raise HTTPException(500, "Device not active, try reconnecting")
try:
load_zones_id(session, device_id, active_devices[device_id])
except nb.NetbriteTransferException:
raise HTTPException(500, "Failed to send zones. Device inactive now.")
return 200
@app.post(
"/api/devices/{device_id}/zones",
response_model=ZonePublic,
description="**NOTE**: this does not update the device.",
)
def create_zone(device_id: int, new_zone: ZoneBase, session: SessionDep):
device = session.get(NetBriteDB, device_id) device = session.get(NetBriteDB, device_id)
if not device: if not device:
raise HTTPException(404, "Device not found") raise HTTPException(404, "Device not found")
zone = ZoneDB.model_validate(body) new_zone_data = new_zone.model_dump(exclude_unset=True)
extra_data = {"netbrite_id": device_id}
zone = ZoneDB.model_validate(new_zone_data, update=extra_data)
msg = MessageDB( msg = MessageDB(
text="{erase}Welcome", text="{erase}Welcome",
) )
@ -224,13 +305,7 @@ def create_zone(device_id: int, body: ZoneBase, session: SessionDep):
zone.default_message_id = msg.id zone.default_message_id = msg.id
session.commit() session.commit()
if not device.id in active_devices: return zone
raise HTTPException(503, "Device not active")
try:
load_zones(device.zones, active_devices[device.id])
except nb.NetbriteTransferException:
raise HTTPException(503, "Device not active")
@app.get("/api/devices/{device_id}/zones", response_model=list[ZonePublic]) @app.get("/api/devices/{device_id}/zones", response_model=list[ZonePublic])
@ -244,8 +319,12 @@ def get_zones(device_id: int, session: SessionDep):
@app.delete( @app.delete(
"/api/zone/{zone_id}", "/api/zone/{zone_id}",
description="**NOTE**: this does not update the device.",
) )
def delete_zone(zone_id: int, session: SessionDep): def delete_zone(
zone_id: int,
session: SessionDep,
):
zone = session.get(ZoneDB, zone_id) zone = session.get(ZoneDB, zone_id)
if not zone: if not zone:
raise HTTPException(404, "Zone not found") raise HTTPException(404, "Zone not found")
@ -258,3 +337,81 @@ def delete_zone(zone_id: int, session: SessionDep):
session.commit() session.commit()
return 200 return 200
@app.post(
"/api/zone/{zone_id}",
response_model=ZonePublic,
description="**NOTE**: this does not update the device.",
)
def edit_zone(zone_id: int, zone: ZoneUpdate, session: SessionDep):
zone_db = session.get(ZoneDB, zone_id)
if not zone_db:
raise HTTPException(404, "Zone not found")
zone_update_data = zone.model_dump(exclude_unset=True)
print(zone_update_data)
_ = zone_db.sqlmodel_update(zone_update_data)
session.add(zone_db)
session.commit()
session.refresh(zone_db)
return zone_db
@app.post(
"/api/zone/{zone_id}/adhoc_message",
response_model=ZonePublic,
description="**NOTE**: this does not update the device.",
)
def edit_message(zone_id: int, message: MessagePublic, session: SessionDep):
zone_db = session.get(ZoneDB, zone_id)
if not zone_db:
raise HTTPException(404, "Zone not found")
device_id = zone_db.netbrite_id
if not device_id in active_devices:
raise HTTPException(500, "Device inactive")
session.add(zone_db)
session.commit()
session.refresh(zone_db)
return zone_db
@app.post(
"/api/zone/{zone_id}/adhoc_message",
response_model=ZonePublic,
description="**NOTE**: this updates the device temporarily. Edit the zone message for permanent changes.",
)
def adhoc_message(zone_id: int, message: MessagePublic, session: SessionDep):
zone_db = session.get(ZoneDB, zone_id)
if not zone_db:
raise HTTPException(404, "Zone not found")
device_id = zone_db.netbrite_id
if not device_id in active_devices:
raise HTTPException(500, "Device inactive")
nb_message = nb.Message(
text=message.text,
activation_delay=message.activation_delay,
display_delay=message.display_delay,
display_repeat=message.display_repeat,
priority=message.priority,
sound_alarm=message.sound_alarm,
ttl=message.ttl,
)
try:
active_devices[device_id].message(nb_message, zone_db.name)
except nb.NetbriteTransferException:
del active_devices[device_id]
raise HTTPException(500, "Failed to send message. Device inactive now.")
return 200

42
db.py
View file

@ -9,10 +9,20 @@ class MessageBase(SQLModel):
display_delay: int = 0 display_delay: int = 0
display_repeat: int = 0 display_repeat: int = 0
priority: Priorities = Priorities.OVERRIDE priority: Priorities = Priorities.OVERRIDE
sound_alarm: bool = False
text: str = "" text: str = ""
ttl: int = 0 ttl: int = 0
# class MessageUpdate(SQLModel):
# activation_delay: int | None = 0
# display_delay: int | None = 0
# display_repeat: int | None = 0
# priority: Priorities | None = Priorities.OVERRIDE
# text: str | None = ""
# ttl: int | None = 0
class MessageDB(MessageBase, table=True): class MessageDB(MessageBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
zone: "ZoneDB" = Relationship( # pyright: ignore[reportAny] zone: "ZoneDB" = Relationship( # pyright: ignore[reportAny]
@ -30,6 +40,11 @@ class NetBriteBase(SQLModel):
port: int = 700 port: int = 700
class NetBriteUpdate(SQLModel):
address: str = Field(unique=True, index=True)
port: int = 700
class NetBriteDB(NetBriteBase, table=True): class NetBriteDB(NetBriteBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
zones: list["ZoneDB"] = Relationship( # pyright: ignore[reportAny] zones: list["ZoneDB"] = Relationship( # pyright: ignore[reportAny]
@ -46,21 +61,36 @@ class NetBritePublic(NetBriteBase):
# --- Zone --- # --- Zone ---
class ZoneBase(SQLModel): class ZoneBase(SQLModel):
name: str name: str
x: int x: int = 0
y: int y: int = 0
width: int width: int = 120
height: int height: int = 7
scroll_speed: ScrollSpeeds = ScrollSpeeds.NORMAL scroll_speed: ScrollSpeeds = ScrollSpeeds.NORMAL
pause_duration: int = 1000 pause_duration: int = 1000
volume: int = 4 volume: int = 4
default_font: Fonts = Fonts.NORMAL_7 default_font: Fonts = Fonts.NORMAL_7
default_color: Colors = Colors.RED default_color: Colors = Colors.RED
class ZoneUpdate(SQLModel):
name: str | None = None
x: int | None = None
y: int | None = None
width: int | None = None
height: int | None = None
scroll_speed: ScrollSpeeds | None = ScrollSpeeds.NORMAL
pause_duration: int | None = 1000
volume: int | None = 4
default_font: Fonts | None = Fonts.NORMAL_7
default_color: Colors | None = Colors.RED
class ZoneDBBase(ZoneBase):
default_message_id: int | None = Field(default=None, foreign_key="messagedb.id") default_message_id: int | None = Field(default=None, foreign_key="messagedb.id")
netbrite_id: int = Field(default=None, foreign_key="netbritedb.id") netbrite_id: int = Field(default=None, foreign_key="netbritedb.id")
class ZoneDB(ZoneBase, table=True): class ZoneDB(ZoneDBBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
default_message: MessageDB | None = Relationship( # pyright: ignore[reportAny] default_message: MessageDB | None = Relationship( # pyright: ignore[reportAny]
@ -71,7 +101,7 @@ class ZoneDB(ZoneBase, table=True):
) )
class ZonePublic(ZoneBase): class ZonePublic(ZoneDBBase):
id: int id: int
default_message: MessagePublic default_message: MessagePublic
# netbrite: NetBritePublic # netbrite: NetBritePublic

View file

@ -1,3 +1,4 @@
import select
from typing import Callable from typing import Callable
from crc import Calculator, Crc16 from crc import Calculator, Crc16
from enum import Enum from enum import Enum
@ -63,6 +64,13 @@ def pkt_escape(pkt: bytes) -> bytes:
return bytes(buf) return bytes(buf)
def is_socket_alive(sock: SocketType):
readable, _, exceptional = select.select([sock], [], [sock], 1)
if sock in exceptional:
return False
return bool(readable)
COLORS = [i.name.lower() for i in Colors] COLORS = [i.name.lower() for i in Colors]
COLORS_PATTERH = rb"\{(" + "|".join(COLORS).encode("ascii") + rb")\}" COLORS_PATTERH = rb"\{(" + "|".join(COLORS).encode("ascii") + rb")\}"
@ -200,6 +208,8 @@ class NetBrite:
def connect(self): def connect(self):
try: try:
if not is_socket_alive(self.sock):
raise OSError("Socket dead")
self.sock.connect((self.address, self.port)) self.sock.connect((self.address, self.port))
except OSError as e: except OSError as e:
raise NetbriteConnectionException( raise NetbriteConnectionException(
@ -208,10 +218,37 @@ class NetBrite:
def tx(self, pkt: bytes): def tx(self, pkt: bytes):
try: try:
_ = self.sock.send(pkt) _ = self.sock.sendall(pkt_escape(pkt))
except OSError as e: except OSError as e:
raise NetbriteTransferException(f"Error while opening network socket. {e}") raise NetbriteTransferException(f"Error while opening network socket. {e}")
def reboot(self):
pkt = pack(
f"<3B H H 3B 2B 4B 4B 1B",
0x16,
0x16,
0x01, # msg start
2, # body length
self.seqno, # packet count
0x00,
0x01,
0x00,
0x01,
0x01, # msg type: reset
0x00,
0xC8,
0x01,
0x00, # sign id
0x00,
0x01,
0x0F,
0x00, # reset msg
0x17, # crc follows
)
pkt += pack("<HB", checksum(pkt), 0x04)
self.tx(pkt)
def message(self, msg: Message, zoneName: str): def message(self, msg: Message, zoneName: str):
z = self.zones_list.get(zoneName) z = self.zones_list.get(zoneName)
if z == None: if z == None:
@ -267,7 +304,7 @@ class NetBrite:
) )
footer = pack("<HB", checksum(header + body), 0x04) footer = pack("<HB", checksum(header + body), 0x04)
self.tx(pkt_escape(header + body + footer)) self.tx(header + body + footer)
# print(f"Sent message to zone {zoneName}") # print(f"Sent message to zone {zoneName}")
def zones(self, zones: dict[str, Zone] | None = None): def zones(self, zones: dict[str, Zone] | None = None):
@ -282,6 +319,22 @@ class NetBrite:
ztext = z.initial_text.parse_msg() ztext = z.initial_text.parse_msg()
zlen = len(ztext) zlen = len(ztext)
rect: list[int] = list(z.rect)
if rect[0] > 254:
rect[0] = 254
if rect[1] > 254:
rect[1] = 254
if rect[0] >= rect[2]:
rect[2] = rect[0] + 1
if rect[1] >= rect[3]:
rect[3] = rect[1] + 1
z.rect = tuple(rect) # pyright: ignore[reportAttributeAccessIssue]
body = pack( body = pack(
f"<4B B4B 3B BH 8B B 4B 4B H5B 10B 3B 20BH3B11B{zlen}s B", f"<4B B4B 3B BH 8B B 4B 4B H5B 10B 3B 20BH3B11B{zlen}s B",
0x0F, # Body start 0x0F, # Body start
@ -396,7 +449,7 @@ class NetBrite:
footer = pack("<HB", crc, 0x04) footer = pack("<HB", crc, 0x04)
self.tx(pkt_escape(header + body + footer)) self.tx(header + body + footer)
print(f"Sent zone {zname}") print(f"Sent zone {zname}")
self.seqno += 1 self.seqno += 1