sds-library 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sds/__init__.py +159 -0
- sds/_bindings.py +96 -0
- sds/_build_ffi.py +144 -0
- sds/_logging.py +78 -0
- sds/_mqtt.py +129 -0
- sds/_protocol.py +336 -0
- sds/_types.py +209 -0
- sds/_wire.py +247 -0
- sds/dynamic_table.py +149 -0
- sds/node.py +728 -0
- sds/table.py +345 -0
- sds/tables.py +401 -0
- sds_library-0.6.0.dist-info/METADATA +557 -0
- sds_library-0.6.0.dist-info/RECORD +16 -0
- sds_library-0.6.0.dist-info/WHEEL +5 -0
- sds_library-0.6.0.dist-info/top_level.txt +1 -0
sds/__init__.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SDS (Synchronized Data Structures) Python Library
|
|
3
|
+
|
|
4
|
+
A Pythonic interface for IoT state synchronization over MQTT.
|
|
5
|
+
This is a wrapper around the SDS C library using CFFI.
|
|
6
|
+
|
|
7
|
+
Example usage:
|
|
8
|
+
>>> from sds import SdsNode, Role, Field, FieldType
|
|
9
|
+
>>> from dataclasses import dataclass
|
|
10
|
+
>>>
|
|
11
|
+
>>> @dataclass
|
|
12
|
+
... class SensorState:
|
|
13
|
+
... temperature: float = Field(float32=True)
|
|
14
|
+
... humidity: float = Field(float32=True)
|
|
15
|
+
>>>
|
|
16
|
+
>>> with SdsNode("sensor_01", "localhost") as node:
|
|
17
|
+
... table = node.register_table(
|
|
18
|
+
... "SensorData", Role.DEVICE,
|
|
19
|
+
... state_schema=SensorState,
|
|
20
|
+
... )
|
|
21
|
+
...
|
|
22
|
+
... # C-like attribute access
|
|
23
|
+
... table.state.temperature = 23.5
|
|
24
|
+
... table.state.humidity = 65.0
|
|
25
|
+
...
|
|
26
|
+
... while True:
|
|
27
|
+
... node.poll(timeout_ms=1000)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import logging as _logging_std
|
|
31
|
+
|
|
32
|
+
__version__ = "0.6.0"
|
|
33
|
+
__author__ = "SDS Team"
|
|
34
|
+
|
|
35
|
+
# Core classes
|
|
36
|
+
# Logging configuration
|
|
37
|
+
from sds._logging import configure_logging
|
|
38
|
+
|
|
39
|
+
# Enums
|
|
40
|
+
# Exceptions
|
|
41
|
+
from sds._types import (
|
|
42
|
+
ErrorCode,
|
|
43
|
+
LogLevel,
|
|
44
|
+
Role,
|
|
45
|
+
SdsAlreadyInitializedError,
|
|
46
|
+
SdsCapacityError,
|
|
47
|
+
SdsConfigError,
|
|
48
|
+
SdsError,
|
|
49
|
+
SdsMqttError,
|
|
50
|
+
SdsNotInitializedError,
|
|
51
|
+
SdsPlatformError,
|
|
52
|
+
SdsTableError,
|
|
53
|
+
SdsValidationError,
|
|
54
|
+
check_error,
|
|
55
|
+
)
|
|
56
|
+
from sds.node import SdsNode
|
|
57
|
+
from sds.table import DeviceView, SdsTable, SectionProxy
|
|
58
|
+
|
|
59
|
+
# Table helpers
|
|
60
|
+
from sds.tables import Field, FieldType
|
|
61
|
+
|
|
62
|
+
# Public API
|
|
63
|
+
__all__ = [
|
|
64
|
+
# Version
|
|
65
|
+
"__version__",
|
|
66
|
+
|
|
67
|
+
# Core classes
|
|
68
|
+
"SdsNode",
|
|
69
|
+
"SdsTable",
|
|
70
|
+
"SectionProxy",
|
|
71
|
+
"DeviceView",
|
|
72
|
+
|
|
73
|
+
# Enums
|
|
74
|
+
"Role",
|
|
75
|
+
"ErrorCode",
|
|
76
|
+
"LogLevel",
|
|
77
|
+
|
|
78
|
+
# Exceptions
|
|
79
|
+
"SdsError",
|
|
80
|
+
"SdsNotInitializedError",
|
|
81
|
+
"SdsAlreadyInitializedError",
|
|
82
|
+
"SdsConfigError",
|
|
83
|
+
"SdsMqttError",
|
|
84
|
+
"SdsTableError",
|
|
85
|
+
"SdsCapacityError",
|
|
86
|
+
"SdsPlatformError",
|
|
87
|
+
"SdsValidationError",
|
|
88
|
+
"check_error",
|
|
89
|
+
|
|
90
|
+
# Table helpers
|
|
91
|
+
"Field",
|
|
92
|
+
"FieldType",
|
|
93
|
+
|
|
94
|
+
# Utility functions
|
|
95
|
+
"get_version",
|
|
96
|
+
"get_c_library_version",
|
|
97
|
+
"set_log_level",
|
|
98
|
+
"get_log_level",
|
|
99
|
+
"configure_logging",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_version() -> str:
|
|
104
|
+
"""Get the SDS Python library version."""
|
|
105
|
+
return __version__
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_c_library_version() -> str:
|
|
109
|
+
"""
|
|
110
|
+
Get the SDS C library version.
|
|
111
|
+
|
|
112
|
+
The pure-Python backend does not link the C library; this reports the
|
|
113
|
+
optional CFFI extension when present.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
from sds._bindings import lib # noqa: F401
|
|
117
|
+
return "1.0.0"
|
|
118
|
+
except Exception:
|
|
119
|
+
return "unknown (extension not built)"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Runtime log level for the pure-Python backend. Mapped onto the "sds" logger
|
|
123
|
+
# so it composes with standard logging configuration.
|
|
124
|
+
_LOGLEVEL_TO_PY = {
|
|
125
|
+
LogLevel.NONE: _logging_std.CRITICAL + 10,
|
|
126
|
+
LogLevel.ERROR: _logging_std.ERROR,
|
|
127
|
+
LogLevel.WARN: _logging_std.WARNING,
|
|
128
|
+
LogLevel.INFO: _logging_std.INFO,
|
|
129
|
+
LogLevel.DEBUG: _logging_std.DEBUG,
|
|
130
|
+
}
|
|
131
|
+
_current_log_level: LogLevel = LogLevel.WARN
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def set_log_level(level: LogLevel) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Set the runtime log level.
|
|
137
|
+
|
|
138
|
+
Controls which SDS log messages are output. Can be called at any time,
|
|
139
|
+
even before creating an SdsNode.
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> from sds import set_log_level, LogLevel
|
|
143
|
+
>>> set_log_level(LogLevel.DEBUG) # Enable all logs
|
|
144
|
+
>>> set_log_level(LogLevel.NONE) # Disable all logs
|
|
145
|
+
"""
|
|
146
|
+
global _current_log_level
|
|
147
|
+
level = LogLevel(level)
|
|
148
|
+
_current_log_level = level
|
|
149
|
+
_logging_std.getLogger("sds").setLevel(_LOGLEVEL_TO_PY[level])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_log_level() -> LogLevel:
|
|
153
|
+
"""
|
|
154
|
+
Get the current runtime log level.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The current log level
|
|
158
|
+
"""
|
|
159
|
+
return _current_log_level
|
sds/_bindings.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Low-level CFFI bindings for the SDS library.
|
|
3
|
+
|
|
4
|
+
This module provides direct access to the C library functions.
|
|
5
|
+
Most users should use the higher-level sds.SdsNode class instead.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# Try to import the compiled CFFI extension
|
|
10
|
+
try:
|
|
11
|
+
from sds._sds_cffi import ffi, lib
|
|
12
|
+
except ImportError as e:
|
|
13
|
+
# Provide helpful error message
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"SDS CFFI extension not found. Please build the extension first:\n"
|
|
16
|
+
" cd python && pip install -e .\n"
|
|
17
|
+
"or:\n"
|
|
18
|
+
" cd python && python sds/_build_ffi.py\n"
|
|
19
|
+
f"Original error: {e}"
|
|
20
|
+
) from e
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Re-export ffi and lib for direct access
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ffi",
|
|
26
|
+
"lib",
|
|
27
|
+
"SdsErrorCode",
|
|
28
|
+
"SdsRoleCode",
|
|
29
|
+
"encode_string",
|
|
30
|
+
"decode_string",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SdsErrorCode:
|
|
35
|
+
"""Error code constants from the C library."""
|
|
36
|
+
|
|
37
|
+
OK = lib.SDS_OK
|
|
38
|
+
NOT_INITIALIZED = lib.SDS_ERR_NOT_INITIALIZED
|
|
39
|
+
ALREADY_INITIALIZED = lib.SDS_ERR_ALREADY_INITIALIZED
|
|
40
|
+
INVALID_CONFIG = lib.SDS_ERR_INVALID_CONFIG
|
|
41
|
+
MQTT_CONNECT_FAILED = lib.SDS_ERR_MQTT_CONNECT_FAILED
|
|
42
|
+
MQTT_DISCONNECTED = lib.SDS_ERR_MQTT_DISCONNECTED
|
|
43
|
+
TABLE_NOT_FOUND = lib.SDS_ERR_TABLE_NOT_FOUND
|
|
44
|
+
TABLE_ALREADY_REGISTERED = lib.SDS_ERR_TABLE_ALREADY_REGISTERED
|
|
45
|
+
MAX_TABLES_REACHED = lib.SDS_ERR_MAX_TABLES_REACHED
|
|
46
|
+
INVALID_TABLE = lib.SDS_ERR_INVALID_TABLE
|
|
47
|
+
INVALID_ROLE = lib.SDS_ERR_INVALID_ROLE
|
|
48
|
+
OWNER_EXISTS = lib.SDS_ERR_OWNER_EXISTS
|
|
49
|
+
MAX_NODES_REACHED = lib.SDS_ERR_MAX_NODES_REACHED
|
|
50
|
+
BUFFER_FULL = lib.SDS_ERR_BUFFER_FULL
|
|
51
|
+
SECTION_TOO_LARGE = lib.SDS_ERR_SECTION_TOO_LARGE
|
|
52
|
+
PLATFORM_NOT_SET = lib.SDS_ERR_PLATFORM_NOT_SET
|
|
53
|
+
PLATFORM_ERROR = lib.SDS_ERR_PLATFORM_ERROR
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SdsRoleCode:
|
|
57
|
+
"""Role code constants from the C library."""
|
|
58
|
+
|
|
59
|
+
OWNER = lib.SDS_ROLE_OWNER
|
|
60
|
+
DEVICE = lib.SDS_ROLE_DEVICE
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def encode_string(s: str | None) -> bytes | ffi.CData:
|
|
64
|
+
"""
|
|
65
|
+
Encode a Python string to a C string (char*).
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
s: Python string or None
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
C string (ffi.new("char[]")) or ffi.NULL if input is None
|
|
72
|
+
"""
|
|
73
|
+
if s is None:
|
|
74
|
+
return ffi.NULL
|
|
75
|
+
return s.encode("utf-8")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decode_string(c_str: ffi.CData) -> str | None:
|
|
79
|
+
"""
|
|
80
|
+
Decode a C string (char*) to a Python string.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
c_str: C string pointer
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Python string or None if pointer is NULL
|
|
87
|
+
"""
|
|
88
|
+
if c_str == ffi.NULL:
|
|
89
|
+
return None
|
|
90
|
+
return ffi.string(c_str).decode("utf-8")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_error_string(error_code: int) -> str:
|
|
94
|
+
"""Get human-readable error message for an error code."""
|
|
95
|
+
c_str = lib.sds_error_string(error_code)
|
|
96
|
+
return decode_string(c_str) or f"Unknown error ({error_code})"
|
sds/_build_ffi.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CFFI build script for SDS library bindings.
|
|
3
|
+
|
|
4
|
+
This script is used by setup.py to compile the CFFI extension.
|
|
5
|
+
It can also be run directly to build the extension in-place.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from cffi import FFI
|
|
13
|
+
|
|
14
|
+
# Find the project root (parent of python/)
|
|
15
|
+
# Use resolve() to get absolute paths
|
|
16
|
+
PYTHON_DIR = Path(__file__).resolve().parent.parent
|
|
17
|
+
PROJECT_ROOT = PYTHON_DIR.parent
|
|
18
|
+
|
|
19
|
+
# Paths to C sources
|
|
20
|
+
INCLUDE_DIR = PROJECT_ROOT / "include"
|
|
21
|
+
SRC_DIR = PROJECT_ROOT / "src"
|
|
22
|
+
PLATFORM_DIR = PROJECT_ROOT / "platform"
|
|
23
|
+
|
|
24
|
+
print("Building CFFI extension with:")
|
|
25
|
+
print(f" PROJECT_ROOT: {PROJECT_ROOT}")
|
|
26
|
+
print(f" INCLUDE_DIR: {INCLUDE_DIR}")
|
|
27
|
+
print(f" SRC_DIR: {SRC_DIR}")
|
|
28
|
+
|
|
29
|
+
ffibuilder = FFI()
|
|
30
|
+
|
|
31
|
+
# Read the C definitions
|
|
32
|
+
cdefs_path = Path(__file__).parent / "_cdefs.h"
|
|
33
|
+
with open(cdefs_path, "r") as f:
|
|
34
|
+
cdefs = f.read()
|
|
35
|
+
|
|
36
|
+
ffibuilder.cdef(cdefs)
|
|
37
|
+
|
|
38
|
+
# Source code to compile - we'll compile the C library directly into the extension
|
|
39
|
+
# Include sds_types.h to get the auto-registered table metadata (SensorData, ActuatorData, etc.)
|
|
40
|
+
# Include sds_platform.h for log level types and functions
|
|
41
|
+
source_code = """
|
|
42
|
+
#include "sds.h"
|
|
43
|
+
#include "sds_json.h"
|
|
44
|
+
#include "sds_error.h"
|
|
45
|
+
#include "sds_platform.h" /* For SdsLogLevel, sds_set_log_level, sds_get_log_level */
|
|
46
|
+
#include "sds_types.h" /* Generated types - auto-registers via constructor */
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Get include directories and source files
|
|
50
|
+
include_dirs = [str(INCLUDE_DIR)]
|
|
51
|
+
library_dirs = []
|
|
52
|
+
sources = []
|
|
53
|
+
|
|
54
|
+
# For setuptools, we need relative paths from python/ directory
|
|
55
|
+
# But for the compiler, we need include paths to be absolute
|
|
56
|
+
def make_relative_source(path):
|
|
57
|
+
"""Convert absolute path to relative from python/ directory."""
|
|
58
|
+
return os.path.relpath(str(path), str(PYTHON_DIR))
|
|
59
|
+
|
|
60
|
+
# Add all C source files
|
|
61
|
+
for src_file in SRC_DIR.glob("*.c"):
|
|
62
|
+
sources.append(make_relative_source(src_file))
|
|
63
|
+
|
|
64
|
+
# Add platform implementation (POSIX for macOS/Linux)
|
|
65
|
+
platform_posix_c = PLATFORM_DIR / "posix" / "sds_platform_posix.c"
|
|
66
|
+
if platform_posix_c.exists():
|
|
67
|
+
sources.append(make_relative_source(platform_posix_c))
|
|
68
|
+
print(f"Using POSIX platform: {platform_posix_c}")
|
|
69
|
+
else:
|
|
70
|
+
print(f"Warning: Platform implementation not found at {platform_posix_c}")
|
|
71
|
+
|
|
72
|
+
# Platform-specific library paths
|
|
73
|
+
# The POSIX platform uses Paho MQTT C library (paho-mqtt3c)
|
|
74
|
+
libraries = ["paho-mqtt3c"]
|
|
75
|
+
|
|
76
|
+
if sys.platform == "darwin":
|
|
77
|
+
# macOS: Use Homebrew paths for Paho MQTT
|
|
78
|
+
homebrew_prefix = None
|
|
79
|
+
|
|
80
|
+
# Try to get Homebrew prefix
|
|
81
|
+
try:
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
["brew", "--prefix", "libpaho-mqtt"],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True
|
|
86
|
+
)
|
|
87
|
+
if result.returncode == 0:
|
|
88
|
+
homebrew_prefix = result.stdout.strip()
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# Fallback to common locations
|
|
93
|
+
if not homebrew_prefix:
|
|
94
|
+
for prefix in ["/opt/homebrew/opt/libpaho-mqtt", "/usr/local/opt/libpaho-mqtt"]:
|
|
95
|
+
if Path(prefix).exists():
|
|
96
|
+
homebrew_prefix = prefix
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
if homebrew_prefix:
|
|
100
|
+
include_dirs.append(f"{homebrew_prefix}/include")
|
|
101
|
+
library_dirs.append(f"{homebrew_prefix}/lib")
|
|
102
|
+
print(f"Using Homebrew Paho MQTT at: {homebrew_prefix}")
|
|
103
|
+
else:
|
|
104
|
+
print("Warning: Could not find Homebrew Paho MQTT installation")
|
|
105
|
+
print("Install with: brew install libpaho-mqtt")
|
|
106
|
+
|
|
107
|
+
elif sys.platform == "linux":
|
|
108
|
+
# Linux: Check for pkg-config
|
|
109
|
+
try:
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
["pkg-config", "--cflags", "--libs", "paho-mqtt3c"],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True
|
|
114
|
+
)
|
|
115
|
+
if result.returncode == 0:
|
|
116
|
+
# Parse pkg-config output
|
|
117
|
+
for flag in result.stdout.split():
|
|
118
|
+
if flag.startswith("-I"):
|
|
119
|
+
include_dirs.append(flag[2:])
|
|
120
|
+
elif flag.startswith("-L"):
|
|
121
|
+
library_dirs.append(flag[2:])
|
|
122
|
+
except FileNotFoundError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
# Define the extension module
|
|
126
|
+
ffibuilder.set_source(
|
|
127
|
+
"sds._sds_cffi",
|
|
128
|
+
source_code,
|
|
129
|
+
sources=sources,
|
|
130
|
+
include_dirs=include_dirs,
|
|
131
|
+
library_dirs=library_dirs,
|
|
132
|
+
libraries=libraries,
|
|
133
|
+
# Extra compile args for safety
|
|
134
|
+
extra_compile_args=["-Wall", "-Wextra"],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_inplace():
|
|
139
|
+
"""Build the extension in-place for development."""
|
|
140
|
+
ffibuilder.compile(verbose=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
build_inplace()
|
sds/_logging.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SDS Logging Configuration.
|
|
3
|
+
|
|
4
|
+
This module provides logging setup for the SDS library.
|
|
5
|
+
By default, the library uses a NullHandler to avoid unwanted output.
|
|
6
|
+
Applications can configure logging as needed.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
# Enable SDS logging to console
|
|
10
|
+
import logging
|
|
11
|
+
from sds import configure_logging
|
|
12
|
+
|
|
13
|
+
configure_logging(level=logging.DEBUG)
|
|
14
|
+
|
|
15
|
+
# Or configure manually
|
|
16
|
+
logging.getLogger("sds").setLevel(logging.DEBUG)
|
|
17
|
+
logging.getLogger("sds").addHandler(logging.StreamHandler())
|
|
18
|
+
"""
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
# Library logger - uses NullHandler by default (library best practice)
|
|
23
|
+
logger = logging.getLogger("sds")
|
|
24
|
+
logger.addHandler(logging.NullHandler())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def configure_logging(
|
|
28
|
+
level: int = logging.INFO,
|
|
29
|
+
format: Optional[str] = None,
|
|
30
|
+
handler: Optional[logging.Handler] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Configure logging for the SDS library.
|
|
34
|
+
|
|
35
|
+
This is a convenience function for setting up logging. For more
|
|
36
|
+
complex setups, configure the "sds" logger directly.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
level: Logging level (default: INFO)
|
|
40
|
+
format: Log format string (default: "[%(levelname)s] sds: %(message)s")
|
|
41
|
+
handler: Custom handler (default: StreamHandler to stderr)
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
>>> from sds import configure_logging
|
|
45
|
+
>>> import logging
|
|
46
|
+
>>> configure_logging(level=logging.DEBUG)
|
|
47
|
+
"""
|
|
48
|
+
sds_logger = logging.getLogger("sds")
|
|
49
|
+
sds_logger.setLevel(level)
|
|
50
|
+
|
|
51
|
+
# Remove existing handlers to avoid duplicates
|
|
52
|
+
for h in sds_logger.handlers[:]:
|
|
53
|
+
if not isinstance(h, logging.NullHandler):
|
|
54
|
+
sds_logger.removeHandler(h)
|
|
55
|
+
|
|
56
|
+
# Add the specified or default handler
|
|
57
|
+
if handler is None:
|
|
58
|
+
handler = logging.StreamHandler()
|
|
59
|
+
if format is None:
|
|
60
|
+
format = "[%(levelname)s] sds: %(message)s"
|
|
61
|
+
handler.setFormatter(logging.Formatter(format))
|
|
62
|
+
|
|
63
|
+
sds_logger.addHandler(handler)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_logger(name: str) -> logging.Logger:
|
|
67
|
+
"""
|
|
68
|
+
Get a logger for an SDS submodule.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
name: Module name (will be prefixed with "sds.")
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Logger instance
|
|
75
|
+
"""
|
|
76
|
+
if name.startswith("sds."):
|
|
77
|
+
return logging.getLogger(name)
|
|
78
|
+
return logging.getLogger(f"sds.{name}")
|
sds/_mqtt.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sds._mqtt — thin paho-mqtt transport for the pure-Python SDS engine.
|
|
3
|
+
|
|
4
|
+
A minimal wrapper that the protocol engine drives: connect (registering the
|
|
5
|
+
node's LWT will), subscribe/unsubscribe, publish (retain/QoS), and a
|
|
6
|
+
``poll()``-compatible network pump. Incoming messages are delivered to a single
|
|
7
|
+
callback as ``(topic, payload_str)``.
|
|
8
|
+
|
|
9
|
+
This module is intentionally *not* imported by ``sds/__init__`` or the
|
|
10
|
+
conformance harness — the wire/protocol engine is transport-agnostic and is
|
|
11
|
+
validated against the golden vectors without a broker. ``paho-mqtt`` is only
|
|
12
|
+
required when this layer is actually used (live broker, Phase 6+).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import paho.mqtt.client as mqtt
|
|
21
|
+
except ImportError as exc: # pragma: no cover - exercised only without paho
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"sds._mqtt requires the 'paho-mqtt' package. Install it with "
|
|
24
|
+
"`pip install paho-mqtt` (it is declared as a runtime dependency of "
|
|
25
|
+
"sds-library)."
|
|
26
|
+
) from exc
|
|
27
|
+
|
|
28
|
+
from ._protocol import is_reserved_topic
|
|
29
|
+
|
|
30
|
+
# Callback: (topic, payload) -> None
|
|
31
|
+
MessageCallback = Callable[[str, str], None]
|
|
32
|
+
|
|
33
|
+
_RESERVED_MSG = "topic '{}' is reserved (sds/ prefix); use the table API instead"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MqttTransport:
|
|
37
|
+
"""A small paho-mqtt client tuned for SDS usage."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
node_id: str,
|
|
42
|
+
host: str = "localhost",
|
|
43
|
+
port: int = 1883,
|
|
44
|
+
*,
|
|
45
|
+
keepalive: int = 60,
|
|
46
|
+
on_message: Optional[MessageCallback] = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.node_id = node_id
|
|
49
|
+
self.host = host
|
|
50
|
+
self.port = port
|
|
51
|
+
self.keepalive = keepalive
|
|
52
|
+
self._on_message = on_message
|
|
53
|
+
self._connected = False
|
|
54
|
+
|
|
55
|
+
# client_id = node_id keeps broker-side session identity aligned with SDS.
|
|
56
|
+
self._client = mqtt.Client(client_id=node_id, clean_session=True)
|
|
57
|
+
self._client.on_connect = self._handle_connect
|
|
58
|
+
self._client.on_disconnect = self._handle_disconnect
|
|
59
|
+
self._client.on_message = self._handle_message
|
|
60
|
+
|
|
61
|
+
# ---- lifecycle --------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def connect(
|
|
64
|
+
self,
|
|
65
|
+
will_topic: Optional[str] = None,
|
|
66
|
+
will_payload: Optional[str] = None,
|
|
67
|
+
*,
|
|
68
|
+
will_retain: bool = True,
|
|
69
|
+
will_qos: int = 0,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Connect to the broker, registering the LWT will if provided."""
|
|
72
|
+
if will_topic is not None and will_payload is not None:
|
|
73
|
+
self._client.will_set(
|
|
74
|
+
will_topic, payload=will_payload, qos=will_qos, retain=will_retain
|
|
75
|
+
)
|
|
76
|
+
self._client.connect(self.host, self.port, self.keepalive)
|
|
77
|
+
|
|
78
|
+
def disconnect(self) -> None:
|
|
79
|
+
self._client.disconnect()
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_connected(self) -> bool:
|
|
83
|
+
return self._connected
|
|
84
|
+
|
|
85
|
+
# ---- pub/sub ----------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def subscribe(self, topic: str, qos: int = 0) -> None:
|
|
88
|
+
self._client.subscribe(topic, qos=qos)
|
|
89
|
+
|
|
90
|
+
def unsubscribe(self, topic: str) -> None:
|
|
91
|
+
self._client.unsubscribe(topic)
|
|
92
|
+
|
|
93
|
+
def publish(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> None:
|
|
94
|
+
self._client.publish(topic, payload=payload, qos=qos, retain=retain)
|
|
95
|
+
|
|
96
|
+
# ---- raw pub/sub (reserved-prefix guarded) ----------------------------
|
|
97
|
+
|
|
98
|
+
def raw_subscribe(self, topic: str, qos: int = 0) -> None:
|
|
99
|
+
if is_reserved_topic(topic):
|
|
100
|
+
raise ValueError(_RESERVED_MSG.format(topic))
|
|
101
|
+
self.subscribe(topic, qos)
|
|
102
|
+
|
|
103
|
+
def raw_publish(self, topic: str, payload: str, qos: int = 0, retain: bool = False) -> None:
|
|
104
|
+
if is_reserved_topic(topic):
|
|
105
|
+
raise ValueError(_RESERVED_MSG.format(topic))
|
|
106
|
+
self.publish(topic, payload, qos, retain)
|
|
107
|
+
|
|
108
|
+
# ---- network pump -----------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def poll(self, timeout_ms: int = 0) -> None:
|
|
111
|
+
"""Pump the network for up to ``timeout_ms`` (poll()-compatible)."""
|
|
112
|
+
self._client.loop(timeout=max(timeout_ms, 0) / 1000.0)
|
|
113
|
+
|
|
114
|
+
# ---- paho callbacks ---------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _handle_connect(self, client, userdata, flags, rc, *args) -> None:
|
|
117
|
+
self._connected = rc == 0
|
|
118
|
+
|
|
119
|
+
def _handle_disconnect(self, client, userdata, rc, *args) -> None:
|
|
120
|
+
self._connected = False
|
|
121
|
+
|
|
122
|
+
def _handle_message(self, client, userdata, msg) -> None:
|
|
123
|
+
if self._on_message is None:
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
payload = msg.payload.decode("utf-8")
|
|
127
|
+
except (UnicodeDecodeError, AttributeError):
|
|
128
|
+
return
|
|
129
|
+
self._on_message(msg.topic, payload)
|