From 321ccb178e9fe05119dad99701680abb50a4a45b Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Mon, 3 Aug 2026 16:06:28 +0100 Subject: [PATCH] Initial json server implementation Completed the initial implementation of the JsonServer class, handling the worker thread that runs the async task, as well as the connection handler callback The current handler awaits readline to cleanly exit when the connection is closed, no data ingress is happening yet Next steps include gating the start procedure to wait for everything to start proprerly in order to avoid having start exit early and async tasks crashing later --- pyproject.toml | 4 +- src/dynalab_core/__init__.py | 89 +++++++++++++++++++ src/dynalab_core/config.py | 13 +++ src/dynalab_core/constants.py | 8 ++ src/dynalab_core/errors.py | 7 ++ src/dynalab_core/protocols/json/__init__.py | 3 + src/dynalab_core/protocols/json/common.py | 18 ++++ src/dynalab_core/protocols/json/constants.py | 7 ++ .../protocols/json/packets/handshake.py | 42 +++++++++ 9 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/dynalab_core/config.py create mode 100644 src/dynalab_core/constants.py create mode 100644 src/dynalab_core/errors.py create mode 100644 src/dynalab_core/protocols/json/__init__.py create mode 100644 src/dynalab_core/protocols/json/common.py create mode 100644 src/dynalab_core/protocols/json/constants.py create mode 100644 src/dynalab_core/protocols/json/packets/handshake.py diff --git a/pyproject.toml b/pyproject.toml index 9f2036b..16048ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,9 @@ authors = [ { name = "Hector van der Aa", email = "hector@h3cx.dev" } ] requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "pydantic>=2.13.4", +] [build-system] requires = ["uv_build>=0.12.1,<0.13.0"] diff --git a/src/dynalab_core/__init__.py b/src/dynalab_core/__init__.py index a1607a4..da9917f 100644 --- a/src/dynalab_core/__init__.py +++ b/src/dynalab_core/__init__.py @@ -2,4 +2,93 @@ # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later +import asyncio +from asyncio import Server +import threading +from threading import Thread +from typing import Literal + +from dynalab_core.config import CoreConfig +from dynalab_core.constants import CORE_VERSION +from dynalab_core.errors import StateError +from dynalab_core.protocols.json.common import VersionDescriptor + + +class JsonServer: + _thread: Thread | None = None + _server: Server | None = None + _config: CoreConfig + _stop_event: threading.Event + + def __init__( + self, + config: CoreConfig, + stop_event: threading.Event, + ) -> None: + self._config = config + self._stop_event = stop_event + + def start(self) -> None: + self._thread = Thread( + target=self._json_server_thread_main, name="json_server_thread", daemon=True + ) + self._thread.start() + + def _json_server_thread_main(self) -> None: + asyncio.run(self._run_json_server()) + + async def _run_json_server(self) -> None: + try: + self._server = await asyncio.start_server( + self._handle_json_connection, + host=self._config.host, + port=self._config.port, + ) + except OSError as error: + print( + f"Failed to start ingress server on {self._config.bind_str()}: {error}" + ) + async with self._server: + await asyncio.to_thread(self._stop_event.wait) + + async def _handle_json_connection( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + print(f"Connected to: {writer.get_extra_info('peername')}") + + try: + await reader.readline() + finally: + writer.close() + await writer.wait_closed() + print("Disconnected") + + +class Core: + _state: Literal["uninitd", "initd", "started", "stopping", "stopped"] = "unintid" + _core_version: VersionDescriptor = CORE_VERSION + _core_config: CoreConfig = CoreConfig() + _stop_event: threading.Event = threading.Event() + _json_server: JsonServer + + def __init__(self, config: CoreConfig) -> None: + self._core_config = config + self._state = "initd" + self._json_server = JsonServer(self._core_config, self._stop_event) + pass + + def start(self) -> None: + if self._state != "initd": + raise StateError( + f'Unable to start DynaLab Core, expected state to be "initd", found {self._state}' + ) + self._json_server.start() + self._state = "started" + + def wait(self, timeout: float | None = None) -> None: + self._stop_event.wait(timeout) + + def stop(self) -> None: + self._stop_event.set() + self._state = "stopped" diff --git a/src/dynalab_core/config.py b/src/dynalab_core/config.py new file mode 100644 index 0000000..54e7b9d --- /dev/null +++ b/src/dynalab_core/config.py @@ -0,0 +1,13 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later + +from pydantic import BaseModel, Field + + +class CoreConfig(BaseModel): + host: str = "127.0.0.1" + port: int = Field(default=58763, ge=0, le=65535) + + def bind_str(self) -> str: + return f"{self.host}:{self.port}" diff --git a/src/dynalab_core/constants.py b/src/dynalab_core/constants.py new file mode 100644 index 0000000..72e74b5 --- /dev/null +++ b/src/dynalab_core/constants.py @@ -0,0 +1,8 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later + +from dynalab_core.protocols.json.common import VersionDescriptor + + +CORE_VERSION = VersionDescriptor(type="alpha", major=0, minor=0, patch=1) diff --git a/src/dynalab_core/errors.py b/src/dynalab_core/errors.py new file mode 100644 index 0000000..de6f34a --- /dev/null +++ b/src/dynalab_core/errors.py @@ -0,0 +1,7 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later + + +class StateError(Exception): + """DynaLab Core state error.""" diff --git a/src/dynalab_core/protocols/json/__init__.py b/src/dynalab_core/protocols/json/__init__.py new file mode 100644 index 0000000..1bd24e9 --- /dev/null +++ b/src/dynalab_core/protocols/json/__init__.py @@ -0,0 +1,3 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/src/dynalab_core/protocols/json/common.py b/src/dynalab_core/protocols/json/common.py new file mode 100644 index 0000000..a693058 --- /dev/null +++ b/src/dynalab_core/protocols/json/common.py @@ -0,0 +1,18 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Literal +from uuid import UUID +from pydantic import BaseModel, ConfigDict, Field + + +class VersionDescriptor(BaseModel): + model_config = ConfigDict(frozen=True, populate_by_name=True, extra="forbid") + type: Literal["alpha", "beta", "release"] = Field(alias="type") + major: int = Field(ge=0) + minor: int = Field(ge=0) + patch: int = Field(ge=0) + + def get_version(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}-{self.type}" diff --git a/src/dynalab_core/protocols/json/constants.py b/src/dynalab_core/protocols/json/constants.py new file mode 100644 index 0000000..8718dc9 --- /dev/null +++ b/src/dynalab_core/protocols/json/constants.py @@ -0,0 +1,7 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later + +from dynalab_core.protocols.json.common import VersionDescriptor + +PROTOCOL_VERSION = VersionDescriptor(type="alpha", major=0, minor=0, patch=1) diff --git a/src/dynalab_core/protocols/json/packets/handshake.py b/src/dynalab_core/protocols/json/packets/handshake.py new file mode 100644 index 0000000..494de71 --- /dev/null +++ b/src/dynalab_core/protocols/json/packets/handshake.py @@ -0,0 +1,42 @@ +# Copyright (C) 2026 Hector van der Aa +# Copyright (C) 2026 Association Exergie +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Literal +from uuid import UUID +from pydantic import BaseModel, Field + +from dynalab_core.protocols.json.common import VersionDescriptor +from dynalab_core.protocols.json.constants import PROTOCOL_VERSION + + +class DynaLabHello(BaseModel): + type: Literal["dynalab_hello"] = "dynalab_hello" + instance_id: UUID + core_version: VersionDescriptor + protocol_version: VersionDescriptor = PROTOCOL_VERSION + + heartbeat_interval_ms: int = Field(default_factory=1000, ge=0) + heartbeat_timeout_ms: int = Field(default_factory=5000, ge=0) + + +class ConnectorHello(BaseModel): + type: Literal["connector_hello"] = "connector_hello" + connector_uuid: UUID + protocol_version: VersionDescriptor = PROTOCOL_VERSION + + connector_name: str + connector_version: str + + # TODO: implement SignalDescriptor + # signals: list[SignalDescriptor] + + +class HandshakeAccepted(BaseModel): + type: Literal["handshake_accepted"] = "handshake_accepted" + accepted_signals: list[UUID] + + +class HandshakeRejected(BaseModel): + type: Literal["handshake_rejected"] = "handshake_rejected" + reason: str