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.
- comettextel-1.3.0/PKG-INFO +119 -0
- comettextel-1.3.0/README.md +104 -0
- comettextel-1.3.0/comettextel/__init__.py +39 -0
- comettextel-1.3.0/comettextel/_lib.py +207 -0
- comettextel-1.3.0/comettextel/errors.py +35 -0
- comettextel-1.3.0/comettextel/modem.py +127 -0
- comettextel-1.3.0/comettextel/pdu.py +167 -0
- comettextel-1.3.0/comettextel.egg-info/PKG-INFO +119 -0
- comettextel-1.3.0/comettextel.egg-info/SOURCES.txt +14 -0
- comettextel-1.3.0/comettextel.egg-info/dependency_links.txt +1 -0
- comettextel-1.3.0/comettextel.egg-info/requires.txt +3 -0
- comettextel-1.3.0/comettextel.egg-info/top_level.txt +1 -0
- comettextel-1.3.0/pyproject.toml +28 -0
- comettextel-1.3.0/setup.cfg +4 -0
- comettextel-1.3.0/tests/test_modem.py +71 -0
- comettextel-1.3.0/tests/test_pdu.py +91 -0
|
@@ -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,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
|
+

|
|
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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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,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)
|