diff --git a/app.py b/app.py index 5edce19..cf3378f 100644 --- a/app.py +++ b/app.py @@ -7,12 +7,15 @@ from sqlmodel import SQLModel, Session, create_engine, delete, select import netbrite as nb from db import ( MessageDB, + MessagePublic, NetBriteBase, NetBriteDB, NetBritePublic, + NetBriteUpdate, ZoneBase, ZoneDB, ZonePublic, + ZoneUpdate, ) DB_URL = "sqlite:///devices.db" @@ -37,24 +40,22 @@ async def lifespan(_: FastAPI): app = FastAPI(lifespan=lifespan) active_devices: dict[int, nb.NetBrite] = {} -device_status: dict[int, bool] = {} # ---------- helper ---------- def load_devices_from_db() -> None: with Session(engine) as session: 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 try: 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: - device_status[id] = False 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) +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: + load_empty(net_dev) + zones: dict[str, nb.Zone] = {} + messages: dict[str, nb.Message] = {} for zone in zones_in: 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, initial_text=default_msg, ) + messages[zone.name] = default_msg if 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: zone = ZoneDB( name="0", - x=0, - y=0, - width=120, - height=7, netbrite_id=device_id, ) msg = MessageDB( @@ -135,7 +147,9 @@ def create_device(device: NetBriteBase, session: SessionDep): session.commit() 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 @@ -145,40 +159,27 @@ def get_devices(session: SessionDep): for device in session.exec(select(NetBriteDB)).all(): 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) - return devices # FIXME: implement active + return devices @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) if not db_dev: raise HTTPException(404, "Device not found") - db_dev.port = updated_device.port - db_dev.address = updated_device.address + dev_data = updated_device.model_dump(exclude_unset=True) + _ = db_dev.sqlmodel_update(dev_data) - return 200 + session.add(db_dev) + session.commit() + session.refresh(db_dev) - -# 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)) + return db_dev # TODO: update device @app.delete("/api/devices/{device_id}") @@ -204,13 +205,93 @@ def delete_device(device_id: int, session: SessionDep): return 200 -@app.post("/api/devices/{device_id}/zones", response_model=ZonePublic) -def create_zone(device_id: int, body: ZoneBase, session: SessionDep): +@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) + 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) if not device: 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( text="{erase}Welcome", ) @@ -224,13 +305,7 @@ def create_zone(device_id: int, body: ZoneBase, session: SessionDep): zone.default_message_id = msg.id session.commit() - if not device.id in active_devices: - raise HTTPException(503, "Device not active") - - try: - load_zones(device.zones, active_devices[device.id]) - except nb.NetbriteTransferException: - raise HTTPException(503, "Device not active") + return zone @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( "/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) if not zone: raise HTTPException(404, "Zone not found") @@ -258,3 +337,81 @@ def delete_zone(zone_id: int, session: SessionDep): session.commit() 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 diff --git a/db.py b/db.py index a62f3f6..c70ec7d 100644 --- a/db.py +++ b/db.py @@ -9,10 +9,20 @@ class MessageBase(SQLModel): display_delay: int = 0 display_repeat: int = 0 priority: Priorities = Priorities.OVERRIDE + sound_alarm: bool = False text: str = "" 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): id: int | None = Field(default=None, primary_key=True) zone: "ZoneDB" = Relationship( # pyright: ignore[reportAny] @@ -30,6 +40,11 @@ class NetBriteBase(SQLModel): port: int = 700 +class NetBriteUpdate(SQLModel): + address: str = Field(unique=True, index=True) + port: int = 700 + + class NetBriteDB(NetBriteBase, table=True): id: int | None = Field(default=None, primary_key=True) zones: list["ZoneDB"] = Relationship( # pyright: ignore[reportAny] @@ -46,21 +61,36 @@ class NetBritePublic(NetBriteBase): # --- Zone --- class ZoneBase(SQLModel): name: str - x: int - y: int - width: int - height: int + x: int = 0 + y: int = 0 + width: int = 120 + height: int = 7 scroll_speed: ScrollSpeeds = ScrollSpeeds.NORMAL pause_duration: int = 1000 volume: int = 4 default_font: Fonts = Fonts.NORMAL_7 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") 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) 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 default_message: MessagePublic # netbrite: NetBritePublic diff --git a/netbrite.py b/netbrite.py index 1a39dbb..687b219 100644 --- a/netbrite.py +++ b/netbrite.py @@ -1,3 +1,4 @@ +import select from typing import Callable from crc import Calculator, Crc16 from enum import Enum @@ -63,6 +64,13 @@ def pkt_escape(pkt: bytes) -> bytes: 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_PATTERH = rb"\{(" + "|".join(COLORS).encode("ascii") + rb")\}" @@ -200,6 +208,8 @@ class NetBrite: def connect(self): try: + if not is_socket_alive(self.sock): + raise OSError("Socket dead") self.sock.connect((self.address, self.port)) except OSError as e: raise NetbriteConnectionException( @@ -208,10 +218,37 @@ class NetBrite: def tx(self, pkt: bytes): try: - _ = self.sock.send(pkt) + _ = self.sock.sendall(pkt_escape(pkt)) except OSError as 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(" 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( f"<4B B4B 3B BH 8B B 4B 4B H5B 10B 3B 20BH3B11B{zlen}s B", 0x0F, # Body start @@ -396,7 +449,7 @@ class NetBrite: footer = pack("