Updated Readme some. Added a family of messages that can pack/unpack/compress themselves to be analogous to HTTP Req/Resp.
This commit is contained in:
@@ -4,6 +4,9 @@ Basically, this is supposed to be a modernized BBS for radio,
|
|||||||
but with clients and servers exchanging binary messages
|
but with clients and servers exchanging binary messages
|
||||||
(which can be compressed automatically) rather than human-typed text.
|
(which can be compressed automatically) rather than human-typed text.
|
||||||
|
|
||||||
|
Right now, it will use ax25 connected sessions through AGWPE,
|
||||||
|
though I make add an unconnected protocol using UI later on..
|
||||||
|
|
||||||
I'm planning several features like:
|
I'm planning several features like:
|
||||||
|
|
||||||
- automatic compression for all RF communication
|
- automatic compression for all RF communication
|
||||||
@@ -11,7 +14,7 @@ I'm planning several features like:
|
|||||||
- RF beacon
|
- RF beacon
|
||||||
- administration over RF
|
- administration over RF
|
||||||
- object storage/retrieval
|
- object storage/retrieval
|
||||||
- running user-scripts scripts or shell commands on the server in containers with podman/docker
|
- running user-defined scripts or shell commands on the server in containers with podman/docker
|
||||||
- possibly a cron system (again in containers for safety)
|
- possibly a cron system (again in containers for safety)
|
||||||
- maybe an e-mail or an sms gateway (though clever user uploaded scripts could do this instead)
|
- maybe an e-mail or an sms gateway (though clever user uploaded scripts could do this instead)
|
||||||
- maybe APRS integration through APRS-IS
|
- maybe APRS integration through APRS-IS
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from pe.connect import Connection
|
from pe.connect import Connection
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from msgpack import Unpacker
|
from msgpack import Unpacker
|
||||||
|
from msgpack import packb, unpackb
|
||||||
|
from enum import Enum
|
||||||
|
import bz2
|
||||||
|
from typing import Union, Self
|
||||||
|
|
||||||
|
|
||||||
class PacketServerConnection(Connection):
|
class PacketServerConnection(Connection):
|
||||||
@@ -32,3 +36,207 @@ class PacketServerConnection(Connection):
|
|||||||
def query_accept(cls, port, call_from, call_to):
|
def query_accept(cls, port, call_from, call_to):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class Message:
|
||||||
|
"""Base class for communication encapsulated in msgpack objects."""
|
||||||
|
|
||||||
|
class CompressionType(Enum):
|
||||||
|
NONE = 0
|
||||||
|
BZIP2 = 1
|
||||||
|
GZIP = 2
|
||||||
|
DEFLATE = 3
|
||||||
|
|
||||||
|
class MessageType(Enum):
|
||||||
|
REQUEST = 0
|
||||||
|
RESPONSE = 1
|
||||||
|
|
||||||
|
def __init__(self, msg_type: MessageType, compression: CompressionType, payload: dict):
|
||||||
|
self.type = Message.MessageType(msg_type)
|
||||||
|
self.compression = Message.CompressionType(compression)
|
||||||
|
self.data = payload
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vars(self) -> dict:
|
||||||
|
if 'v' in self.data:
|
||||||
|
if type(self.data['v']) is dict:
|
||||||
|
return self.data['v']
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_var(self, key: str):
|
||||||
|
if 'v' not in self.data:
|
||||||
|
raise KeyError(f"Variable '{key}' not found.")
|
||||||
|
if str(key) not in self.data['v']:
|
||||||
|
raise KeyError(f"Variable '{key}' not found.")
|
||||||
|
return self.data['v'][str(key)]
|
||||||
|
|
||||||
|
def set_var(self, key: str, value):
|
||||||
|
if 'v' not in self.data:
|
||||||
|
self.data['v'] = {}
|
||||||
|
self.data['v'][str(key)] = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_bytes(self):
|
||||||
|
return packb(self.data)
|
||||||
|
|
||||||
|
def pack(self) -> bytes:
|
||||||
|
output = {'t': self.type.value, 'c': self.compression.value}
|
||||||
|
data_bytes = self.data_bytes
|
||||||
|
|
||||||
|
if (self.compression is self.CompressionType.NONE) or (len(data_bytes) < 30):
|
||||||
|
output['d'] = data_bytes
|
||||||
|
return packb(output)
|
||||||
|
|
||||||
|
if self.compression is self.CompressionType.BZIP2:
|
||||||
|
compressed = bz2.compress(packb(self.data))
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"Compression type {self.compression.name} is not implemented yet.")
|
||||||
|
|
||||||
|
if len(compressed) < len(data_bytes):
|
||||||
|
output['d'] = compressed
|
||||||
|
else:
|
||||||
|
output['d'] = data_bytes
|
||||||
|
output['c'] = self.CompressionType.NONE.value
|
||||||
|
return packb(output)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payload(self):
|
||||||
|
if 'd' in self.data:
|
||||||
|
pl = self.data['d']
|
||||||
|
if type(pl) in (dict, str, bytes):
|
||||||
|
return pl
|
||||||
|
else:
|
||||||
|
return str(pl)
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@payload.setter
|
||||||
|
def payload(self, payload: Union[str, bytes, dict]):
|
||||||
|
if type(payload) in (str, bytes, dict):
|
||||||
|
self.data['d'] = payload
|
||||||
|
else:
|
||||||
|
self.data['d'] = str(payload)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unpack(cls, msg_bytes: bytes) -> Self:
|
||||||
|
try:
|
||||||
|
unpacked = unpackb(msg_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError("ERROR: msg_bytes didn't contain a valid msgpack object.\n" + str(e))
|
||||||
|
for i in ('t', 'c', 'd'):
|
||||||
|
if i not in unpacked:
|
||||||
|
raise ValueError("ERROR: unpacked bytes do not contain a valid Message object.")
|
||||||
|
|
||||||
|
comp = Message.CompressionType(unpacked['c'])
|
||||||
|
msg_type = Message.MessageType(unpacked['t'])
|
||||||
|
raw_data = unpacked['d']
|
||||||
|
|
||||||
|
if comp is Message.CompressionType.NONE:
|
||||||
|
data = unpackb(raw_data)
|
||||||
|
elif comp is Message.CompressionType.BZIP2:
|
||||||
|
data = unpackb(bz2.decompress(raw_data))
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"Compression type {comp.name} is not implemented yet.")
|
||||||
|
return Message(msg_type, comp, data)
|
||||||
|
|
||||||
|
class Request(Message):
|
||||||
|
class Method(Enum):
|
||||||
|
GET = 0
|
||||||
|
POST = 1
|
||||||
|
UPDATE = 2
|
||||||
|
DELETE = 3
|
||||||
|
|
||||||
|
def __init__(self, msg: Message):
|
||||||
|
if msg.type is not Message.MessageType.REQUEST:
|
||||||
|
raise ValueError(f"Can't create a Request Object from a {msg.type} Message object.")
|
||||||
|
|
||||||
|
super().__init__(msg.type, msg.compression, msg.data)
|
||||||
|
|
||||||
|
if ('p' in msg.data) and (type(msg.data['p']) is not str):
|
||||||
|
raise ValueError("Path of Request must be a string.")
|
||||||
|
|
||||||
|
if 'm' in msg.data:
|
||||||
|
if type(msg.data['m']) is not bytes:
|
||||||
|
raise ValueError("Method of Request must be bytes.")
|
||||||
|
self.Method(int(self.data['m'][0]))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self):
|
||||||
|
if 'p' in self.data:
|
||||||
|
return str(self.data['p'])
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@path.setter
|
||||||
|
def path(self, path: str):
|
||||||
|
self.data['p'] = str(path.strip())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def method(self) -> Method:
|
||||||
|
if 'm' in self.data:
|
||||||
|
return self.Method(int(self.data['m'][0]))
|
||||||
|
else:
|
||||||
|
return self.Method.GET
|
||||||
|
|
||||||
|
@method.setter
|
||||||
|
def method(self, meth: Method):
|
||||||
|
meth = self.Method(meth)
|
||||||
|
self.data['m'] = meth.value.to_bytes(1)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unpack(cls, msg_bytes: bytes) -> Self:
|
||||||
|
msg = super().unpack(msg_bytes)
|
||||||
|
return Request(msg)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def blank(cls) -> Self:
|
||||||
|
msg = Message(Message.MessageType.REQUEST, Message.CompressionType.NONE, {})
|
||||||
|
return Request(msg)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Request: {self.method.name} '{self.path}'>"
|
||||||
|
|
||||||
|
class Response(Message):
|
||||||
|
def __init__(self, msg: Message):
|
||||||
|
if msg.type is not Message.MessageType.RESPONSE:
|
||||||
|
raise ValueError(f"Can't create a Response Object from a {msg.type} Message object.")
|
||||||
|
|
||||||
|
super().__init__(msg.type, msg.compression, msg.data)
|
||||||
|
if 'c' in msg.data:
|
||||||
|
status_bytes = self.data['c']
|
||||||
|
if type(status_bytes) is not bytes:
|
||||||
|
raise ValueError("Invalid Response data")
|
||||||
|
status_code = int.from_bytes(status_bytes)
|
||||||
|
if status_code >= 600:
|
||||||
|
raise ValueError("Invalid status code.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unpack(cls, msg_bytes: bytes) -> Self:
|
||||||
|
msg = super().unpack(msg_bytes)
|
||||||
|
return Response(msg)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def blank(cls) -> Self:
|
||||||
|
msg = Message(Message.MessageType.RESPONSE, Message.CompressionType.NONE, {})
|
||||||
|
return Response(msg)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_code(self) -> int:
|
||||||
|
if 'c' in self.data:
|
||||||
|
status_bytes = self.data['c']
|
||||||
|
if type(status_bytes) is not bytes:
|
||||||
|
raise ValueError("Invalid Response data")
|
||||||
|
status_code = int.from_bytes(status_bytes)
|
||||||
|
if status_code >= 600:
|
||||||
|
raise ValueError("Invalid status code.")
|
||||||
|
return status_code
|
||||||
|
else:
|
||||||
|
return 200
|
||||||
|
|
||||||
|
@status_code.setter
|
||||||
|
def status_code(self, code: int):
|
||||||
|
if (code <= 0) or (code >= 600):
|
||||||
|
raise ValueError("Status must be a positive integer <= 600")
|
||||||
|
self.data['c'] = code.to_bytes(2)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Response: {self.status_code}>"
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import pe
|
import pe
|
||||||
from ..common import PacketServerConnection
|
from ..common import PacketServerConnection
|
||||||
import ax25
|
import ax25
|
||||||
from configparser import ConfigParser
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import ZODB, transaction
|
import ZODB, transaction, ZODB.FileStorage
|
||||||
|
|
||||||
class Server:
|
class Server:
|
||||||
def __init__(self, pe_server: str, port: int, server_callsign: str, data_dir: str = None):
|
def __init__(self, pe_server: str, port: int, server_callsign: str, data_dir: str = None):
|
||||||
@@ -13,8 +12,27 @@ class Server:
|
|||||||
self.pe_server = pe_server
|
self.pe_server = pe_server
|
||||||
self.pe_port = port
|
self.pe_port = port
|
||||||
if data_dir:
|
if data_dir:
|
||||||
if Path.is_dir(data_dir):
|
data_path = Path(data_dir)
|
||||||
|
else:
|
||||||
|
data_path = Path.home().joinpath(".packetserver")
|
||||||
|
if data_path.is_dir():
|
||||||
|
if data_path.joinpath("data.zopedb").exists():
|
||||||
|
if not data_path.joinpath("data.zopedb").is_file():
|
||||||
|
raise FileExistsError("data.zopedb exists as non-file in specified path")
|
||||||
self.home_dir = data_dir
|
self.home_dir = data_dir
|
||||||
|
else:
|
||||||
|
if data_path.exists():
|
||||||
|
raise FileExistsError(f"Non-Directory path '{data_dir}' already exists.")
|
||||||
|
else:
|
||||||
|
data_path.mkdir()
|
||||||
|
self.home_dir = data_dir
|
||||||
|
self.storage = ZODB.FileStorage.FileStorage(self.data_file)
|
||||||
|
self.db = ZODB.DB(self.storage)
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_file(self) -> str:
|
||||||
|
return str(Path(self.home_dir).joinpath('data.zopedb'))
|
||||||
|
|
||||||
|
|
||||||
def server_receiver(self, conn: PacketServerConnection):
|
def server_receiver(self, conn: PacketServerConnection):
|
||||||
|
|||||||
Reference in New Issue
Block a user