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