comettextel 1.5.0__py3-none-win_amd64.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.
@@ -0,0 +1,39 @@
1
+ """CometTextel — thin Python FFI for the C ABI (PDU + modem).
2
+
3
+ Requires the shared library from the C SDK (`comettextel.dll` / `libcomettextel.so`).
4
+ Does not reimplement PDU codecs.
5
+
6
+ Copyright (c) Ji-Feng Tsai. All rights reserved.
7
+ Code released under the MIT license.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .errors import CometTextelError, Status
13
+ from .modem import GsmModem
14
+ from .pdu import (
15
+ DCS_8BIT,
16
+ DCS_GSM7,
17
+ DCS_UCS2,
18
+ Message,
19
+ decode,
20
+ encode_submit,
21
+ encode_submit_segments,
22
+ status_string,
23
+ )
24
+
25
+ __all__ = [
26
+ "CometTextelError",
27
+ "Status",
28
+ "DCS_GSM7",
29
+ "DCS_8BIT",
30
+ "DCS_UCS2",
31
+ "Message",
32
+ "GsmModem",
33
+ "decode",
34
+ "encode_submit",
35
+ "encode_submit_segments",
36
+ "status_string",
37
+ ]
38
+
39
+ __version__ = "1.5.0"
comettextel/_lib.py ADDED
@@ -0,0 +1,267 @@
1
+ """Locate and load ``comettextel`` shared library; bind C ABI exports.
2
+
3
+ Copyright (c) Ji-Feng Tsai. All rights reserved.
4
+ Code released under the MIT license.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ctypes
10
+ import os
11
+ import sys
12
+ from ctypes import (
13
+ POINTER,
14
+ c_char,
15
+ c_char_p,
16
+ c_int,
17
+ c_int32,
18
+ c_size_t,
19
+ c_uint32,
20
+ c_void_p,
21
+ )
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ # Layout must match struct ct_message in include/comettextel/c_api.h.
26
+ class CtMessage(ctypes.Structure):
27
+ """Structure representing a CT message."""
28
+
29
+ _fields_ = [
30
+ ("index", c_int32),
31
+ ("dcs", c_int32),
32
+ ("has_udh", c_int32),
33
+ ("service_center", c_char * 32),
34
+ ("peer_address", c_char * 32),
35
+ ("service_timestamp", c_char * 32),
36
+ ("user_data", c_char * 512),
37
+ ("is_concatenated", c_int32),
38
+ ("concat_ref", c_int32),
39
+ ("concat_total", c_int32),
40
+ ("concat_seq", c_int32),
41
+ ]
42
+
43
+
44
+ _lib: Optional[ctypes.CDLL] = None
45
+
46
+
47
+ def _candidate_names() -> list[str]:
48
+ """Return a list of candidate library names."""
49
+
50
+ if sys.platform == "win32":
51
+ return ["comettextel.dll"]
52
+ if sys.platform == "darwin":
53
+ return ["libcomettextel.dylib", "comettextel.dylib"]
54
+ return ["libcomettextel.so", "libcomettextel.so.1", "comettextel.so"]
55
+
56
+
57
+ def _is_usable_lib(path: Path) -> bool:
58
+ """True when @p path is a loadable shared library (file or symlink to one)."""
59
+
60
+ try:
61
+ return path.is_file() and not path.name.endswith(".a")
62
+ except OSError:
63
+ return False
64
+
65
+
66
+ def _glob_libs_in(directory: Path) -> list[Path]:
67
+ """Find versioned shared libs that fixed candidate names may miss."""
68
+
69
+ if sys.platform == "win32":
70
+ patterns = ["comettextel.dll"]
71
+ elif sys.platform == "darwin":
72
+ patterns = ["libcomettextel*.dylib", "comettextel*.dylib"]
73
+ else:
74
+ patterns = ["libcomettextel.so*"]
75
+
76
+ found: list[Path] = []
77
+ for pattern in patterns:
78
+ for candidate in sorted(directory.glob(pattern)):
79
+ if _is_usable_lib(candidate):
80
+ found.append(candidate)
81
+ return found
82
+
83
+
84
+ def _packaged_native_dir() -> Optional[Path]:
85
+ """Directory of wheel-embedded native libs (``_native/<tag>/``), if present."""
86
+
87
+ here = Path(__file__).resolve().parent
88
+ native = here / "_native"
89
+ if sys.platform == "win32":
90
+ tag = "win_amd64"
91
+ elif sys.platform.startswith("linux"):
92
+ tag = "linux_x86_64"
93
+ else:
94
+ return None
95
+ candidate = native / tag
96
+ return candidate if candidate.is_dir() else None
97
+
98
+
99
+ def _search_dirs(explicit: Optional[Path]) -> list[Path]:
100
+ """Search for the shared library in the given directories."""
101
+
102
+ dirs: list[Path] = []
103
+ if explicit is not None:
104
+ dirs.append(explicit if explicit.is_dir() else explicit.parent)
105
+
106
+ env = os.environ.get("COMETTEXTEL_LIB")
107
+ if env:
108
+ p = Path(env)
109
+ dirs.append(p if p.is_dir() else p.parent)
110
+
111
+ packaged = _packaged_native_dir()
112
+ if packaged is not None:
113
+ dirs.append(packaged)
114
+
115
+ # sdk/python/comettextel/_lib.py → parents[1]=sdk/python, [2]=sdk, [3]=repo root
116
+ here = Path(__file__).resolve()
117
+ sdk_python = here.parents[1]
118
+ repo = here.parents[3] if len(here.parents) > 3 else here.parents[-1]
119
+
120
+ dirs.extend(
121
+ [
122
+ Path.cwd(),
123
+ sdk_python,
124
+ sdk_python / "examples",
125
+ repo / "artifact" / "comettextel-c-sdk-windows-x64" / "bin",
126
+ repo / "artifact" / "comettextel-c-sdk-linux-x64" / "lib",
127
+ repo / "build-c-sdk" / "Release",
128
+ repo / "build-c-sdk" / "Debug",
129
+ repo / "build" / "Release",
130
+ repo / "build" / "Debug",
131
+ repo / "build",
132
+ ]
133
+ )
134
+
135
+ # Deduplicate while preserving order.
136
+ seen: set[str] = set()
137
+ out: list[Path] = []
138
+ for d in dirs:
139
+ key = str(d.resolve()) if d.exists() else str(d)
140
+ if key not in seen:
141
+ seen.add(key)
142
+ out.append(d)
143
+ return out
144
+
145
+
146
+ def find_library(path: Optional[str | Path] = None) -> Path:
147
+ """Resolve the shared library path.
148
+
149
+ Search order: explicit path / ``COMETTEXTEL_LIB`` (file or directory) /
150
+ cwd / common build & artifact folders next to this package.
151
+ """
152
+
153
+ explicit: Optional[Path] = Path(path) if path else None
154
+ if explicit is not None and _is_usable_lib(explicit):
155
+ return explicit.resolve()
156
+
157
+ # Prefer COMETTEXTEL_LIB when it already points at a shared library file
158
+ # (e.g. libcomettextel.so.1.3.0 from the staged Linux C SDK).
159
+ if explicit is None:
160
+ env = os.environ.get("COMETTEXTEL_LIB")
161
+ if env:
162
+ env_path = Path(env)
163
+ if _is_usable_lib(env_path):
164
+ return env_path.resolve()
165
+
166
+ names = _candidate_names()
167
+ for directory in _search_dirs(explicit):
168
+ if not directory.is_dir():
169
+ continue
170
+ for name in names:
171
+ candidate = directory / name
172
+ if _is_usable_lib(candidate):
173
+ return candidate.resolve()
174
+ # Fall back to versioned sonames (libcomettextel.so.1.3.0, …).
175
+ versioned = _glob_libs_in(directory)
176
+ if versioned:
177
+ return versioned[0].resolve()
178
+
179
+ raise FileNotFoundError(
180
+ "comettextel shared library not found. Set COMETTEXTEL_LIB to the DLL/SO "
181
+ "path (or its directory), or place it next to the example / on PATH. "
182
+ f"Looked for: {', '.join(names)}"
183
+ )
184
+
185
+
186
+ def load(path: Optional[str | Path] = None) -> ctypes.CDLL:
187
+ """Load (or return cached) shared library and configure C ABI prototypes."""
188
+
189
+ global _lib
190
+ if _lib is not None and path is None:
191
+ return _lib
192
+
193
+ lib_path = find_library(path)
194
+ if sys.platform == "win32":
195
+ # Ensure dependent MSVC runtime resolution from the DLL folder.
196
+ os.add_dll_directory(str(lib_path.parent))
197
+ loaded = ctypes.WinDLL(str(lib_path))
198
+ else:
199
+ loaded = ctypes.CDLL(str(lib_path))
200
+
201
+ loaded.ct_status_string.argtypes = [c_int]
202
+ loaded.ct_status_string.restype = c_char_p
203
+
204
+ loaded.ct_modem_create.argtypes = []
205
+ loaded.ct_modem_create.restype = c_void_p
206
+
207
+ loaded.ct_modem_destroy.argtypes = [c_void_p]
208
+ loaded.ct_modem_destroy.restype = None
209
+
210
+ loaded.ct_modem_open.argtypes = [c_void_p, c_char_p, c_uint32]
211
+ loaded.ct_modem_open.restype = c_int
212
+
213
+ loaded.ct_modem_send.argtypes = [
214
+ c_void_p,
215
+ c_char_p,
216
+ c_char_p,
217
+ c_char_p,
218
+ c_int,
219
+ c_int,
220
+ ]
221
+ loaded.ct_modem_send.restype = c_int
222
+
223
+ loaded.ct_modem_list.argtypes = [
224
+ c_void_p,
225
+ POINTER(CtMessage),
226
+ c_int,
227
+ POINTER(c_int),
228
+ c_int,
229
+ ]
230
+ loaded.ct_modem_list.restype = c_int
231
+
232
+ loaded.ct_modem_delete.argtypes = [c_void_p, c_int, c_int]
233
+ loaded.ct_modem_delete.restype = c_int
234
+
235
+ loaded.ct_pdu_encode_submit.argtypes = [
236
+ c_char_p,
237
+ c_char_p,
238
+ c_char_p,
239
+ c_int,
240
+ c_void_p,
241
+ c_size_t,
242
+ ]
243
+ loaded.ct_pdu_encode_submit.restype = c_int
244
+
245
+ loaded.ct_pdu_encode_submit_segments.argtypes = [
246
+ c_char_p,
247
+ c_char_p,
248
+ c_char_p,
249
+ c_int,
250
+ c_void_p,
251
+ c_size_t,
252
+ POINTER(c_int),
253
+ ]
254
+ loaded.ct_pdu_encode_submit_segments.restype = c_int
255
+
256
+ loaded.ct_pdu_decode.argtypes = [c_char_p, POINTER(CtMessage)]
257
+ loaded.ct_pdu_decode.restype = c_int
258
+
259
+ _lib = loaded
260
+ return _lib
261
+
262
+
263
+ def reset() -> None:
264
+ """Drop the cached CDLL (mainly for tests)."""
265
+
266
+ global _lib
267
+ _lib = None
@@ -0,0 +1,10 @@
1
+ # Platform-native shared libraries for wheels
2
+
3
+ Filled by `scripts/stage_native.py` before `python -m build --wheel`:
4
+
5
+ | Directory | Contents |
6
+ |-----------|----------|
7
+ | `win_amd64/` | `comettextel.dll` |
8
+ | `linux_x86_64/` | `libcomettextel.so` |
9
+
10
+ Do not commit binaries. Source / editable installs still use `COMETTEXTEL_LIB` or nearby CMake / C SDK artifacts.
comettextel/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Status codes mirroring ``enum ct_status`` in ``c_api.h``.
2
+
3
+ Copyright (c) Ji-Feng Tsai. All rights reserved.
4
+ Code released under the MIT license.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import IntEnum
10
+
11
+
12
+ class Status(IntEnum):
13
+ OK = 0
14
+ INVALID_ARGUMENT = 1
15
+ NOT_OPEN = 2
16
+ ALREADY_OPEN = 3
17
+ IO = 4
18
+ TIMEOUT = 5
19
+ MODEM_REJECTED = 6
20
+ ENCODE = 7
21
+ DECODE = 8
22
+ UNSUPPORTED = 9
23
+ UNKNOWN = 100
24
+
25
+
26
+ class CometTextelError(RuntimeError):
27
+ """Raised when a ``ct_*`` call returns a non-OK status."""
28
+
29
+ def __init__(self, status: int, what: str, detail: str = "") -> None:
30
+ self.status = int(status)
31
+ self.what = what
32
+ message = f"{what} failed ({self.status})"
33
+ if detail:
34
+ message = f"{message}: {detail}"
35
+ super().__init__(message)
comettextel/modem.py ADDED
@@ -0,0 +1,127 @@
1
+ """GSM modem helpers over ``ct_modem_*``.
2
+
3
+ Copyright (c) Ji-Feng Tsai. All rights reserved.
4
+ Code released under the MIT license.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ctypes
10
+ from types import TracebackType
11
+ from typing import Optional, Type
12
+
13
+ from . import _lib
14
+ from .errors import CometTextelError
15
+ from .pdu import DCS_UCS2, Message, _as_utf8, _check, message_from_ct
16
+
17
+
18
+ class GsmModem:
19
+ """Context-managed wrapper around an opaque ``ct_modem`` handle."""
20
+
21
+ def __init__(self) -> None:
22
+ """Create a new modem object."""
23
+
24
+ lib = _lib.load()
25
+ handle = lib.ct_modem_create()
26
+ if not handle:
27
+ raise CometTextelError(100, "ct_modem_create", "null handle")
28
+ self._handle = handle
29
+ self._closed = False
30
+
31
+ def close(self) -> None:
32
+ """Destroy the native modem (idempotent)."""
33
+
34
+ if self._closed:
35
+ return
36
+ lib = _lib.load()
37
+ lib.ct_modem_destroy(self._handle)
38
+ self._handle = None
39
+ self._closed = True
40
+
41
+ def __enter__(self) -> GsmModem:
42
+ """Return the modem when the context is entered."""
43
+
44
+ return self
45
+
46
+ def __exit__(
47
+ self,
48
+ exc_type: Optional[Type[BaseException]],
49
+ exc: Optional[BaseException],
50
+ tb: Optional[TracebackType],
51
+ ) -> None:
52
+ """Close the modem when the context is exited."""
53
+
54
+ self.close()
55
+
56
+ def __del__(self) -> None:
57
+ """Destroy the modem when the object is garbage collected."""
58
+
59
+ try:
60
+ self.close()
61
+ except Exception:
62
+ pass
63
+
64
+ def _ensure_open(self) -> ctypes.CDLL:
65
+ """Ensure the modem is open."""
66
+
67
+ if self._closed or not self._handle:
68
+ raise CometTextelError(2, "GsmModem", "modem is closed")
69
+ return _lib.load()
70
+
71
+ def open(self, port: str, baud_rate: int = 115200) -> None:
72
+ """Open the serial port and initialize PDU mode."""
73
+
74
+ if not port:
75
+ raise ValueError("port must be non-empty")
76
+ lib = self._ensure_open()
77
+ status = lib.ct_modem_open(self._handle, _as_utf8(port), int(baud_rate))
78
+ _check(status, "ct_modem_open")
79
+
80
+ def send(
81
+ self,
82
+ destination: str,
83
+ text: str,
84
+ smsc: str = "",
85
+ dcs: int = DCS_UCS2,
86
+ timeout_ms: int = 10000,
87
+ ) -> None:
88
+ """Send an SMS (long text auto-splits with concat UDH)."""
89
+
90
+ if not destination:
91
+ raise ValueError("destination must be non-empty")
92
+ lib = self._ensure_open()
93
+ status = lib.ct_modem_send(
94
+ self._handle,
95
+ _as_utf8(smsc),
96
+ _as_utf8(destination),
97
+ _as_utf8(text),
98
+ int(dcs),
99
+ int(timeout_ms),
100
+ )
101
+ _check(status, "ct_modem_send")
102
+
103
+ def list(self, max_count: int = 64, timeout_ms: int = 8000) -> list[Message]:
104
+ """List stored messages (complete concat sets are rejoined)."""
105
+
106
+ if max_count <= 0:
107
+ raise ValueError("max_count must be positive")
108
+ lib = self._ensure_open()
109
+ buffer = (_lib.CtMessage * max_count)()
110
+ count = ctypes.c_int(0)
111
+ status = lib.ct_modem_list(
112
+ self._handle,
113
+ buffer,
114
+ int(max_count),
115
+ ctypes.byref(count),
116
+ int(timeout_ms),
117
+ )
118
+ _check(status, "ct_modem_list")
119
+ n = int(count.value)
120
+ return [message_from_ct(buffer[i]) for i in range(n)]
121
+
122
+ def delete(self, index: int, timeout_ms: int = 5000) -> None:
123
+ """Delete one stored message by modem storage index."""
124
+
125
+ lib = self._ensure_open()
126
+ status = lib.ct_modem_delete(self._handle, int(index), int(timeout_ms))
127
+ _check(status, "ct_modem_delete")
comettextel/pdu.py ADDED
@@ -0,0 +1,167 @@
1
+ """PDU encode / decode helpers over ``ct_pdu_*``.
2
+
3
+ Copyright (c) Ji-Feng Tsai. All rights reserved.
4
+ Code released under the MIT license.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ctypes
10
+ from dataclasses import dataclass
11
+ from typing import Optional
12
+
13
+ from . import _lib
14
+ from .errors import CometTextelError, Status
15
+
16
+ DCS_GSM7 = 0
17
+ DCS_8BIT = 4
18
+ DCS_UCS2 = 8
19
+
20
+ _DEFAULT_HEX_CAP = 65536
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Message:
25
+ """Decoded message fields (UTF-8 text)."""
26
+
27
+ peer_address: str
28
+ user_data: str
29
+ dcs: int
30
+ has_udh: bool = False
31
+ is_concatenated: bool = False
32
+ concat_ref: int = 0
33
+ concat_total: int = 0
34
+ concat_seq: int = 0
35
+ service_center: str = ""
36
+ service_timestamp: str = ""
37
+ index: int = -1
38
+
39
+ @property
40
+ def is_reassembled_concat(self) -> bool:
41
+ """True when list/reassembly joined a complete multi-part set."""
42
+ return self.is_concatenated and self.concat_seq == 0 and self.concat_total > 0
43
+
44
+
45
+ def _as_utf8(text: str) -> bytes:
46
+ """Convert a Python string to a UTF-8 encoded bytes object."""
47
+
48
+ return text.encode("utf-8")
49
+
50
+
51
+ def _c_z(buf: ctypes.Array) -> str:
52
+ """Convert a C string buffer to a Python string."""
53
+
54
+ raw = bytes(buf)
55
+ nul = raw.find(b"\x00")
56
+ if nul >= 0:
57
+ raw = raw[:nul]
58
+ return raw.decode("utf-8", errors="replace")
59
+
60
+
61
+ def message_from_ct(out: _lib.CtMessage) -> Message:
62
+ """Build a :class:`Message` from a native ``ct_message``."""
63
+
64
+ return Message(
65
+ index=int(out.index),
66
+ dcs=int(out.dcs),
67
+ has_udh=bool(out.has_udh),
68
+ service_center=_c_z(out.service_center),
69
+ peer_address=_c_z(out.peer_address),
70
+ service_timestamp=_c_z(out.service_timestamp),
71
+ user_data=_c_z(out.user_data),
72
+ is_concatenated=bool(out.is_concatenated),
73
+ concat_ref=int(out.concat_ref),
74
+ concat_total=int(out.concat_total),
75
+ concat_seq=int(out.concat_seq),
76
+ )
77
+
78
+
79
+ def status_string(status: int) -> str:
80
+ """Convert a status code to a string."""
81
+
82
+ lib = _lib.load()
83
+ ptr = lib.ct_status_string(int(status))
84
+ if not ptr:
85
+ return ""
86
+ return ptr.decode("utf-8", errors="replace")
87
+
88
+
89
+ def _check(status: int, what: str) -> None:
90
+ """Check if a status code is OK."""
91
+
92
+ if status != Status.OK:
93
+ raise CometTextelError(status, what, status_string(status))
94
+
95
+
96
+ def encode_submit(
97
+ destination: str,
98
+ text: str,
99
+ smsc: str = "",
100
+ dcs: int = DCS_UCS2,
101
+ *,
102
+ out_hex_cap: int = _DEFAULT_HEX_CAP,
103
+ ) -> str:
104
+ """Encode a single-segment submit PDU (hex). Raises if the text needs split."""
105
+
106
+ lib = _lib.load()
107
+ buf = ctypes.create_string_buffer(out_hex_cap)
108
+ status = lib.ct_pdu_encode_submit(
109
+ _as_utf8(smsc),
110
+ _as_utf8(destination),
111
+ _as_utf8(text),
112
+ int(dcs),
113
+ buf,
114
+ out_hex_cap,
115
+ )
116
+ _check(status, "ct_pdu_encode_submit")
117
+ return buf.value.decode("ascii")
118
+
119
+
120
+ def encode_submit_segments(
121
+ destination: str,
122
+ text: str,
123
+ smsc: str = "",
124
+ dcs: int = DCS_UCS2,
125
+ *,
126
+ out_hex_cap: int = _DEFAULT_HEX_CAP,
127
+ ) -> list[str]:
128
+ """Encode one or more submit PDUs; auto-splits with concat UDH when needed."""
129
+
130
+ lib = _lib.load()
131
+ buf = ctypes.create_string_buffer(out_hex_cap)
132
+ count = ctypes.c_int(0)
133
+ status = lib.ct_pdu_encode_submit_segments(
134
+ _as_utf8(smsc),
135
+ _as_utf8(destination),
136
+ _as_utf8(text),
137
+ int(dcs),
138
+ buf,
139
+ out_hex_cap,
140
+ ctypes.byref(count),
141
+ )
142
+ _check(status, "ct_pdu_encode_submit_segments")
143
+ joined = buf.value.decode("ascii")
144
+ parts = [p for p in joined.split("\n") if p]
145
+ if count.value and len(parts) != int(count.value):
146
+ raise CometTextelError(
147
+ Status.ENCODE,
148
+ "ct_pdu_encode_submit_segments",
149
+ f"segment count mismatch (api={count.value}, parsed={len(parts)})",
150
+ )
151
+ return parts
152
+
153
+
154
+ def decode(pdu_hex: str) -> Message:
155
+ """Decode a PDU hex string into a :class:`Message`."""
156
+
157
+ lib = _lib.load()
158
+ out = _lib.CtMessage()
159
+ status = lib.ct_pdu_decode(_as_utf8(pdu_hex.strip()), ctypes.byref(out))
160
+ _check(status, "ct_pdu_decode")
161
+ return message_from_ct(out)
162
+
163
+
164
+ def load_library(path: Optional[str] = None) -> None:
165
+ """Eagerly load the native library (optional; otherwise lazy on first call)."""
166
+
167
+ _lib.load(path)
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: comettextel
3
+ Version: 1.5.0
4
+ Summary: Thin ctypes FFI for the CometTextel C ABI (PDU + GSM modem).
5
+ Author-email: Ji-Feng Tsai <jiowcl@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://inwazy.com
8
+ Project-URL: Source, https://github.com/jiowcl/CometTextel
9
+ Project-URL: Issues, https://github.com/jiowcl/CometTextel/issues
10
+ Keywords: sms,pdu,gsm,comettextel
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7; extra == "dev"
15
+ Requires-Dist: build>=1.0; extra == "dev"
16
+
17
+ # CometTextel — Python (thin FFI)
18
+
19
+ Thin **ctypes** binding to the same C ABI as the C SDK and CometTextel.NET (`c_api.h`).
20
+ This is **not** a second native library: it runtime-loads `comettextel.dll` / `libcomettextel.so`.
21
+
22
+ Covers **PDU** helpers and **GsmModem** (`open` / `send` / `list` / `delete`).
23
+ Hardware modem tests are optional; smoke tests run without a device.
24
+
25
+ ![Python](https://img.shields.io/badge/language-python-blue.svg)
26
+
27
+ ## Layout
28
+
29
+ ```text
30
+ sdk/python/
31
+ ├── comettextel/ # package (ctypes + PDU + modem)
32
+ ├── examples/
33
+ │ ├── pdu_example.py # encode / decode + self-check
34
+ │ └── modem_example.py # list / send / delete
35
+ ├── tests/
36
+ │ ├── test_pdu.py
37
+ │ └── test_modem.py
38
+ └── README.md
39
+ ```
40
+
41
+ ## Prerequisites
42
+
43
+ | Item | Detail |
44
+ |------|--------|
45
+ | Python | 3.10+ (3.13 verified) |
46
+ | Native | C SDK shared library (`comettextel.dll` or `libcomettextel.so`) |
47
+ | Modem (optional) | AT modem in **PDU mode** (e.g. `COM3`, `/dev/ttyUSB0`) |
48
+
49
+ Download `comettextel-c-sdk-*` from CI Artifacts or a GitHub Release, or use a local CMake build.
50
+
51
+ ### Finding the library
52
+
53
+ Search order:
54
+
55
+ 1. Explicit path passed to `comettextel.pdu.load_library(...)`
56
+ 2. Environment variable **`COMETTEXTEL_LIB`** (shared-library **file** or a directory containing it — including versioned Linux sonames such as `libcomettextel.so.1.3.0`)
57
+ 3. Wheel-embedded `_native/<platform>/` (CI platform wheels)
58
+ 4. Current working directory
59
+ 5. Nearby build / artifact folders (`build-c-sdk/Release`, `artifact/comettextel-c-sdk-*/…`)
60
+
61
+ ```powershell
62
+ # Windows
63
+ $env:COMETTEXTEL_LIB = "D:\path\to\comettextel.dll"
64
+ ```
65
+
66
+ ```bash
67
+ # Linux
68
+ export COMETTEXTEL_LIB=/path/to/libcomettextel.so
69
+ ```
70
+
71
+ ## Wheels
72
+
73
+ CI builds platform wheels that **embed** the matching C ABI shared library:
74
+
75
+ | Artifact | Tag |
76
+ |----------|-----|
77
+ | `comettextel-python-wheel-windows` | `win_amd64` |
78
+ | `comettextel-python-wheel-linux` | `linux_x86_64` |
79
+
80
+ Local pack (after a C SDK stage):
81
+
82
+ ```powershell
83
+ python sdk/python/scripts/stage_native.py --from artifact/comettextel-c-sdk-windows-x64
84
+ python -m pip install build
85
+ python -m build --wheel --outdir dist sdk/python
86
+ python sdk/python/scripts/stage_native.py --clean
87
+ pip install dist/comettextel-*.whl
88
+ ```
89
+
90
+ Editable / source installs without a staged `_native` still need `COMETTEXTEL_LIB` or a nearby build.
91
+
92
+ ## PDU example
93
+
94
+ ```powershell
95
+ cd sdk\python
96
+ python examples\pdu_example.py
97
+ python examples\pdu_example.py 886912345678 "Hello" 886932000000
98
+ python examples\pdu_example.py 886912345678 "測試中文簡訊" 886932000000
99
+ ```
100
+
101
+ ## Modem example
102
+
103
+ ```powershell
104
+ python examples\modem_example.py list COM3
105
+ python examples\modem_example.py send COM3 886932000000 886912345678 "Hello"
106
+ python examples\modem_example.py delete COM3 1
107
+ ```
108
+
109
+ `list` rejoins **complete** concatenated SMS sets (`concat_seq == 0` / `Message.is_reassembled_concat`).
110
+
111
+ ## Tests
112
+
113
+ ```powershell
114
+ cd sdk\python
115
+ pip install pytest
116
+ pytest -q
117
+ ```
118
+
119
+ ## API sketch
120
+
121
+ ```python
122
+ from comettextel import DCS_UCS2, GsmModem, decode, encode_submit_segments
123
+
124
+ parts = encode_submit_segments("886912345678", "Hello", "886932000000", DCS_UCS2)
125
+ msg = decode(parts[0])
126
+
127
+ with GsmModem() as modem:
128
+ modem.open("COM3", 115200)
129
+ modem.send("886912345678", "Hello from Python", smsc="886932000000")
130
+ for m in modem.list():
131
+ print(m.index, m.peer_address, m.user_data, m.is_reassembled_concat)
132
+ ```
133
+
134
+ All text at the native boundary is **UTF-8**. Do not reimplement PDU codecs in Python; if the C ABI changes, update this package only.
135
+
136
+ ### GSM 7-bit (`DCS_GSM7`)
137
+
138
+ Maps UTF-8 through **GSM 03.38** (default alphabet + ESC extension). Examples: `[]{}\~^|€`.
139
+ Unsupported glyphs (e.g. Chinese) raise `CometTextelError` / encode status — use `DCS_UCS2`.
140
+ Extension characters consume two septets; concat auto-split will not break an ESC pair.
141
+
142
+ ```python
143
+ from comettextel import DCS_GSM7, decode, encode_submit
144
+
145
+ hex_pdu = encode_submit("886912345678", "Cost: 10€ [ok]", "886932000000", DCS_GSM7)
146
+ print(decode(hex_pdu).user_data)
147
+ ```
148
+
149
+ ## See also
150
+
151
+ - [`include/comettextel/c_api.h`](../../include/comettextel/c_api.h)
152
+ - [`sdk/c/README.md`](../c/README.md)
153
+ - [`sdk/freebasic/README.md`](../freebasic/README.md)
154
+ - [`sdk/purebasic/README.md`](../purebasic/README.md)
155
+ - [`examples/c_api_example.c`](../../examples/c_api_example.c)
@@ -0,0 +1,11 @@
1
+ comettextel/__init__.py,sha256=ETw7d9QE4ZY-_WqEtUw9-M_9YcaXbM1aJ5eJ1UE12qw,819
2
+ comettextel/_lib.py,sha256=9NshfbcJFqdphsD0pNWOU_TrvboFXNlBwfkJa0eJ8GI,7856
3
+ comettextel/errors.py,sha256=jejEfEaMPfkaEQhUnzeG0oowCYqWQ7tYTjliJmZzF6A,852
4
+ comettextel/modem.py,sha256=bOVoJkYcYzMyMOomsc1VAmFVnlDLJ-ftuqfDTYlrU9c,3867
5
+ comettextel/pdu.py,sha256=NICvns0dBgI1zsl4bt5Y7MynvRwPsIeFo7WY5sNtXGw,4535
6
+ comettextel/_native/README.md,sha256=xu17Ne8sS0s14Pn2SIQXGEcKq7gxcI3RQ-1lpsTWgR0,372
7
+ comettextel/_native/win_amd64/comettextel.dll,sha256=QP0Sh8uGyL1o1tgoZE4Ll0u6kPyZo86Jmc535qkj8jU,104448
8
+ comettextel-1.5.0.dist-info/METADATA,sha256=OXh-BjlVKvm2PnVy61vX_EH6NWyOjqFSgNJnTrTpyvU,5091
9
+ comettextel-1.5.0.dist-info/WHEEL,sha256=3I5VVWZdsFlU417aCS2bMRuerS8wsfvBq99MyatBV3A,97
10
+ comettextel-1.5.0.dist-info/top_level.txt,sha256=OKfoCtq8MdBy3M_koAWo-tnaqCZJQrsVUvsypYWYn0U,12
11
+ comettextel-1.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1 @@
1
+ comettextel