py-ctrl-os 0.6.1__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.
py_ctrl_os/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Python CFFI wrapper for librs_ctrl_os.so.
2
+
3
+ Auto-downloads .so from GitHub release on first import.
4
+ """
5
+
6
+ from .binding import lib, ffi, RCOS_OK, RcOsError
7
+ from .comms import PubSubManager
8
+ from .config import StaticConfig, load_config
9
+ from .downloader import ensure_so
10
+
11
+ __version__ = "0.6.1"
12
+ __all__ = [
13
+ "PubSubManager",
14
+ "StaticConfig",
15
+ "load_config",
16
+ "RcOsError",
17
+ "ensure_so",
18
+ "lib",
19
+ "ffi",
20
+ ]
py_ctrl_os/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
py_ctrl_os/binding.py ADDED
@@ -0,0 +1,139 @@
1
+ """Low-level CFFI binding for librs_ctrl_os."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from cffi import FFI
7
+
8
+ ffi = FFI()
9
+
10
+ # C declarations matching rs_ctrl_os.h
11
+ ffi.cdef("""
12
+ typedef int rcos_err_t;
13
+
14
+ #define RCOS_OK 0
15
+ #define RCOS_ERR_INVALID 1
16
+ #define RCOS_ERR_UTF8 2
17
+ #define RCOS_ERR_CONFIG 3
18
+ #define RCOS_ERR_COMMS 4
19
+ #define RCOS_ERR_SERIALIZATION 6
20
+ #define RCOS_ERR_INTERNAL 99
21
+
22
+ typedef struct RcOsTimeSyncHandle RcOsTimeSyncHandle;
23
+ typedef struct RcOsConfig RcOsConfig;
24
+ typedef struct RcOsServiceRegistry RcOsServiceRegistry;
25
+ typedef struct RcOsPubSub RcOsPubSub;
26
+
27
+ rcos_err_t rs_ctrl_os_init_logging(void);
28
+ rcos_err_t rs_ctrl_os_last_error(char *buf, size_t buf_len);
29
+ void rs_ctrl_os_str_free(char *p);
30
+
31
+ RcOsTimeSyncHandle *rs_ctrl_os_time_sync_new(void);
32
+ void rs_ctrl_os_time_sync_destroy(RcOsTimeSyncHandle *p);
33
+ uint64_t rs_ctrl_os_time_sync_now_ms(const RcOsTimeSyncHandle *p);
34
+ int rs_ctrl_os_time_sync_is_synced(const RcOsTimeSyncHandle *p);
35
+
36
+ RcOsConfig *rs_ctrl_os_config_open(const char *path_utf8);
37
+ void rs_ctrl_os_config_destroy(RcOsConfig *p);
38
+ rcos_err_t rs_ctrl_os_config_get_dynamic_toml(const RcOsConfig *cfg, char **out_toml);
39
+ char *rs_ctrl_os_config_get_my_id(const RcOsConfig *cfg);
40
+ char *rs_ctrl_os_config_get_host(const RcOsConfig *cfg);
41
+ uint16_t rs_ctrl_os_config_get_port(const RcOsConfig *cfg);
42
+ int rs_ctrl_os_config_get_is_master(const RcOsConfig *cfg);
43
+ int64_t rs_ctrl_os_config_get_publish_hz(const RcOsConfig *cfg);
44
+ int64_t rs_ctrl_os_config_get_subscribe_hz(const RcOsConfig *cfg);
45
+ int rs_ctrl_os_config_get_dynamic_load_enable(const RcOsConfig *cfg);
46
+
47
+ RcOsServiceRegistry *rs_ctrl_os_discovery_start(
48
+ const char *my_id, const char *my_host, uint16_t my_port,
49
+ int is_master, const RcOsTimeSyncHandle *time_sync);
50
+ void rs_ctrl_os_registry_destroy(RcOsServiceRegistry *p);
51
+
52
+ RcOsPubSub *rs_ctrl_os_pubsub_new(const RcOsConfig *cfg, RcOsServiceRegistry *registry);
53
+ void rs_ctrl_os_pubsub_destroy(RcOsPubSub *p);
54
+ void rs_ctrl_os_pubsub_set_publish_hz(RcOsPubSub *bus, int64_t hz);
55
+ void rs_ctrl_os_pubsub_set_subscribe_hz(RcOsPubSub *bus, int64_t hz);
56
+
57
+ rcos_err_t rs_ctrl_os_pubsub_publish_raw(
58
+ RcOsPubSub *bus, const char *topic_key, const char *sub_topic,
59
+ const uint8_t *payload, size_t payload_len);
60
+
61
+ rcos_err_t rs_ctrl_os_pubsub_try_recv_raw(
62
+ RcOsPubSub *bus, const char *local_name,
63
+ char **sender_id_out, char **sub_topic_out,
64
+ uint8_t **payload_out, size_t *payload_len_out,
65
+ int *got_message_out);
66
+
67
+ void rs_ctrl_os_payload_free(uint8_t *p, size_t len);
68
+
69
+ rcos_err_t rs_ctrl_os_pubsub_set_sub_topics(
70
+ RcOsPubSub *bus, const char *local_name,
71
+ const char *const *topics, size_t topic_count);
72
+
73
+ rcos_err_t rs_ctrl_os_pubsub_publish_request(
74
+ RcOsPubSub *bus, const char *topic_key, const char *sub_topic,
75
+ uint64_t request_id, const uint8_t *payload, size_t payload_len);
76
+
77
+ rcos_err_t rs_ctrl_os_pubsub_publish_response(
78
+ RcOsPubSub *bus, const char *topic_key, const char *sub_topic,
79
+ uint64_t request_id, const uint8_t *payload, size_t payload_len);
80
+
81
+ rcos_err_t rs_ctrl_os_pubsub_try_recv_request(
82
+ RcOsPubSub *bus, const char *local_name,
83
+ char **sender_id_out, char **sub_topic_out,
84
+ uint64_t *request_id_out, uint8_t **payload_out,
85
+ size_t *payload_len_out, int *got_message_out);
86
+
87
+ rcos_err_t rs_ctrl_os_pubsub_try_recv_response(
88
+ RcOsPubSub *bus, const char *local_name,
89
+ char **sender_id_out, char **sub_topic_out,
90
+ uint64_t *request_id_out, uint8_t **payload_out,
91
+ size_t *payload_len_out, int *got_message_out);
92
+ """)
93
+
94
+ # Constants
95
+ RCOS_OK = 0
96
+ RCOS_ERR_INVALID = 1
97
+ RCOS_ERR_UTF8 = 2
98
+ RCOS_ERR_CONFIG = 3
99
+ RCOS_ERR_COMMS = 4
100
+ RCOS_ERR_SERIALIZATION = 6
101
+ RCOS_ERR_INTERNAL = 99
102
+
103
+
104
+ def _find_so() -> str:
105
+ """Find librs_ctrl_os.so, auto-download if needed."""
106
+ from .downloader import ensure_so
107
+ try:
108
+ return ensure_so()
109
+ except Exception as e:
110
+ raise FileNotFoundError(
111
+ f"librs_ctrl_os.so not found and auto-download failed: {e}\n"
112
+ f"Please download manually from https://github.com/LycanW/rs_ctrl_os/releases"
113
+ )
114
+
115
+
116
+ lib = ffi.dlopen(_find_so())
117
+
118
+
119
+ class RcOsError(Exception):
120
+ """rs_ctrl_os error with code and message."""
121
+
122
+ def __init__(self, code: int, msg: str = ""):
123
+ self.code = code
124
+ super().__init__(msg or f"rs_ctrl_os error {code}")
125
+
126
+
127
+ def check(err: int) -> None:
128
+ """Check return code, raise RcOsError on failure."""
129
+ if err != RCOS_OK:
130
+ buf = ffi.new("char[]", 256)
131
+ lib.rs_ctrl_os_last_error(buf, 256)
132
+ msg = ffi.string(buf).decode("utf-8", errors="replace")
133
+ raise RcOsError(err, msg)
134
+
135
+
136
+ def _free_str(p) -> None:
137
+ """Free a string returned by rs_ctrl_os."""
138
+ if p != ffi.NULL:
139
+ lib.rs_ctrl_os_str_free(p)
py_ctrl_os/cli.py ADDED
@@ -0,0 +1,19 @@
1
+ """CLI tool for py_ctrl_os (CFFI version)."""
2
+
3
+ import sys
4
+ from .config import load_config
5
+
6
+
7
+ def main():
8
+ path = sys.argv[1] if len(sys.argv) > 1 else "config.toml"
9
+ static, dynamic = load_config(path)
10
+ print(f"Node ID: {static.my_id}")
11
+ print(f"Host: {static.host}:{static.port}")
12
+ print(f"Master: {static.is_master}")
13
+ print(f"Publish Hz: {static.publish_hz}")
14
+ print(f"Subscribe Hz: {static.subscribe_hz}")
15
+ print(f"Dynamic: {dynamic}")
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
py_ctrl_os/comms.py ADDED
@@ -0,0 +1,206 @@
1
+ """PubSubManager - Python wrapper around rs_ctrl_os C FFI."""
2
+
3
+ import json
4
+ from typing import Optional
5
+
6
+ from .binding import ffi, lib, check, _free_str, RCOS_OK
7
+
8
+
9
+ class PubSubManager:
10
+ """ZMQ PUB/SUB manager backed by librs_ctrl_os.so.
11
+
12
+ Wire format: 3-frame multipart [sender_id, sub_topic, payload]
13
+ Identical behavior to the Rust PubSubManager.
14
+ """
15
+
16
+ def __init__(self, static_cfg, registry=None):
17
+ """
18
+ Args:
19
+ static_cfg: StaticConfig or path to TOML config file.
20
+ registry: Optional RcOsServiceRegistry pointer. If None, starts discovery.
21
+ """
22
+ from .config import StaticConfig
23
+
24
+ if isinstance(static_cfg, str):
25
+ from .config import load_config
26
+ static_cfg, _ = load_config(static_cfg)
27
+
28
+ self._cfg_path = static_cfg._config_path
29
+ self._owns_registry = registry is None
30
+
31
+ # Open config
32
+ self._cfg = lib.rs_ctrl_os_config_open(self._cfg_path.encode())
33
+ if self._cfg == ffi.NULL:
34
+ raise RuntimeError(f"Failed to open config: {self._cfg_path}")
35
+
36
+ # Start discovery if no registry provided
37
+ if registry is None:
38
+ my_id = lib.rs_ctrl_os_config_get_my_id(self._cfg)
39
+ my_host = lib.rs_ctrl_os_config_get_host(self._cfg)
40
+ my_port = lib.rs_ctrl_os_config_get_port(self._cfg)
41
+ is_master = lib.rs_ctrl_os_config_get_is_master(self._cfg)
42
+
43
+ registry = lib.rs_ctrl_os_discovery_start(
44
+ my_id, my_host, my_port, is_master, ffi.NULL
45
+ )
46
+ _free_str(my_id)
47
+ _free_str(my_host)
48
+
49
+ if registry == ffi.NULL:
50
+ lib.rs_ctrl_os_config_destroy(self._cfg)
51
+ raise RuntimeError("Failed to start discovery")
52
+
53
+ # Create PubSubManager (consumes registry on success)
54
+ self._bus = lib.rs_ctrl_os_pubsub_new(self._cfg, registry)
55
+ if self._bus == ffi.NULL:
56
+ # registry ownership not consumed, destroy it
57
+ if self._owns_registry:
58
+ lib.rs_ctrl_os_registry_destroy(registry)
59
+ lib.rs_ctrl_os_config_destroy(self._cfg)
60
+ raise RuntimeError("Failed to create PubSubManager")
61
+
62
+ def set_publish_hz(self, hz: int) -> None:
63
+ lib.rs_ctrl_os_pubsub_set_publish_hz(self._bus, hz)
64
+
65
+ def set_subscribe_hz(self, hz: int) -> None:
66
+ lib.rs_ctrl_os_pubsub_set_subscribe_hz(self._bus, hz)
67
+
68
+ def set_sub_topics(self, local_name: str, topics: list[str]) -> None:
69
+ if topics:
70
+ c_topics = [ffi.new("char[]", t.encode()) for t in topics]
71
+ topics_arr = ffi.new("const char *[]", c_topics)
72
+ check(lib.rs_ctrl_os_pubsub_set_sub_topics(
73
+ self._bus, local_name.encode(), topics_arr, len(topics)
74
+ ))
75
+ else:
76
+ check(lib.rs_ctrl_os_pubsub_set_sub_topics(
77
+ self._bus, local_name.encode(), ffi.NULL, 0
78
+ ))
79
+
80
+ def publish_raw(self, topic_key: str, sub_topic: str, payload: bytes) -> None:
81
+ check(lib.rs_ctrl_os_pubsub_publish_raw(
82
+ self._bus,
83
+ topic_key.encode(),
84
+ sub_topic.encode(),
85
+ payload,
86
+ len(payload),
87
+ ))
88
+
89
+ def publish_json(self, topic_key: str, sub_topic: str, data: dict) -> None:
90
+ payload = json.dumps(data).encode()
91
+ self.publish_raw(topic_key, sub_topic, payload)
92
+
93
+ def try_recv_raw(self, local_name: str) -> Optional[tuple[str, str, bytes]]:
94
+ sender_out = ffi.new("char **")
95
+ topic_out = ffi.new("char **")
96
+ payload_out = ffi.new("uint8_t **")
97
+ payload_len_out = ffi.new("size_t *")
98
+ got_out = ffi.new("int *")
99
+
100
+ check(lib.rs_ctrl_os_pubsub_try_recv_raw(
101
+ self._bus, local_name.encode(),
102
+ sender_out, topic_out, payload_out, payload_len_out, got_out,
103
+ ))
104
+
105
+ if got_out[0] == 0:
106
+ return None
107
+
108
+ sender = ffi.string(sender_out[0]).decode("utf-8", errors="replace")
109
+ topic = ffi.string(topic_out[0]).decode("utf-8", errors="replace")
110
+ payload = bytes(ffi.buffer(payload_out[0], payload_len_out[0]))
111
+
112
+ _free_str(sender_out[0])
113
+ _free_str(topic_out[0])
114
+ lib.rs_ctrl_os_payload_free(payload_out[0], payload_len_out[0])
115
+
116
+ return sender, topic, payload
117
+
118
+ def try_recv_json(self, local_name: str) -> Optional[tuple[str, str, dict]]:
119
+ result = self.try_recv_raw(local_name)
120
+ if result is None:
121
+ return None
122
+ sender, topic, payload = result
123
+ try:
124
+ data = json.loads(payload)
125
+ except Exception:
126
+ return None
127
+ return sender, topic, data
128
+
129
+ def publish_request(self, topic_key: str, sub_topic: str,
130
+ request_id: int, payload: bytes) -> None:
131
+ check(lib.rs_ctrl_os_pubsub_publish_request(
132
+ self._bus, topic_key.encode(), sub_topic.encode(),
133
+ request_id, payload, len(payload),
134
+ ))
135
+
136
+ def publish_response(self, topic_key: str, sub_topic: str,
137
+ request_id: int, payload: bytes) -> None:
138
+ check(lib.rs_ctrl_os_pubsub_publish_response(
139
+ self._bus, topic_key.encode(), sub_topic.encode(),
140
+ request_id, payload, len(payload),
141
+ ))
142
+
143
+ def try_recv_request(self, local_name: str):
144
+ sender_out = ffi.new("char **")
145
+ topic_out = ffi.new("char **")
146
+ rid_out = ffi.new("uint64_t *")
147
+ payload_out = ffi.new("uint8_t **")
148
+ payload_len_out = ffi.new("size_t *")
149
+ got_out = ffi.new("int *")
150
+
151
+ check(lib.rs_ctrl_os_pubsub_try_recv_request(
152
+ self._bus, local_name.encode(),
153
+ sender_out, topic_out, rid_out, payload_out, payload_len_out, got_out,
154
+ ))
155
+
156
+ if got_out[0] == 0:
157
+ return None
158
+
159
+ sender = ffi.string(sender_out[0]).decode("utf-8", errors="replace")
160
+ topic = ffi.string(topic_out[0]).decode("utf-8", errors="replace")
161
+ payload = bytes(ffi.buffer(payload_out[0], payload_len_out[0]))
162
+ rid = rid_out[0]
163
+
164
+ _free_str(sender_out[0])
165
+ _free_str(topic_out[0])
166
+ lib.rs_ctrl_os_payload_free(payload_out[0], payload_len_out[0])
167
+
168
+ return sender, rid, topic, payload
169
+
170
+ def try_recv_response(self, local_name: str):
171
+ sender_out = ffi.new("char **")
172
+ topic_out = ffi.new("char **")
173
+ rid_out = ffi.new("uint64_t *")
174
+ payload_out = ffi.new("uint8_t **")
175
+ payload_len_out = ffi.new("size_t *")
176
+ got_out = ffi.new("int *")
177
+
178
+ check(lib.rs_ctrl_os_pubsub_try_recv_response(
179
+ self._bus, local_name.encode(),
180
+ sender_out, topic_out, rid_out, payload_out, payload_len_out, got_out,
181
+ ))
182
+
183
+ if got_out[0] == 0:
184
+ return None
185
+
186
+ sender = ffi.string(sender_out[0]).decode("utf-8", errors="replace")
187
+ topic = ffi.string(topic_out[0]).decode("utf-8", errors="replace")
188
+ payload = bytes(ffi.buffer(payload_out[0], payload_len_out[0]))
189
+ rid = rid_out[0]
190
+
191
+ _free_str(sender_out[0])
192
+ _free_str(topic_out[0])
193
+ lib.rs_ctrl_os_payload_free(payload_out[0], payload_len_out[0])
194
+
195
+ return sender, rid, topic, payload
196
+
197
+ def close(self) -> None:
198
+ if hasattr(self, "_bus") and self._bus != ffi.NULL:
199
+ lib.rs_ctrl_os_pubsub_destroy(self._bus)
200
+ self._bus = ffi.NULL
201
+ if hasattr(self, "_cfg") and self._cfg != ffi.NULL:
202
+ lib.rs_ctrl_os_config_destroy(self._cfg)
203
+ self._cfg = ffi.NULL
204
+
205
+ def __del__(self):
206
+ self.close()
py_ctrl_os/config.py ADDED
@@ -0,0 +1,86 @@
1
+ """Config loading - wraps rs_ctrl_os C FFI config functions."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Dict, Optional
6
+
7
+ from .binding import ffi, lib, _free_str
8
+
9
+
10
+ @dataclass
11
+ class StaticConfig:
12
+ """Mirrors rs_ctrl_os::config::StaticBase."""
13
+
14
+ my_id: str
15
+ host: str
16
+ port: int
17
+ is_master: bool = False
18
+ publishers: Dict[str, str] = field(default_factory=dict)
19
+ subscribers: Dict[str, str] = field(default_factory=dict)
20
+ static_nodes: Dict[str, str] = field(default_factory=dict)
21
+ publish_hz: int = 0
22
+ subscribe_hz: int = 0
23
+ dynamic_load_enable: bool = True
24
+ _config_path: str = ""
25
+
26
+
27
+ def load_config(path: str | Path) -> tuple[StaticConfig, dict]:
28
+ """Load TOML config via rs_ctrl_os C FFI.
29
+
30
+ Returns:
31
+ (static_config, dynamic_dict)
32
+ """
33
+ path = str(Path(path).resolve())
34
+
35
+ cfg = lib.rs_ctrl_os_config_open(path.encode())
36
+ if cfg == ffi.NULL:
37
+ raise RuntimeError(f"Failed to open config: {path}")
38
+
39
+ try:
40
+ # Read static fields
41
+ my_id_p = lib.rs_ctrl_os_config_get_my_id(cfg)
42
+ host_p = lib.rs_ctrl_os_config_get_host(cfg)
43
+
44
+ my_id = ffi.string(my_id_p).decode()
45
+ host = ffi.string(host_p).decode()
46
+ port = lib.rs_ctrl_os_config_get_port(cfg)
47
+ is_master = bool(lib.rs_ctrl_os_config_get_is_master(cfg))
48
+ publish_hz = lib.rs_ctrl_os_config_get_publish_hz(cfg)
49
+ subscribe_hz = lib.rs_ctrl_os_config_get_subscribe_hz(cfg)
50
+ dynamic_load_enable = bool(lib.rs_ctrl_os_config_get_dynamic_load_enable(cfg))
51
+
52
+ _free_str(my_id_p)
53
+ _free_str(host_p)
54
+
55
+ # Read dynamic TOML
56
+ dyn_out = ffi.new("char **")
57
+ dynamic = {}
58
+ if lib.rs_ctrl_os_config_get_dynamic_toml(cfg, dyn_out) == 0:
59
+ dyn_str = ffi.string(dyn_out[0]).decode()
60
+ _free_str(dyn_out[0])
61
+ if dyn_str.strip():
62
+ try:
63
+ import tomli
64
+ dynamic = tomli.loads(f"[dynamic]\n{dyn_str}")["dynamic"]
65
+ except Exception:
66
+ try:
67
+ import tomllib
68
+ dynamic = tomllib.loads(f"[dynamic]\n{dyn_str}")["dynamic"]
69
+ except Exception:
70
+ dynamic = {}
71
+
72
+ static = StaticConfig(
73
+ my_id=my_id,
74
+ host=host,
75
+ port=port,
76
+ is_master=is_master,
77
+ publish_hz=publish_hz,
78
+ subscribe_hz=subscribe_hz,
79
+ dynamic_load_enable=dynamic_load_enable,
80
+ _config_path=path,
81
+ )
82
+
83
+ return static, dynamic
84
+
85
+ finally:
86
+ lib.rs_ctrl_os_config_destroy(cfg)
@@ -0,0 +1,104 @@
1
+ """Auto-download librs_ctrl_os.so from GitHub releases."""
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import tarfile
7
+ from pathlib import Path
8
+ from urllib.request import urlopen
9
+
10
+ GITHUB_RELEASE = "https://github.com/LycanW/rs_ctrl_os/releases/download/v0.6.1"
11
+
12
+
13
+ def _platform_tag() -> str:
14
+ """Return platform tag like 'linux-amd64' or 'linux-aarch64'."""
15
+ machine = platform.machine().lower()
16
+ if machine in ("x86_64", "amd64"):
17
+ return "linux-amd64"
18
+ elif machine in ("aarch64", "arm64"):
19
+ return "linux-aarch64"
20
+ else:
21
+ raise RuntimeError(f"Unsupported platform: {machine}")
22
+
23
+
24
+ def _cache_dir() -> Path:
25
+ """Cache directory for downloaded .so."""
26
+ cache = Path.home() / ".cache" / "py_ctrl_os"
27
+ cache.mkdir(parents=True, exist_ok=True)
28
+ return cache
29
+
30
+
31
+ def _so_path() -> Path:
32
+ """Path to librs_ctrl_os.so."""
33
+ cache = _cache_dir()
34
+ so = cache / "librs_ctrl_os.so"
35
+ return so
36
+
37
+
38
+ def _download() -> Path:
39
+ """Download librs_ctrl_os.so from GitHub release."""
40
+ tag = _platform_tag()
41
+ tar_name = f"rs_ctrl_os-v0.6.1-{tag}.tar.gz"
42
+ url = f"{GITHUB_RELEASE}/{tar_name}"
43
+ cache = _cache_dir()
44
+ tar_path = cache / tar_name
45
+
46
+ if not tar_path.exists():
47
+ print(f"[py_ctrl_os] Downloading {tar_name}...")
48
+ with urlopen(url, timeout=60) as resp:
49
+ if resp.status != 200:
50
+ raise RuntimeError(
51
+ f"Failed to download {url}: {resp.status}\n"
52
+ f"Please download manually from {GITHUB_RELEASE}"
53
+ )
54
+ tar_path.write_bytes(resp.read())
55
+
56
+ # Extract
57
+ extract_dir = cache / f"rs_ctrl_os-v0.6.1-{tag}"
58
+ if not extract_dir.exists():
59
+ with tarfile.open(tar_path, "r:gz") as tar:
60
+ tar.extractall(cache)
61
+
62
+ # Copy .so to cache root
63
+ so_src = extract_dir / "librs_ctrl_os.so"
64
+ so_dst = cache / "librs_ctrl_os.so"
65
+ shutil.copy2(so_src, so_dst)
66
+
67
+ # Also copy .h
68
+ h_src = extract_dir / "rs_ctrl_os.h"
69
+ h_dst = cache / "rs_ctrl_os.h"
70
+ if h_src.exists():
71
+ shutil.copy2(h_src, h_dst)
72
+
73
+ print(f"[py_ctrl_os] Installed librs_ctrl_os.so to {so_dst}")
74
+ return so_dst
75
+
76
+
77
+ def ensure_so() -> str:
78
+ """Ensure librs_ctrl_os.so is available, download if needed."""
79
+ # 1. Check cache
80
+ so = _so_path()
81
+ if so.exists():
82
+ return str(so)
83
+
84
+ # 2. Check bundled (for wheel distribution)
85
+ bundled = Path(__file__).parent / "librs_ctrl_os.so"
86
+ if bundled.exists():
87
+ return str(bundled)
88
+
89
+ # 3. Check LD_LIBRARY_PATH
90
+ for p in os.environ.get("LD_LIBRARY_PATH", "").split(":"):
91
+ if p:
92
+ candidate = Path(p) / "librs_ctrl_os.so"
93
+ if candidate.exists():
94
+ return str(candidate)
95
+
96
+ # 4. Check system paths
97
+ for p in ("/usr/local/lib", "/usr/lib"):
98
+ candidate = Path(p) / "librs_ctrl_os.so"
99
+ if candidate.exists():
100
+ return str(candidate)
101
+
102
+ # 5. Download
103
+ so = _download()
104
+ return str(so)
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-ctrl-os
3
+ Version: 0.6.1
4
+ Summary: Python CFFI bindings for rs_ctrl_os - distributed ZMQ pub/sub
5
+ Author-email: Han Wang <wanghan0410@126.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/LycanW/rs_ctrl_os
8
+ Project-URL: Repository, https://github.com/LycanW/rs_ctrl_os
9
+ Project-URL: Issues, https://github.com/LycanW/rs_ctrl_os/issues
10
+ Keywords: zmq,pubsub,distributed,cffi,rs_ctrl_os
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: System :: Networking
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: cffi>=1.16
22
+
23
+ # py-ctrl-os
24
+
25
+ Python CFFI bindings for `rs_ctrl_os` — a distributed ZMQ pub/sub runtime.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install py-ctrl-os
31
+ ```
32
+
33
+ On first import, the library auto-downloads `librs_ctrl_os.so` from GitHub releases.
34
+
35
+ ## Quick Start
36
+
37
+ ```python
38
+ from py_ctrl_os import PubSubManager, load_config
39
+
40
+ static, dynamic = load_config("config.toml")
41
+ bus = PubSubManager(static)
42
+
43
+ # Publish JSON
44
+ bus.publish_json("my_node", "status", {"hello": "world"})
45
+
46
+ # Receive
47
+ result = bus.try_recv_json("from_other_node")
48
+ if result:
49
+ sender, topic, data = result
50
+ print(f"From {sender}: {data}")
51
+ ```
52
+
53
+ ## Requirements
54
+
55
+ - Python 3.10+
56
+ - Linux x86_64 or aarch64
57
+ - `libzmq` (system library, usually pre-installed)
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,12 @@
1
+ py_ctrl_os/__init__.py,sha256=VbFpcxuFDL0Dl_vxcJmeqO3E87hkMKnSyegLFTgp3lI,423
2
+ py_ctrl_os/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ py_ctrl_os/binding.py,sha256=xQDDpkReELvAbtAQWCniXwMyGp2kEQneBZX-Tn4M6oY,4736
4
+ py_ctrl_os/cli.py,sha256=owccLMlXV3yTNVAfu-hFjvKzVLb_upGgoIj97io5Qp8,529
5
+ py_ctrl_os/comms.py,sha256=zClVInnoXJhTcrQAv1HEwFKqrongUPf8oY0D5X_rt30,7606
6
+ py_ctrl_os/config.py,sha256=VktfvkvQoMywVJhYcBau4u5Fny71huhDrFyNODWDE-M,2693
7
+ py_ctrl_os/downloader.py,sha256=aPWtUqJINB6MvbdLX9ij-YkNhYYC9ZCKokNhmumxsVE,2990
8
+ py_ctrl_os-0.6.1.dist-info/METADATA,sha256=c8ZXx-Ej2JAIYGFW0tVl_yB7ENvpIxzLScQCPExGhyk,1610
9
+ py_ctrl_os-0.6.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ py_ctrl_os-0.6.1.dist-info/entry_points.txt,sha256=GouR-R7znpvEAyHKWEoKnBHlFOjsp5GsItKtZSnbZOE,56
11
+ py_ctrl_os-0.6.1.dist-info/top_level.txt,sha256=TSx72CGZOjhCEv3oeGIozN39zkKczZnDBRwoPYuUh9A,11
12
+ py_ctrl_os-0.6.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ py-ctrl-os-info = py_ctrl_os.cli:main
@@ -0,0 +1 @@
1
+ py_ctrl_os