comettextel 1.3.0__tar.gz

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,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: comettextel
3
+ Version: 1.3.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: 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
+
16
+ # CometTextel — Python (thin FFI)
17
+
18
+ Thin **ctypes** binding to the same C ABI as the C SDK and CometTextel.NET (`c_api.h`).
19
+ This is **not** a second native library: it runtime-loads `comettextel.dll` / `libcomettextel.so`.
20
+
21
+ Covers **PDU** helpers and **GsmModem** (`open` / `send` / `list` / `delete`).
22
+ Hardware modem tests are optional; smoke tests run without a device.
23
+
24
+ ![Python](https://img.shields.io/badge/language-python-blue.svg)
25
+
26
+ ## Layout
27
+
28
+ ```text
29
+ sdk/python/
30
+ ├── comettextel/ # package (ctypes + PDU + modem)
31
+ ├── examples/
32
+ │ ├── pdu_example.py # encode / decode + self-check
33
+ │ └── modem_example.py # list / send / delete
34
+ ├── tests/
35
+ │ ├── test_pdu.py
36
+ │ └── test_modem.py
37
+ └── README.md
38
+ ```
39
+
40
+ ## Prerequisites
41
+
42
+ | Item | Detail |
43
+ |------|--------|
44
+ | Python | 3.10+ (3.13 verified) |
45
+ | Native | C SDK shared library (`comettextel.dll` or `libcomettextel.so`) |
46
+ | Modem (optional) | AT modem in **PDU mode** (e.g. `COM3`, `/dev/ttyUSB0`) |
47
+
48
+ Download `comettextel-c-sdk-*` from CI Artifacts or a GitHub Release, or use a local CMake build.
49
+
50
+ ### Finding the library
51
+
52
+ Search order:
53
+
54
+ 1. Explicit path passed to `comettextel.pdu.load_library(...)`
55
+ 2. Environment variable **`COMETTEXTEL_LIB`** (file or directory)
56
+ 3. Current working directory
57
+ 4. Nearby build / artifact folders (`build-c-sdk/Release`, `artifact/comettextel-c-sdk-*/…`)
58
+
59
+ ```powershell
60
+ # Windows
61
+ $env:COMETTEXTEL_LIB = "D:\path\to\comettextel.dll"
62
+ ```
63
+
64
+ ```bash
65
+ # Linux
66
+ export COMETTEXTEL_LIB=/path/to/libcomettextel.so
67
+ ```
68
+
69
+ ## PDU example
70
+
71
+ ```powershell
72
+ cd sdk\python
73
+ python examples\pdu_example.py
74
+ python examples\pdu_example.py 886912345678 "Hello" 886932000000
75
+ python examples\pdu_example.py 886912345678 "測試中文簡訊" 886932000000
76
+ ```
77
+
78
+ ## Modem example
79
+
80
+ ```powershell
81
+ python examples\modem_example.py list COM3
82
+ python examples\modem_example.py send COM3 886932000000 886912345678 "Hello"
83
+ python examples\modem_example.py delete COM3 1
84
+ ```
85
+
86
+ `list` rejoins **complete** concatenated SMS sets (`concat_seq == 0` / `Message.is_reassembled_concat`).
87
+
88
+ ## Tests
89
+
90
+ ```powershell
91
+ cd sdk\python
92
+ pip install pytest
93
+ pytest -q
94
+ ```
95
+
96
+ ## API sketch
97
+
98
+ ```python
99
+ from comettextel import DCS_UCS2, GsmModem, decode, encode_submit_segments
100
+
101
+ parts = encode_submit_segments("886912345678", "Hello", "886932000000", DCS_UCS2)
102
+ msg = decode(parts[0])
103
+
104
+ with GsmModem() as modem:
105
+ modem.open("COM3", 115200)
106
+ modem.send("886912345678", "Hello from Python", smsc="886932000000")
107
+ for m in modem.list():
108
+ print(m.index, m.peer_address, m.user_data, m.is_reassembled_concat)
109
+ ```
110
+
111
+ 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.
112
+
113
+ ## See also
114
+
115
+ - [`include/comettextel/c_api.h`](../../include/comettextel/c_api.h)
116
+ - [`sdk/c/README.md`](../c/README.md)
117
+ - [`sdk/freebasic/README.md`](../freebasic/README.md)
118
+ - [`sdk/purebasic/README.md`](../purebasic/README.md)
119
+ - [`examples/c_api_example.c`](../../examples/c_api_example.c)
@@ -0,0 +1,104 @@
1
+ # CometTextel — Python (thin FFI)
2
+
3
+ Thin **ctypes** binding to the same C ABI as the C SDK and CometTextel.NET (`c_api.h`).
4
+ This is **not** a second native library: it runtime-loads `comettextel.dll` / `libcomettextel.so`.
5
+
6
+ Covers **PDU** helpers and **GsmModem** (`open` / `send` / `list` / `delete`).
7
+ Hardware modem tests are optional; smoke tests run without a device.
8
+
9
+ ![Python](https://img.shields.io/badge/language-python-blue.svg)
10
+
11
+ ## Layout
12
+
13
+ ```text
14
+ sdk/python/
15
+ ├── comettextel/ # package (ctypes + PDU + modem)
16
+ ├── examples/
17
+ │ ├── pdu_example.py # encode / decode + self-check
18
+ │ └── modem_example.py # list / send / delete
19
+ ├── tests/
20
+ │ ├── test_pdu.py
21
+ │ └── test_modem.py
22
+ └── README.md
23
+ ```
24
+
25
+ ## Prerequisites
26
+
27
+ | Item | Detail |
28
+ |------|--------|
29
+ | Python | 3.10+ (3.13 verified) |
30
+ | Native | C SDK shared library (`comettextel.dll` or `libcomettextel.so`) |
31
+ | Modem (optional) | AT modem in **PDU mode** (e.g. `COM3`, `/dev/ttyUSB0`) |
32
+
33
+ Download `comettextel-c-sdk-*` from CI Artifacts or a GitHub Release, or use a local CMake build.
34
+
35
+ ### Finding the library
36
+
37
+ Search order:
38
+
39
+ 1. Explicit path passed to `comettextel.pdu.load_library(...)`
40
+ 2. Environment variable **`COMETTEXTEL_LIB`** (file or directory)
41
+ 3. Current working directory
42
+ 4. Nearby build / artifact folders (`build-c-sdk/Release`, `artifact/comettextel-c-sdk-*/…`)
43
+
44
+ ```powershell
45
+ # Windows
46
+ $env:COMETTEXTEL_LIB = "D:\path\to\comettextel.dll"
47
+ ```
48
+
49
+ ```bash
50
+ # Linux
51
+ export COMETTEXTEL_LIB=/path/to/libcomettextel.so
52
+ ```
53
+
54
+ ## PDU example
55
+
56
+ ```powershell
57
+ cd sdk\python
58
+ python examples\pdu_example.py
59
+ python examples\pdu_example.py 886912345678 "Hello" 886932000000
60
+ python examples\pdu_example.py 886912345678 "測試中文簡訊" 886932000000
61
+ ```
62
+
63
+ ## Modem example
64
+
65
+ ```powershell
66
+ python examples\modem_example.py list COM3
67
+ python examples\modem_example.py send COM3 886932000000 886912345678 "Hello"
68
+ python examples\modem_example.py delete COM3 1
69
+ ```
70
+
71
+ `list` rejoins **complete** concatenated SMS sets (`concat_seq == 0` / `Message.is_reassembled_concat`).
72
+
73
+ ## Tests
74
+
75
+ ```powershell
76
+ cd sdk\python
77
+ pip install pytest
78
+ pytest -q
79
+ ```
80
+
81
+ ## API sketch
82
+
83
+ ```python
84
+ from comettextel import DCS_UCS2, GsmModem, decode, encode_submit_segments
85
+
86
+ parts = encode_submit_segments("886912345678", "Hello", "886932000000", DCS_UCS2)
87
+ msg = decode(parts[0])
88
+
89
+ with GsmModem() as modem:
90
+ modem.open("COM3", 115200)
91
+ modem.send("886912345678", "Hello from Python", smsc="886932000000")
92
+ for m in modem.list():
93
+ print(m.index, m.peer_address, m.user_data, m.is_reassembled_concat)
94
+ ```
95
+
96
+ 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.
97
+
98
+ ## See also
99
+
100
+ - [`include/comettextel/c_api.h`](../../include/comettextel/c_api.h)
101
+ - [`sdk/c/README.md`](../c/README.md)
102
+ - [`sdk/freebasic/README.md`](../freebasic/README.md)
103
+ - [`sdk/purebasic/README.md`](../purebasic/README.md)
104
+ - [`examples/c_api_example.c`](../../examples/c_api_example.c)
@@ -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.3.0"
@@ -0,0 +1,207 @@
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 _search_dirs(explicit: Optional[Path]) -> list[Path]:
58
+ """Search for the shared library in the given directories."""
59
+
60
+ dirs: list[Path] = []
61
+ if explicit is not None:
62
+ dirs.append(explicit if explicit.is_dir() else explicit.parent)
63
+
64
+ env = os.environ.get("COMETTEXTEL_LIB")
65
+ if env:
66
+ p = Path(env)
67
+ dirs.append(p if p.is_dir() else p.parent)
68
+
69
+ # sdk/python/comettextel/_lib.py → parents[1]=sdk/python, [2]=sdk, [3]=repo root
70
+ here = Path(__file__).resolve()
71
+ sdk_python = here.parents[1]
72
+ repo = here.parents[3] if len(here.parents) > 3 else here.parents[-1]
73
+
74
+ dirs.extend(
75
+ [
76
+ Path.cwd(),
77
+ sdk_python,
78
+ sdk_python / "examples",
79
+ repo / "artifact" / "comettextel-c-sdk-windows-x64" / "bin",
80
+ repo / "artifact" / "comettextel-c-sdk-linux-x64" / "lib",
81
+ repo / "build-c-sdk" / "Release",
82
+ repo / "build-c-sdk" / "Debug",
83
+ repo / "build" / "Release",
84
+ repo / "build" / "Debug",
85
+ ]
86
+ )
87
+
88
+ # Deduplicate while preserving order.
89
+ seen: set[str] = set()
90
+ out: list[Path] = []
91
+ for d in dirs:
92
+ key = str(d.resolve()) if d.exists() else str(d)
93
+ if key not in seen:
94
+ seen.add(key)
95
+ out.append(d)
96
+ return out
97
+
98
+
99
+ def find_library(path: Optional[str | Path] = None) -> Path:
100
+ """Resolve the shared library path.
101
+
102
+ Search order: explicit path / ``COMETTEXTEL_LIB`` / cwd / common build &
103
+ artifact folders next to this package.
104
+ """
105
+
106
+ explicit: Optional[Path] = Path(path) if path else None
107
+ if explicit is not None and explicit.is_file():
108
+ return explicit.resolve()
109
+
110
+ names = _candidate_names()
111
+ for directory in _search_dirs(explicit):
112
+ if not directory.is_dir():
113
+ continue
114
+ for name in names:
115
+ candidate = directory / name
116
+ if candidate.is_file():
117
+ return candidate.resolve()
118
+
119
+ raise FileNotFoundError(
120
+ "comettextel shared library not found. Set COMETTEXTEL_LIB to the DLL/SO "
121
+ "path (or its directory), or place it next to the example / on PATH. "
122
+ f"Looked for: {', '.join(names)}"
123
+ )
124
+
125
+
126
+ def load(path: Optional[str | Path] = None) -> ctypes.CDLL:
127
+ """Load (or return cached) shared library and configure C ABI prototypes."""
128
+
129
+ global _lib
130
+ if _lib is not None and path is None:
131
+ return _lib
132
+
133
+ lib_path = find_library(path)
134
+ if sys.platform == "win32":
135
+ # Ensure dependent MSVC runtime resolution from the DLL folder.
136
+ os.add_dll_directory(str(lib_path.parent))
137
+ loaded = ctypes.WinDLL(str(lib_path))
138
+ else:
139
+ loaded = ctypes.CDLL(str(lib_path))
140
+
141
+ loaded.ct_status_string.argtypes = [c_int]
142
+ loaded.ct_status_string.restype = c_char_p
143
+
144
+ loaded.ct_modem_create.argtypes = []
145
+ loaded.ct_modem_create.restype = c_void_p
146
+
147
+ loaded.ct_modem_destroy.argtypes = [c_void_p]
148
+ loaded.ct_modem_destroy.restype = None
149
+
150
+ loaded.ct_modem_open.argtypes = [c_void_p, c_char_p, c_uint32]
151
+ loaded.ct_modem_open.restype = c_int
152
+
153
+ loaded.ct_modem_send.argtypes = [
154
+ c_void_p,
155
+ c_char_p,
156
+ c_char_p,
157
+ c_char_p,
158
+ c_int,
159
+ c_int,
160
+ ]
161
+ loaded.ct_modem_send.restype = c_int
162
+
163
+ loaded.ct_modem_list.argtypes = [
164
+ c_void_p,
165
+ POINTER(CtMessage),
166
+ c_int,
167
+ POINTER(c_int),
168
+ c_int,
169
+ ]
170
+ loaded.ct_modem_list.restype = c_int
171
+
172
+ loaded.ct_modem_delete.argtypes = [c_void_p, c_int, c_int]
173
+ loaded.ct_modem_delete.restype = c_int
174
+
175
+ loaded.ct_pdu_encode_submit.argtypes = [
176
+ c_char_p,
177
+ c_char_p,
178
+ c_char_p,
179
+ c_int,
180
+ c_void_p,
181
+ c_size_t,
182
+ ]
183
+ loaded.ct_pdu_encode_submit.restype = c_int
184
+
185
+ loaded.ct_pdu_encode_submit_segments.argtypes = [
186
+ c_char_p,
187
+ c_char_p,
188
+ c_char_p,
189
+ c_int,
190
+ c_void_p,
191
+ c_size_t,
192
+ POINTER(c_int),
193
+ ]
194
+ loaded.ct_pdu_encode_submit_segments.restype = c_int
195
+
196
+ loaded.ct_pdu_decode.argtypes = [c_char_p, POINTER(CtMessage)]
197
+ loaded.ct_pdu_decode.restype = c_int
198
+
199
+ _lib = loaded
200
+ return _lib
201
+
202
+
203
+ def reset() -> None:
204
+ """Drop the cached CDLL (mainly for tests)."""
205
+
206
+ global _lib
207
+ _lib = None
@@ -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)
@@ -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")
@@ -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,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: comettextel
3
+ Version: 1.3.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: 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
+
16
+ # CometTextel — Python (thin FFI)
17
+
18
+ Thin **ctypes** binding to the same C ABI as the C SDK and CometTextel.NET (`c_api.h`).
19
+ This is **not** a second native library: it runtime-loads `comettextel.dll` / `libcomettextel.so`.
20
+
21
+ Covers **PDU** helpers and **GsmModem** (`open` / `send` / `list` / `delete`).
22
+ Hardware modem tests are optional; smoke tests run without a device.
23
+
24
+ ![Python](https://img.shields.io/badge/language-python-blue.svg)
25
+
26
+ ## Layout
27
+
28
+ ```text
29
+ sdk/python/
30
+ ├── comettextel/ # package (ctypes + PDU + modem)
31
+ ├── examples/
32
+ │ ├── pdu_example.py # encode / decode + self-check
33
+ │ └── modem_example.py # list / send / delete
34
+ ├── tests/
35
+ │ ├── test_pdu.py
36
+ │ └── test_modem.py
37
+ └── README.md
38
+ ```
39
+
40
+ ## Prerequisites
41
+
42
+ | Item | Detail |
43
+ |------|--------|
44
+ | Python | 3.10+ (3.13 verified) |
45
+ | Native | C SDK shared library (`comettextel.dll` or `libcomettextel.so`) |
46
+ | Modem (optional) | AT modem in **PDU mode** (e.g. `COM3`, `/dev/ttyUSB0`) |
47
+
48
+ Download `comettextel-c-sdk-*` from CI Artifacts or a GitHub Release, or use a local CMake build.
49
+
50
+ ### Finding the library
51
+
52
+ Search order:
53
+
54
+ 1. Explicit path passed to `comettextel.pdu.load_library(...)`
55
+ 2. Environment variable **`COMETTEXTEL_LIB`** (file or directory)
56
+ 3. Current working directory
57
+ 4. Nearby build / artifact folders (`build-c-sdk/Release`, `artifact/comettextel-c-sdk-*/…`)
58
+
59
+ ```powershell
60
+ # Windows
61
+ $env:COMETTEXTEL_LIB = "D:\path\to\comettextel.dll"
62
+ ```
63
+
64
+ ```bash
65
+ # Linux
66
+ export COMETTEXTEL_LIB=/path/to/libcomettextel.so
67
+ ```
68
+
69
+ ## PDU example
70
+
71
+ ```powershell
72
+ cd sdk\python
73
+ python examples\pdu_example.py
74
+ python examples\pdu_example.py 886912345678 "Hello" 886932000000
75
+ python examples\pdu_example.py 886912345678 "測試中文簡訊" 886932000000
76
+ ```
77
+
78
+ ## Modem example
79
+
80
+ ```powershell
81
+ python examples\modem_example.py list COM3
82
+ python examples\modem_example.py send COM3 886932000000 886912345678 "Hello"
83
+ python examples\modem_example.py delete COM3 1
84
+ ```
85
+
86
+ `list` rejoins **complete** concatenated SMS sets (`concat_seq == 0` / `Message.is_reassembled_concat`).
87
+
88
+ ## Tests
89
+
90
+ ```powershell
91
+ cd sdk\python
92
+ pip install pytest
93
+ pytest -q
94
+ ```
95
+
96
+ ## API sketch
97
+
98
+ ```python
99
+ from comettextel import DCS_UCS2, GsmModem, decode, encode_submit_segments
100
+
101
+ parts = encode_submit_segments("886912345678", "Hello", "886932000000", DCS_UCS2)
102
+ msg = decode(parts[0])
103
+
104
+ with GsmModem() as modem:
105
+ modem.open("COM3", 115200)
106
+ modem.send("886912345678", "Hello from Python", smsc="886932000000")
107
+ for m in modem.list():
108
+ print(m.index, m.peer_address, m.user_data, m.is_reassembled_concat)
109
+ ```
110
+
111
+ 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.
112
+
113
+ ## See also
114
+
115
+ - [`include/comettextel/c_api.h`](../../include/comettextel/c_api.h)
116
+ - [`sdk/c/README.md`](../c/README.md)
117
+ - [`sdk/freebasic/README.md`](../freebasic/README.md)
118
+ - [`sdk/purebasic/README.md`](../purebasic/README.md)
119
+ - [`examples/c_api_example.c`](../../examples/c_api_example.c)
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ comettextel/__init__.py
4
+ comettextel/_lib.py
5
+ comettextel/errors.py
6
+ comettextel/modem.py
7
+ comettextel/pdu.py
8
+ comettextel.egg-info/PKG-INFO
9
+ comettextel.egg-info/SOURCES.txt
10
+ comettextel.egg-info/dependency_links.txt
11
+ comettextel.egg-info/requires.txt
12
+ comettextel.egg-info/top_level.txt
13
+ tests/test_modem.py
14
+ tests/test_pdu.py
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=7
@@ -0,0 +1 @@
1
+ comettextel
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "comettextel"
7
+ version = "1.3.0"
8
+ description = "Thin ctypes FFI for the CometTextel C ABI (PDU + GSM modem)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Ji-Feng Tsai", email = "jiowcl@gmail.com" }]
13
+ keywords = ["sms", "pdu", "gsm", "comettextel"]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=7"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://inwazy.com"
20
+ Source = "https://github.com/jiowcl/CometTextel"
21
+ Issues = "https://github.com/jiowcl/CometTextel/issues"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["."]
25
+ include = ["comettextel*"]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,71 @@
1
+ """Modem API smoke tests (no hardware required for create/open-fail paths)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ _SDK_ROOT = Path(__file__).resolve().parents[1]
12
+ if str(_SDK_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(_SDK_ROOT))
14
+
15
+ from comettextel import CometTextelError, GsmModem, Message # noqa: E402
16
+ from comettextel import _lib # noqa: E402
17
+
18
+
19
+ def _have_native() -> bool:
20
+ try:
21
+ _lib.find_library(os.environ.get("COMETTEXTEL_LIB"))
22
+ return True
23
+ except FileNotFoundError:
24
+ return False
25
+
26
+
27
+ pytestmark = pytest.mark.skipif(not _have_native(), reason="comettextel shared library not found")
28
+
29
+
30
+ def test_modem_create_close() -> None:
31
+ modem = GsmModem()
32
+ modem.close()
33
+ modem.close() # idempotent
34
+
35
+
36
+ def test_modem_context_manager() -> None:
37
+ with GsmModem() as modem:
38
+ assert modem is not None
39
+
40
+
41
+ def test_modem_open_invalid_port_raises() -> None:
42
+ with GsmModem() as modem:
43
+ with pytest.raises(CometTextelError):
44
+ modem.open("COM_INVALID_PORT_FOR_COMETTEXTEL_TEST", 115200)
45
+
46
+
47
+ def test_modem_rejects_empty_port() -> None:
48
+ with GsmModem() as modem:
49
+ with pytest.raises(ValueError):
50
+ modem.open("", 115200)
51
+
52
+
53
+ def test_message_reassembled_property() -> None:
54
+ msg = Message(
55
+ peer_address="886912345678",
56
+ user_data="abc",
57
+ dcs=8,
58
+ is_concatenated=True,
59
+ concat_total=2,
60
+ concat_seq=0,
61
+ )
62
+ assert msg.is_reassembled_concat
63
+ part = Message(
64
+ peer_address="886912345678",
65
+ user_data="a",
66
+ dcs=8,
67
+ is_concatenated=True,
68
+ concat_total=2,
69
+ concat_seq=1,
70
+ )
71
+ assert not part.is_reassembled_concat
@@ -0,0 +1,91 @@
1
+ """PDU round-trip tests (require comettextel shared library)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ _SDK_ROOT = Path(__file__).resolve().parents[1]
12
+ if str(_SDK_ROOT) not in sys.path:
13
+ sys.path.insert(0, str(_SDK_ROOT))
14
+
15
+ from comettextel import ( # noqa: E402
16
+ DCS_UCS2,
17
+ CometTextelError,
18
+ decode,
19
+ encode_submit,
20
+ encode_submit_segments,
21
+ )
22
+ from comettextel import _lib # noqa: E402
23
+
24
+
25
+ def _have_native() -> bool:
26
+ """Check if the comettextel shared library is found."""
27
+
28
+ try:
29
+ _lib.find_library(os.environ.get("COMETTEXTEL_LIB"))
30
+ return True
31
+ except FileNotFoundError:
32
+ return False
33
+
34
+
35
+ pytestmark = pytest.mark.skipif(not _have_native(), reason="comettextel shared library not found")
36
+
37
+
38
+ def test_ascii_roundtrip() -> None:
39
+ """Test ASCII roundtrip."""
40
+
41
+ parts = encode_submit_segments("886912345678", "Hello from Python", "886932000000", DCS_UCS2)
42
+ assert len(parts) == 1
43
+ msg = decode(parts[0])
44
+ assert msg.peer_address == "886912345678"
45
+ assert msg.user_data == "Hello from Python"
46
+ assert msg.dcs == DCS_UCS2
47
+ assert not msg.is_concatenated
48
+
49
+
50
+ def test_chinese_roundtrip() -> None:
51
+ """Test Chinese roundtrip."""
52
+
53
+ text = "測試中文簡訊"
54
+ parts = encode_submit_segments("886912345678", text, "886932000000", DCS_UCS2)
55
+ assert len(parts) == 1
56
+ # Must not be U+FFFD replacement noise in the PDU.
57
+ assert "FFFD" not in parts[0]
58
+ assert "6E2C" in parts[0].upper()
59
+ msg = decode(parts[0])
60
+ assert msg.user_data == text
61
+
62
+
63
+ def test_ucs2_concat_two_segments() -> None:
64
+ """Test UCS2 concatenated two segments."""
65
+
66
+ text = "B" * 71
67
+ parts = encode_submit_segments("886912345678", text, "886932000000", DCS_UCS2)
68
+ assert len(parts) == 2
69
+ joined = ""
70
+ for i, part in enumerate(parts):
71
+ msg = decode(part)
72
+ assert msg.is_concatenated
73
+ assert msg.concat_total == 2
74
+ assert msg.concat_seq == i + 1
75
+ joined += msg.user_data
76
+ assert joined == text
77
+
78
+
79
+ def test_encode_submit_single() -> None:
80
+ """Test encode submit single."""
81
+
82
+ hex_pdu = encode_submit("886912345678", "Hello", "886932000000", DCS_UCS2)
83
+ msg = decode(hex_pdu)
84
+ assert msg.user_data == "Hello"
85
+
86
+
87
+ def test_encode_submit_rejects_or_errors_on_empty_dest() -> None:
88
+ """Test encode submit rejects or errors on empty destination."""
89
+
90
+ with pytest.raises(CometTextelError):
91
+ encode_submit("", "Hello", "886932000000", DCS_UCS2)