comettextel 1.3.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.
- comettextel/__init__.py +39 -0
- comettextel/_lib.py +207 -0
- comettextel/errors.py +35 -0
- comettextel/modem.py +127 -0
- comettextel/pdu.py +167 -0
- comettextel-1.3.0.dist-info/METADATA +119 -0
- comettextel-1.3.0.dist-info/RECORD +9 -0
- comettextel-1.3.0.dist-info/WHEEL +5 -0
- comettextel-1.3.0.dist-info/top_level.txt +1 -0
comettextel/__init__.py
ADDED
|
@@ -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"
|
comettextel/_lib.py
ADDED
|
@@ -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
|
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,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
|
+

|
|
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,9 @@
|
|
|
1
|
+
comettextel/__init__.py,sha256=unJUyDfVdsDSB-oRsei7zgqidbdwznsRz7laUzjMtUo,819
|
|
2
|
+
comettextel/_lib.py,sha256=4MMqL7sGB8avNaMCdYNQC4Sz1yWUwXLj0pzzeiP2wS8,5854
|
|
3
|
+
comettextel/errors.py,sha256=jejEfEaMPfkaEQhUnzeG0oowCYqWQ7tYTjliJmZzF6A,852
|
|
4
|
+
comettextel/modem.py,sha256=bOVoJkYcYzMyMOomsc1VAmFVnlDLJ-ftuqfDTYlrU9c,3867
|
|
5
|
+
comettextel/pdu.py,sha256=NICvns0dBgI1zsl4bt5Y7MynvRwPsIeFo7WY5sNtXGw,4535
|
|
6
|
+
comettextel-1.3.0.dist-info/METADATA,sha256=_qmtch8u1nEnUYsv3kfJfIT69FXvPSDE44CW15DBoIQ,3692
|
|
7
|
+
comettextel-1.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
comettextel-1.3.0.dist-info/top_level.txt,sha256=OKfoCtq8MdBy3M_koAWo-tnaqCZJQrsVUvsypYWYn0U,12
|
|
9
|
+
comettextel-1.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
comettextel
|