geme-plug 1.0.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.
- geme_plug-1.0.0/LICENSE +21 -0
- geme_plug-1.0.0/PKG-INFO +236 -0
- geme_plug-1.0.0/README.md +210 -0
- geme_plug-1.0.0/pyproject.toml +42 -0
- geme_plug-1.0.0/setup.cfg +4 -0
- geme_plug-1.0.0/setup.py +3 -0
- geme_plug-1.0.0/src/geme_plug/__init__.py +16 -0
- geme_plug-1.0.0/src/geme_plug/client.py +233 -0
- geme_plug-1.0.0/src/geme_plug/exceptions.py +14 -0
- geme_plug-1.0.0/src/geme_plug/models.py +92 -0
- geme_plug-1.0.0/src/geme_plug.egg-info/PKG-INFO +236 -0
- geme_plug-1.0.0/src/geme_plug.egg-info/SOURCES.txt +14 -0
- geme_plug-1.0.0/src/geme_plug.egg-info/dependency_links.txt +1 -0
- geme_plug-1.0.0/src/geme_plug.egg-info/requires.txt +1 -0
- geme_plug-1.0.0/src/geme_plug.egg-info/top_level.txt +1 -0
- geme_plug-1.0.0/tests/test_client.py +117 -0
geme_plug-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 loks666
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
geme_plug-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geme-plug
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python MQTT SDK for GemeOpen GSPM1B smart plugs using a self-hosted broker such as EMQX.
|
|
5
|
+
Author: loks666
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/loks666/geme-plug
|
|
8
|
+
Project-URL: Repository, https://github.com/loks666/geme-plug
|
|
9
|
+
Project-URL: Issues, https://github.com/loks666/geme-plug/issues
|
|
10
|
+
Keywords: GemeOpen,GeekOpen,GSPM1B,MQTT,EMQX,smart plug,IoT
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: paho-mqtt<3,>=2.1
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# geme-plug
|
|
28
|
+
|
|
29
|
+
`geme-plug` 是一个用于 **GemeOpen / GeekOpen GSPM1B 智能插座** 的 Python MQTT SDK,面向自建 MQTT Broker(例如 EMQX)。
|
|
30
|
+
|
|
31
|
+
> 本项目是第三方开源 SDK,与 GemeOpen / 武汉智鸟科技无隶属关系。
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install geme-plug
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 快速开始
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from geme_plug import SmartPlug
|
|
43
|
+
|
|
44
|
+
plug = SmartPlug(
|
|
45
|
+
host="192.168.31.100", # EMQX Broker IP / hostname
|
|
46
|
+
mac="AABBCCDDEEFF",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
plug.connect()
|
|
50
|
+
|
|
51
|
+
plug.turn_on()
|
|
52
|
+
plug.turn_off()
|
|
53
|
+
|
|
54
|
+
print(plug.get_status())
|
|
55
|
+
print(plug.get_power())
|
|
56
|
+
|
|
57
|
+
plug.disconnect()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
也支持上下文管理器:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from geme_plug import SmartPlug
|
|
64
|
+
|
|
65
|
+
with SmartPlug(host="192.168.31.100", mac="AA:BB:CC:DD:EE:FF") as plug:
|
|
66
|
+
plug.turn_on()
|
|
67
|
+
print(plug.get_power())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## EMQX 认证
|
|
71
|
+
|
|
72
|
+
仓库中的 Compose 配置默认启用 MQTT 客户端认证,并内置以下局域网设备账号:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
用户名:geme-plug
|
|
76
|
+
密码:x7Tq9V2mK8rP4nD6sH3wF5cJ1bL0zQeA
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
SDK 和设备配网页都要使用相同的账号:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
plug = SmartPlug(
|
|
83
|
+
host="192.168.31.100",
|
|
84
|
+
mac="AABBCCDDEEFF",
|
|
85
|
+
username="geme-plug",
|
|
86
|
+
password="x7Tq9V2mK8rP4nD6sH3wF5cJ1bL0zQeA",
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`port` 默认是 `1883`。
|
|
91
|
+
|
|
92
|
+
## 使用 Docker Compose 启动 EMQX
|
|
93
|
+
|
|
94
|
+
仓库内置了 EMQX Compose 配置,包含账号密码认证和持久化数据卷:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
docker compose up -d
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- MQTT Broker:`mqtt://<本机局域网 IP>:1883`
|
|
101
|
+
- EMQX Dashboard:`http://127.0.0.1:18083`
|
|
102
|
+
|
|
103
|
+
设备必须连接到运行 EMQX 的电脑的局域网 IP,不能使用设备视角下的
|
|
104
|
+
`127.0.0.1`。EMQX Dashboard 的初始登录信息请以当前镜像的启动页提示为准,
|
|
105
|
+
首次登录后应立即修改密码。
|
|
106
|
+
|
|
107
|
+
## 关断测试
|
|
108
|
+
|
|
109
|
+
确认设备已经连到该 Broker 后,可运行:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python examples/turn_off.py \
|
|
113
|
+
--host <运行 EMQX 的局域网 IP> \
|
|
114
|
+
--mac <插座 MAC 地址>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
脚本先查询当前状态,再发布关断指令,最后重新查询并验证设备报告为关闭。
|
|
118
|
+
|
|
119
|
+
## 设备 MQTT 配置
|
|
120
|
+
|
|
121
|
+
在使用 SDK 前,插座必须已经:
|
|
122
|
+
|
|
123
|
+
1. 完成 2.4 GHz Wi-Fi 配网;
|
|
124
|
+
2. 配置为连接你的 MQTT Broker / EMQX;
|
|
125
|
+
3. 配置与 SDK 一致的 MQTT Topic。
|
|
126
|
+
|
|
127
|
+
`geme-plug` 默认使用两个简洁的 Topic:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
request
|
|
131
|
+
response
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- SDK 向 `request` 发布控制指令,设备订阅该主题;
|
|
135
|
+
- SDK 订阅 `response`,设备通过该主题返回状态和电量数据。
|
|
136
|
+
|
|
137
|
+
在 GemeOpen 的“自定义 MQTT”页面中填写:
|
|
138
|
+
|
|
139
|
+
- 订阅主题:`request`
|
|
140
|
+
- 发布主题:`response`
|
|
141
|
+
|
|
142
|
+
设备配置页的字段名称与 SDK 视角相反:SDK 的 `publish_topic` 对应设备的
|
|
143
|
+
“订阅主题”,SDK 的 `subscribe_topic` 对应设备的“发布主题”。如果同一个
|
|
144
|
+
Broker 连接多台设备,应为每台设备设置独立 Topic,避免消息互相干扰。
|
|
145
|
+
|
|
146
|
+
如果你已经给设备配置了其他 Topic,可以显式覆盖:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
plug = SmartPlug(
|
|
150
|
+
host="192.168.31.100",
|
|
151
|
+
mac="AABBCCDDEEFF",
|
|
152
|
+
publish_topic="my/device/command",
|
|
153
|
+
subscribe_topic="my/device/state",
|
|
154
|
+
)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## API
|
|
158
|
+
|
|
159
|
+
### `SmartPlug(...)`
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
SmartPlug(
|
|
163
|
+
host: str,
|
|
164
|
+
mac: str,
|
|
165
|
+
port: int = 1883,
|
|
166
|
+
username: str | None = None,
|
|
167
|
+
password: str | None = None,
|
|
168
|
+
timeout: float = 5.0,
|
|
169
|
+
publish_topic: str | None = None,
|
|
170
|
+
subscribe_topic: str | None = None,
|
|
171
|
+
client_id: str | None = None,
|
|
172
|
+
)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
MAC 可以使用以下任意格式:
|
|
176
|
+
|
|
177
|
+
```text
|
|
178
|
+
AABBCCDDEEFF
|
|
179
|
+
AA:BB:CC:DD:EE:FF
|
|
180
|
+
AA-BB-CC-DD-EE-FF
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
SDK 内部统一规范化为 `aabbccddeeff`。
|
|
184
|
+
|
|
185
|
+
### `connect()` / `disconnect()`
|
|
186
|
+
|
|
187
|
+
连接或断开 MQTT Broker。
|
|
188
|
+
|
|
189
|
+
### `turn_on()` / `turn_off()`
|
|
190
|
+
|
|
191
|
+
控制插座通断电。SDK 根据 GSPM1B 协议发送:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{"type":"event","key":1}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
或:
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{"type":"event","key":0}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### `get_status()`
|
|
204
|
+
|
|
205
|
+
查询设备状态,返回 `PlugStatus`,常用字段包括:
|
|
206
|
+
|
|
207
|
+
- `mac`
|
|
208
|
+
- `device_type`
|
|
209
|
+
- `version`
|
|
210
|
+
- `key` / `is_on`
|
|
211
|
+
- `signal`
|
|
212
|
+
- `ip`
|
|
213
|
+
- `ssid`
|
|
214
|
+
- `wifi_lock`
|
|
215
|
+
- `key_lock`
|
|
216
|
+
- `on_state`
|
|
217
|
+
- `timer_enable`
|
|
218
|
+
- `timer_interval`
|
|
219
|
+
|
|
220
|
+
### `get_power()`
|
|
221
|
+
|
|
222
|
+
查询电量数据,返回 `PowerStatus`:
|
|
223
|
+
|
|
224
|
+
- `voltage`:V
|
|
225
|
+
- `current`:A
|
|
226
|
+
- `power`:W
|
|
227
|
+
- `energy`:kWh
|
|
228
|
+
- `key` / `is_on`
|
|
229
|
+
|
|
230
|
+
## 说明
|
|
231
|
+
|
|
232
|
+
本版本聚焦于最基础、稳定的控制能力:连接 Broker、通断控制、状态查询和电量查询。设备配网和局域网自动发现暂不包含在 `1.0.0` 中。
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# geme-plug
|
|
2
|
+
|
|
3
|
+
`geme-plug` 是一个用于 **GemeOpen / GeekOpen GSPM1B 智能插座** 的 Python MQTT SDK,面向自建 MQTT Broker(例如 EMQX)。
|
|
4
|
+
|
|
5
|
+
> 本项目是第三方开源 SDK,与 GemeOpen / 武汉智鸟科技无隶属关系。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install geme-plug
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 快速开始
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from geme_plug import SmartPlug
|
|
17
|
+
|
|
18
|
+
plug = SmartPlug(
|
|
19
|
+
host="192.168.31.100", # EMQX Broker IP / hostname
|
|
20
|
+
mac="AABBCCDDEEFF",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
plug.connect()
|
|
24
|
+
|
|
25
|
+
plug.turn_on()
|
|
26
|
+
plug.turn_off()
|
|
27
|
+
|
|
28
|
+
print(plug.get_status())
|
|
29
|
+
print(plug.get_power())
|
|
30
|
+
|
|
31
|
+
plug.disconnect()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
也支持上下文管理器:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from geme_plug import SmartPlug
|
|
38
|
+
|
|
39
|
+
with SmartPlug(host="192.168.31.100", mac="AA:BB:CC:DD:EE:FF") as plug:
|
|
40
|
+
plug.turn_on()
|
|
41
|
+
print(plug.get_power())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## EMQX 认证
|
|
45
|
+
|
|
46
|
+
仓库中的 Compose 配置默认启用 MQTT 客户端认证,并内置以下局域网设备账号:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
用户名:geme-plug
|
|
50
|
+
密码:x7Tq9V2mK8rP4nD6sH3wF5cJ1bL0zQeA
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
SDK 和设备配网页都要使用相同的账号:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
plug = SmartPlug(
|
|
57
|
+
host="192.168.31.100",
|
|
58
|
+
mac="AABBCCDDEEFF",
|
|
59
|
+
username="geme-plug",
|
|
60
|
+
password="x7Tq9V2mK8rP4nD6sH3wF5cJ1bL0zQeA",
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`port` 默认是 `1883`。
|
|
65
|
+
|
|
66
|
+
## 使用 Docker Compose 启动 EMQX
|
|
67
|
+
|
|
68
|
+
仓库内置了 EMQX Compose 配置,包含账号密码认证和持久化数据卷:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
docker compose up -d
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- MQTT Broker:`mqtt://<本机局域网 IP>:1883`
|
|
75
|
+
- EMQX Dashboard:`http://127.0.0.1:18083`
|
|
76
|
+
|
|
77
|
+
设备必须连接到运行 EMQX 的电脑的局域网 IP,不能使用设备视角下的
|
|
78
|
+
`127.0.0.1`。EMQX Dashboard 的初始登录信息请以当前镜像的启动页提示为准,
|
|
79
|
+
首次登录后应立即修改密码。
|
|
80
|
+
|
|
81
|
+
## 关断测试
|
|
82
|
+
|
|
83
|
+
确认设备已经连到该 Broker 后,可运行:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
python examples/turn_off.py \
|
|
87
|
+
--host <运行 EMQX 的局域网 IP> \
|
|
88
|
+
--mac <插座 MAC 地址>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
脚本先查询当前状态,再发布关断指令,最后重新查询并验证设备报告为关闭。
|
|
92
|
+
|
|
93
|
+
## 设备 MQTT 配置
|
|
94
|
+
|
|
95
|
+
在使用 SDK 前,插座必须已经:
|
|
96
|
+
|
|
97
|
+
1. 完成 2.4 GHz Wi-Fi 配网;
|
|
98
|
+
2. 配置为连接你的 MQTT Broker / EMQX;
|
|
99
|
+
3. 配置与 SDK 一致的 MQTT Topic。
|
|
100
|
+
|
|
101
|
+
`geme-plug` 默认使用两个简洁的 Topic:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
request
|
|
105
|
+
response
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- SDK 向 `request` 发布控制指令,设备订阅该主题;
|
|
109
|
+
- SDK 订阅 `response`,设备通过该主题返回状态和电量数据。
|
|
110
|
+
|
|
111
|
+
在 GemeOpen 的“自定义 MQTT”页面中填写:
|
|
112
|
+
|
|
113
|
+
- 订阅主题:`request`
|
|
114
|
+
- 发布主题:`response`
|
|
115
|
+
|
|
116
|
+
设备配置页的字段名称与 SDK 视角相反:SDK 的 `publish_topic` 对应设备的
|
|
117
|
+
“订阅主题”,SDK 的 `subscribe_topic` 对应设备的“发布主题”。如果同一个
|
|
118
|
+
Broker 连接多台设备,应为每台设备设置独立 Topic,避免消息互相干扰。
|
|
119
|
+
|
|
120
|
+
如果你已经给设备配置了其他 Topic,可以显式覆盖:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
plug = SmartPlug(
|
|
124
|
+
host="192.168.31.100",
|
|
125
|
+
mac="AABBCCDDEEFF",
|
|
126
|
+
publish_topic="my/device/command",
|
|
127
|
+
subscribe_topic="my/device/state",
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## API
|
|
132
|
+
|
|
133
|
+
### `SmartPlug(...)`
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
SmartPlug(
|
|
137
|
+
host: str,
|
|
138
|
+
mac: str,
|
|
139
|
+
port: int = 1883,
|
|
140
|
+
username: str | None = None,
|
|
141
|
+
password: str | None = None,
|
|
142
|
+
timeout: float = 5.0,
|
|
143
|
+
publish_topic: str | None = None,
|
|
144
|
+
subscribe_topic: str | None = None,
|
|
145
|
+
client_id: str | None = None,
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
MAC 可以使用以下任意格式:
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
AABBCCDDEEFF
|
|
153
|
+
AA:BB:CC:DD:EE:FF
|
|
154
|
+
AA-BB-CC-DD-EE-FF
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
SDK 内部统一规范化为 `aabbccddeeff`。
|
|
158
|
+
|
|
159
|
+
### `connect()` / `disconnect()`
|
|
160
|
+
|
|
161
|
+
连接或断开 MQTT Broker。
|
|
162
|
+
|
|
163
|
+
### `turn_on()` / `turn_off()`
|
|
164
|
+
|
|
165
|
+
控制插座通断电。SDK 根据 GSPM1B 协议发送:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{"type":"event","key":1}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
或:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{"type":"event","key":0}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `get_status()`
|
|
178
|
+
|
|
179
|
+
查询设备状态,返回 `PlugStatus`,常用字段包括:
|
|
180
|
+
|
|
181
|
+
- `mac`
|
|
182
|
+
- `device_type`
|
|
183
|
+
- `version`
|
|
184
|
+
- `key` / `is_on`
|
|
185
|
+
- `signal`
|
|
186
|
+
- `ip`
|
|
187
|
+
- `ssid`
|
|
188
|
+
- `wifi_lock`
|
|
189
|
+
- `key_lock`
|
|
190
|
+
- `on_state`
|
|
191
|
+
- `timer_enable`
|
|
192
|
+
- `timer_interval`
|
|
193
|
+
|
|
194
|
+
### `get_power()`
|
|
195
|
+
|
|
196
|
+
查询电量数据,返回 `PowerStatus`:
|
|
197
|
+
|
|
198
|
+
- `voltage`:V
|
|
199
|
+
- `current`:A
|
|
200
|
+
- `power`:W
|
|
201
|
+
- `energy`:kWh
|
|
202
|
+
- `key` / `is_on`
|
|
203
|
+
|
|
204
|
+
## 说明
|
|
205
|
+
|
|
206
|
+
本版本聚焦于最基础、稳定的控制能力:连接 Broker、通断控制、状态查询和电量查询。设备配网和局域网自动发现暂不包含在 `1.0.0` 中。
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "geme-plug"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python MQTT SDK for GemeOpen GSPM1B smart plugs using a self-hosted broker such as EMQX."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "loks666" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["GemeOpen", "GeekOpen", "GSPM1B", "MQTT", "EMQX", "smart plug", "IoT"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Home Automation",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"paho-mqtt>=2.1,<3",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/loks666/geme-plug"
|
|
34
|
+
Repository = "https://github.com/loks666/geme-plug"
|
|
35
|
+
Issues = "https://github.com/loks666/geme-plug/issues"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
addopts = "-q"
|
|
42
|
+
testpaths = ["tests"]
|
geme_plug-1.0.0/setup.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .client import SmartPlug, normalize_mac
|
|
2
|
+
from .exceptions import ConnectionError, GemePlugError, ProtocolError, RequestTimeoutError
|
|
3
|
+
from .models import PlugStatus, PowerStatus
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"SmartPlug",
|
|
7
|
+
"PlugStatus",
|
|
8
|
+
"PowerStatus",
|
|
9
|
+
"normalize_mac",
|
|
10
|
+
"GemePlugError",
|
|
11
|
+
"ConnectionError",
|
|
12
|
+
"RequestTimeoutError",
|
|
13
|
+
"ProtocolError",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import paho.mqtt.client as mqtt
|
|
10
|
+
|
|
11
|
+
from .exceptions import ConnectionError, ProtocolError, RequestTimeoutError
|
|
12
|
+
from .models import PlugStatus, PowerStatus
|
|
13
|
+
|
|
14
|
+
_MAC_RE = re.compile(r"^[0-9a-f]{12}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_mac(mac: str) -> str:
|
|
18
|
+
"""Normalize a MAC address to 12 lowercase hexadecimal characters."""
|
|
19
|
+
normalized = re.sub(r"[^0-9A-Fa-f]", "", mac).lower()
|
|
20
|
+
if not _MAC_RE.fullmatch(normalized):
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"mac must contain exactly 12 hexadecimal characters, "
|
|
23
|
+
"for example 'AABBCCDDEEFF' or 'AA:BB:CC:DD:EE:FF'"
|
|
24
|
+
)
|
|
25
|
+
return normalized
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SmartPlug:
|
|
29
|
+
"""Synchronous MQTT client for GemeOpen GSPM1B-compatible smart plugs.
|
|
30
|
+
|
|
31
|
+
The plug must already be configured to connect to the same MQTT broker and
|
|
32
|
+
use the same publish/subscribe topics as this client.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
host: str,
|
|
38
|
+
mac: str,
|
|
39
|
+
port: int = 1883,
|
|
40
|
+
username: str | None = None,
|
|
41
|
+
password: str | None = None,
|
|
42
|
+
timeout: float = 5.0,
|
|
43
|
+
publish_topic: str | None = None,
|
|
44
|
+
subscribe_topic: str | None = None,
|
|
45
|
+
client_id: str | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
if not host or not host.strip():
|
|
48
|
+
raise ValueError("host cannot be empty")
|
|
49
|
+
if port <= 0 or port > 65535:
|
|
50
|
+
raise ValueError("port must be between 1 and 65535")
|
|
51
|
+
if timeout <= 0:
|
|
52
|
+
raise ValueError("timeout must be greater than 0")
|
|
53
|
+
if password is not None and username is None:
|
|
54
|
+
raise ValueError("username is required when password is provided")
|
|
55
|
+
|
|
56
|
+
self.host = host.strip()
|
|
57
|
+
self.port = int(port)
|
|
58
|
+
self.mac = normalize_mac(mac)
|
|
59
|
+
self.username = username
|
|
60
|
+
self.password = password
|
|
61
|
+
self.timeout = float(timeout)
|
|
62
|
+
|
|
63
|
+
# The SDK publishes commands to the topic subscribed to by the device,
|
|
64
|
+
# and subscribes to the topic used by the device for responses.
|
|
65
|
+
self.publish_topic = publish_topic or "request"
|
|
66
|
+
self.subscribe_topic = subscribe_topic or "response"
|
|
67
|
+
self.client_id = client_id or f"geme-plug-{self.mac}-{uuid.uuid4().hex[:8]}"
|
|
68
|
+
|
|
69
|
+
self._connected = threading.Event()
|
|
70
|
+
self._connect_error: str | None = None
|
|
71
|
+
self._pending: dict[str, tuple[threading.Event, dict[str, Any] | None]] = {}
|
|
72
|
+
self._pending_lock = threading.Lock()
|
|
73
|
+
|
|
74
|
+
self._client = mqtt.Client(
|
|
75
|
+
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
|
76
|
+
client_id=self.client_id,
|
|
77
|
+
protocol=mqtt.MQTTv311,
|
|
78
|
+
)
|
|
79
|
+
if self.username is not None:
|
|
80
|
+
self._client.username_pw_set(self.username, self.password)
|
|
81
|
+
|
|
82
|
+
self._client.on_connect = self._on_connect
|
|
83
|
+
self._client.on_disconnect = self._on_disconnect
|
|
84
|
+
self._client.on_message = self._on_message
|
|
85
|
+
|
|
86
|
+
def connect(self) -> "SmartPlug":
|
|
87
|
+
"""Connect to the MQTT broker and subscribe to the plug response topic."""
|
|
88
|
+
if self._connected.is_set():
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
self._connect_error = None
|
|
92
|
+
try:
|
|
93
|
+
rc = self._client.connect(self.host, self.port, keepalive=60)
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
raise ConnectionError(
|
|
96
|
+
f"failed to connect to MQTT broker {self.host}:{self.port}: {exc}"
|
|
97
|
+
) from exc
|
|
98
|
+
if rc != mqtt.MQTT_ERR_SUCCESS:
|
|
99
|
+
raise ConnectionError(
|
|
100
|
+
f"failed to connect to MQTT broker {self.host}:{self.port}: rc={rc}"
|
|
101
|
+
)
|
|
102
|
+
self._client.loop_start()
|
|
103
|
+
|
|
104
|
+
if not self._connected.wait(self.timeout):
|
|
105
|
+
self._client.loop_stop()
|
|
106
|
+
try:
|
|
107
|
+
self._client.disconnect()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
detail = f": {self._connect_error}" if self._connect_error else ""
|
|
111
|
+
raise ConnectionError(
|
|
112
|
+
f"failed to connect to MQTT broker {self.host}:{self.port}{detail}"
|
|
113
|
+
)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def disconnect(self) -> None:
|
|
117
|
+
"""Disconnect from the MQTT broker."""
|
|
118
|
+
if not self._connected.is_set():
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
self._client.disconnect()
|
|
122
|
+
finally:
|
|
123
|
+
self._client.loop_stop()
|
|
124
|
+
self._connected.clear()
|
|
125
|
+
|
|
126
|
+
def turn_on(self) -> None:
|
|
127
|
+
"""Turn the plug output on."""
|
|
128
|
+
self._publish({"type": "event", "key": 1})
|
|
129
|
+
|
|
130
|
+
def turn_off(self) -> None:
|
|
131
|
+
"""Turn the plug output off."""
|
|
132
|
+
self._publish({"type": "event", "key": 0})
|
|
133
|
+
|
|
134
|
+
def get_status(self) -> PlugStatus:
|
|
135
|
+
"""Query basic plug status."""
|
|
136
|
+
payload = self._request({"type": "info"})
|
|
137
|
+
return PlugStatus.from_payload(payload)
|
|
138
|
+
|
|
139
|
+
def get_power(self) -> PowerStatus:
|
|
140
|
+
"""Query voltage, current, power, energy and switch state."""
|
|
141
|
+
payload = self._request({"type": "statistic"})
|
|
142
|
+
return PowerStatus.from_payload(payload)
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> "SmartPlug":
|
|
145
|
+
return self.connect()
|
|
146
|
+
|
|
147
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
148
|
+
self.disconnect()
|
|
149
|
+
|
|
150
|
+
def _ensure_connected(self) -> None:
|
|
151
|
+
if not self._connected.is_set():
|
|
152
|
+
raise ConnectionError("not connected; call plug.connect() first")
|
|
153
|
+
|
|
154
|
+
def _publish(self, payload: dict[str, Any]) -> None:
|
|
155
|
+
self._ensure_connected()
|
|
156
|
+
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
157
|
+
info = self._client.publish(self.publish_topic, encoded, qos=0, retain=False)
|
|
158
|
+
if info.rc != mqtt.MQTT_ERR_SUCCESS:
|
|
159
|
+
raise ConnectionError(f"MQTT publish failed with rc={info.rc}")
|
|
160
|
+
info.wait_for_publish(timeout=self.timeout)
|
|
161
|
+
|
|
162
|
+
def _request(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
163
|
+
self._ensure_connected()
|
|
164
|
+
message_id = uuid.uuid4().hex
|
|
165
|
+
request = dict(payload)
|
|
166
|
+
request["messageId"] = message_id
|
|
167
|
+
event = threading.Event()
|
|
168
|
+
|
|
169
|
+
with self._pending_lock:
|
|
170
|
+
self._pending[message_id] = (event, None)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
self._publish(request)
|
|
174
|
+
if not event.wait(self.timeout):
|
|
175
|
+
raise RequestTimeoutError(
|
|
176
|
+
f"device {self.mac} did not respond within {self.timeout:g}s"
|
|
177
|
+
)
|
|
178
|
+
with self._pending_lock:
|
|
179
|
+
_, response = self._pending.get(message_id, (event, None))
|
|
180
|
+
if response is None:
|
|
181
|
+
raise ProtocolError("device response was empty")
|
|
182
|
+
return response
|
|
183
|
+
finally:
|
|
184
|
+
with self._pending_lock:
|
|
185
|
+
self._pending.pop(message_id, None)
|
|
186
|
+
|
|
187
|
+
def _on_connect(
|
|
188
|
+
self,
|
|
189
|
+
client: mqtt.Client,
|
|
190
|
+
userdata: Any,
|
|
191
|
+
flags: mqtt.ConnectFlags,
|
|
192
|
+
reason_code: mqtt.ReasonCode,
|
|
193
|
+
properties: mqtt.Properties | None,
|
|
194
|
+
) -> None:
|
|
195
|
+
if reason_code.is_failure:
|
|
196
|
+
self._connect_error = str(reason_code)
|
|
197
|
+
return
|
|
198
|
+
result, _ = client.subscribe(self.subscribe_topic, qos=0)
|
|
199
|
+
if result != mqtt.MQTT_ERR_SUCCESS:
|
|
200
|
+
self._connect_error = f"subscribe failed with rc={result}"
|
|
201
|
+
return
|
|
202
|
+
self._connected.set()
|
|
203
|
+
|
|
204
|
+
def _on_disconnect(
|
|
205
|
+
self,
|
|
206
|
+
client: mqtt.Client,
|
|
207
|
+
userdata: Any,
|
|
208
|
+
disconnect_flags: mqtt.DisconnectFlags,
|
|
209
|
+
reason_code: mqtt.ReasonCode,
|
|
210
|
+
properties: mqtt.Properties | None,
|
|
211
|
+
) -> None:
|
|
212
|
+
self._connected.clear()
|
|
213
|
+
|
|
214
|
+
def _on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
|
|
215
|
+
try:
|
|
216
|
+
payload = json.loads(message.payload.decode("utf-8"))
|
|
217
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
218
|
+
return
|
|
219
|
+
if not isinstance(payload, dict):
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
message_id = payload.get("messageId")
|
|
223
|
+
if message_id is None:
|
|
224
|
+
return
|
|
225
|
+
message_id = str(message_id)
|
|
226
|
+
|
|
227
|
+
with self._pending_lock:
|
|
228
|
+
pending = self._pending.get(message_id)
|
|
229
|
+
if pending is None:
|
|
230
|
+
return
|
|
231
|
+
event, _ = pending
|
|
232
|
+
self._pending[message_id] = (event, payload)
|
|
233
|
+
event.set()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class GemePlugError(Exception):
|
|
2
|
+
"""Base exception for geme-plug."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConnectionError(GemePlugError):
|
|
6
|
+
"""Raised when the MQTT broker connection cannot be established."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RequestTimeoutError(GemePlugError):
|
|
10
|
+
"""Raised when the plug does not respond within the configured timeout."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProtocolError(GemePlugError):
|
|
14
|
+
"""Raised when a malformed or unexpected device response is received."""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class PlugStatus:
|
|
9
|
+
mac: str
|
|
10
|
+
device_type: str | None = None
|
|
11
|
+
version: str | None = None
|
|
12
|
+
key: int | None = None
|
|
13
|
+
wifi_lock: int | None = None
|
|
14
|
+
key_lock: int | None = None
|
|
15
|
+
signal: int | None = None
|
|
16
|
+
on_state: int | None = None
|
|
17
|
+
timer_enable: int | None = None
|
|
18
|
+
timer_interval: int | None = None
|
|
19
|
+
ip: str | None = None
|
|
20
|
+
ssid: str | None = None
|
|
21
|
+
message_id: str | None = None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_on(self) -> bool | None:
|
|
25
|
+
if self.key is None:
|
|
26
|
+
return None
|
|
27
|
+
return self.key == 1
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_payload(cls, payload: dict[str, Any]) -> "PlugStatus":
|
|
31
|
+
return cls(
|
|
32
|
+
mac=str(payload.get("mac", "")),
|
|
33
|
+
device_type=payload.get("type"),
|
|
34
|
+
version=payload.get("version"),
|
|
35
|
+
key=_to_int(payload.get("key")),
|
|
36
|
+
wifi_lock=_to_int(payload.get("wifiLock")),
|
|
37
|
+
key_lock=_to_int(payload.get("keyLock")),
|
|
38
|
+
signal=_to_int(payload.get("signal")),
|
|
39
|
+
on_state=_to_int(payload.get("onState")),
|
|
40
|
+
timer_enable=_to_int(payload.get("timerEnable")),
|
|
41
|
+
timer_interval=_to_int(payload.get("timerInterval")),
|
|
42
|
+
ip=payload.get("ip"),
|
|
43
|
+
ssid=payload.get("ssid"),
|
|
44
|
+
message_id=_to_str_or_none(payload.get("messageId")),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class PowerStatus:
|
|
50
|
+
mac: str
|
|
51
|
+
voltage: float | None = None
|
|
52
|
+
current: float | None = None
|
|
53
|
+
power: float | None = None
|
|
54
|
+
energy: float | None = None
|
|
55
|
+
key: int | None = None
|
|
56
|
+
message_id: str | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_on(self) -> bool | None:
|
|
60
|
+
if self.key is None:
|
|
61
|
+
return None
|
|
62
|
+
return self.key == 1
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_payload(cls, payload: dict[str, Any]) -> "PowerStatus":
|
|
66
|
+
return cls(
|
|
67
|
+
mac=str(payload.get("mac", "")),
|
|
68
|
+
voltage=_to_float(payload.get("voltage")),
|
|
69
|
+
current=_to_float(payload.get("current")),
|
|
70
|
+
power=_to_float(payload.get("power")),
|
|
71
|
+
energy=_to_float(payload.get("energy")),
|
|
72
|
+
key=_to_int(payload.get("key")),
|
|
73
|
+
message_id=_to_str_or_none(payload.get("messageId")),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _to_int(value: Any) -> int | None:
|
|
78
|
+
if value is None:
|
|
79
|
+
return None
|
|
80
|
+
return int(value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _to_float(value: Any) -> float | None:
|
|
84
|
+
if value is None:
|
|
85
|
+
return None
|
|
86
|
+
return float(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _to_str_or_none(value: Any) -> str | None:
|
|
90
|
+
if value is None:
|
|
91
|
+
return None
|
|
92
|
+
return str(value)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geme-plug
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python MQTT SDK for GemeOpen GSPM1B smart plugs using a self-hosted broker such as EMQX.
|
|
5
|
+
Author: loks666
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/loks666/geme-plug
|
|
8
|
+
Project-URL: Repository, https://github.com/loks666/geme-plug
|
|
9
|
+
Project-URL: Issues, https://github.com/loks666/geme-plug/issues
|
|
10
|
+
Keywords: GemeOpen,GeekOpen,GSPM1B,MQTT,EMQX,smart plug,IoT
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: paho-mqtt<3,>=2.1
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# geme-plug
|
|
28
|
+
|
|
29
|
+
`geme-plug` 是一个用于 **GemeOpen / GeekOpen GSPM1B 智能插座** 的 Python MQTT SDK,面向自建 MQTT Broker(例如 EMQX)。
|
|
30
|
+
|
|
31
|
+
> 本项目是第三方开源 SDK,与 GemeOpen / 武汉智鸟科技无隶属关系。
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install geme-plug
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 快速开始
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from geme_plug import SmartPlug
|
|
43
|
+
|
|
44
|
+
plug = SmartPlug(
|
|
45
|
+
host="192.168.31.100", # EMQX Broker IP / hostname
|
|
46
|
+
mac="AABBCCDDEEFF",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
plug.connect()
|
|
50
|
+
|
|
51
|
+
plug.turn_on()
|
|
52
|
+
plug.turn_off()
|
|
53
|
+
|
|
54
|
+
print(plug.get_status())
|
|
55
|
+
print(plug.get_power())
|
|
56
|
+
|
|
57
|
+
plug.disconnect()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
也支持上下文管理器:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from geme_plug import SmartPlug
|
|
64
|
+
|
|
65
|
+
with SmartPlug(host="192.168.31.100", mac="AA:BB:CC:DD:EE:FF") as plug:
|
|
66
|
+
plug.turn_on()
|
|
67
|
+
print(plug.get_power())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## EMQX 认证
|
|
71
|
+
|
|
72
|
+
仓库中的 Compose 配置默认启用 MQTT 客户端认证,并内置以下局域网设备账号:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
用户名:geme-plug
|
|
76
|
+
密码:x7Tq9V2mK8rP4nD6sH3wF5cJ1bL0zQeA
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
SDK 和设备配网页都要使用相同的账号:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
plug = SmartPlug(
|
|
83
|
+
host="192.168.31.100",
|
|
84
|
+
mac="AABBCCDDEEFF",
|
|
85
|
+
username="geme-plug",
|
|
86
|
+
password="x7Tq9V2mK8rP4nD6sH3wF5cJ1bL0zQeA",
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`port` 默认是 `1883`。
|
|
91
|
+
|
|
92
|
+
## 使用 Docker Compose 启动 EMQX
|
|
93
|
+
|
|
94
|
+
仓库内置了 EMQX Compose 配置,包含账号密码认证和持久化数据卷:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
docker compose up -d
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- MQTT Broker:`mqtt://<本机局域网 IP>:1883`
|
|
101
|
+
- EMQX Dashboard:`http://127.0.0.1:18083`
|
|
102
|
+
|
|
103
|
+
设备必须连接到运行 EMQX 的电脑的局域网 IP,不能使用设备视角下的
|
|
104
|
+
`127.0.0.1`。EMQX Dashboard 的初始登录信息请以当前镜像的启动页提示为准,
|
|
105
|
+
首次登录后应立即修改密码。
|
|
106
|
+
|
|
107
|
+
## 关断测试
|
|
108
|
+
|
|
109
|
+
确认设备已经连到该 Broker 后,可运行:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
python examples/turn_off.py \
|
|
113
|
+
--host <运行 EMQX 的局域网 IP> \
|
|
114
|
+
--mac <插座 MAC 地址>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
脚本先查询当前状态,再发布关断指令,最后重新查询并验证设备报告为关闭。
|
|
118
|
+
|
|
119
|
+
## 设备 MQTT 配置
|
|
120
|
+
|
|
121
|
+
在使用 SDK 前,插座必须已经:
|
|
122
|
+
|
|
123
|
+
1. 完成 2.4 GHz Wi-Fi 配网;
|
|
124
|
+
2. 配置为连接你的 MQTT Broker / EMQX;
|
|
125
|
+
3. 配置与 SDK 一致的 MQTT Topic。
|
|
126
|
+
|
|
127
|
+
`geme-plug` 默认使用两个简洁的 Topic:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
request
|
|
131
|
+
response
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- SDK 向 `request` 发布控制指令,设备订阅该主题;
|
|
135
|
+
- SDK 订阅 `response`,设备通过该主题返回状态和电量数据。
|
|
136
|
+
|
|
137
|
+
在 GemeOpen 的“自定义 MQTT”页面中填写:
|
|
138
|
+
|
|
139
|
+
- 订阅主题:`request`
|
|
140
|
+
- 发布主题:`response`
|
|
141
|
+
|
|
142
|
+
设备配置页的字段名称与 SDK 视角相反:SDK 的 `publish_topic` 对应设备的
|
|
143
|
+
“订阅主题”,SDK 的 `subscribe_topic` 对应设备的“发布主题”。如果同一个
|
|
144
|
+
Broker 连接多台设备,应为每台设备设置独立 Topic,避免消息互相干扰。
|
|
145
|
+
|
|
146
|
+
如果你已经给设备配置了其他 Topic,可以显式覆盖:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
plug = SmartPlug(
|
|
150
|
+
host="192.168.31.100",
|
|
151
|
+
mac="AABBCCDDEEFF",
|
|
152
|
+
publish_topic="my/device/command",
|
|
153
|
+
subscribe_topic="my/device/state",
|
|
154
|
+
)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## API
|
|
158
|
+
|
|
159
|
+
### `SmartPlug(...)`
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
SmartPlug(
|
|
163
|
+
host: str,
|
|
164
|
+
mac: str,
|
|
165
|
+
port: int = 1883,
|
|
166
|
+
username: str | None = None,
|
|
167
|
+
password: str | None = None,
|
|
168
|
+
timeout: float = 5.0,
|
|
169
|
+
publish_topic: str | None = None,
|
|
170
|
+
subscribe_topic: str | None = None,
|
|
171
|
+
client_id: str | None = None,
|
|
172
|
+
)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
MAC 可以使用以下任意格式:
|
|
176
|
+
|
|
177
|
+
```text
|
|
178
|
+
AABBCCDDEEFF
|
|
179
|
+
AA:BB:CC:DD:EE:FF
|
|
180
|
+
AA-BB-CC-DD-EE-FF
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
SDK 内部统一规范化为 `aabbccddeeff`。
|
|
184
|
+
|
|
185
|
+
### `connect()` / `disconnect()`
|
|
186
|
+
|
|
187
|
+
连接或断开 MQTT Broker。
|
|
188
|
+
|
|
189
|
+
### `turn_on()` / `turn_off()`
|
|
190
|
+
|
|
191
|
+
控制插座通断电。SDK 根据 GSPM1B 协议发送:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{"type":"event","key":1}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
或:
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{"type":"event","key":0}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### `get_status()`
|
|
204
|
+
|
|
205
|
+
查询设备状态,返回 `PlugStatus`,常用字段包括:
|
|
206
|
+
|
|
207
|
+
- `mac`
|
|
208
|
+
- `device_type`
|
|
209
|
+
- `version`
|
|
210
|
+
- `key` / `is_on`
|
|
211
|
+
- `signal`
|
|
212
|
+
- `ip`
|
|
213
|
+
- `ssid`
|
|
214
|
+
- `wifi_lock`
|
|
215
|
+
- `key_lock`
|
|
216
|
+
- `on_state`
|
|
217
|
+
- `timer_enable`
|
|
218
|
+
- `timer_interval`
|
|
219
|
+
|
|
220
|
+
### `get_power()`
|
|
221
|
+
|
|
222
|
+
查询电量数据,返回 `PowerStatus`:
|
|
223
|
+
|
|
224
|
+
- `voltage`:V
|
|
225
|
+
- `current`:A
|
|
226
|
+
- `power`:W
|
|
227
|
+
- `energy`:kWh
|
|
228
|
+
- `key` / `is_on`
|
|
229
|
+
|
|
230
|
+
## 说明
|
|
231
|
+
|
|
232
|
+
本版本聚焦于最基础、稳定的控制能力:连接 Broker、通断控制、状态查询和电量查询。设备配网和局域网自动发现暂不包含在 `1.0.0` 中。
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
setup.py
|
|
5
|
+
src/geme_plug/__init__.py
|
|
6
|
+
src/geme_plug/client.py
|
|
7
|
+
src/geme_plug/exceptions.py
|
|
8
|
+
src/geme_plug/models.py
|
|
9
|
+
src/geme_plug.egg-info/PKG-INFO
|
|
10
|
+
src/geme_plug.egg-info/SOURCES.txt
|
|
11
|
+
src/geme_plug.egg-info/dependency_links.txt
|
|
12
|
+
src/geme_plug.egg-info/requires.txt
|
|
13
|
+
src/geme_plug.egg-info/top_level.txt
|
|
14
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
paho-mqtt<3,>=2.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
geme_plug
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from geme_plug import PlugStatus, PowerStatus, SmartPlug, normalize_mac
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_normalize_mac():
|
|
10
|
+
assert normalize_mac("AA:BB:CC:DD:EE:FF") == "aabbccddeeff"
|
|
11
|
+
assert normalize_mac("aa-bb-cc-dd-ee-ff") == "aabbccddeeff"
|
|
12
|
+
assert normalize_mac("AABBCCDDEEFF") == "aabbccddeeff"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize("value", ["", "abc", "GG:BB:CC:DD:EE:FF", "AABBCCDDEEFF00"])
|
|
16
|
+
def test_normalize_mac_rejects_invalid(value):
|
|
17
|
+
with pytest.raises(ValueError):
|
|
18
|
+
normalize_mac(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_default_topics():
|
|
22
|
+
plug = SmartPlug(host="127.0.0.1", mac="AA:BB:CC:DD:EE:FF")
|
|
23
|
+
assert plug.publish_topic == "request"
|
|
24
|
+
assert plug.subscribe_topic == "response"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_custom_topics():
|
|
28
|
+
plug = SmartPlug(
|
|
29
|
+
host="127.0.0.1",
|
|
30
|
+
mac="AABBCCDDEEFF",
|
|
31
|
+
publish_topic="custom/command",
|
|
32
|
+
subscribe_topic="custom/state",
|
|
33
|
+
)
|
|
34
|
+
assert plug.publish_topic == "custom/command"
|
|
35
|
+
assert plug.subscribe_topic == "custom/state"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_status_model():
|
|
39
|
+
status = PlugStatus.from_payload(
|
|
40
|
+
{
|
|
41
|
+
"mac": "aabbccddeeff",
|
|
42
|
+
"type": "Socket-mini",
|
|
43
|
+
"version": "2.0.0",
|
|
44
|
+
"key": 1,
|
|
45
|
+
"wifiLock": 0,
|
|
46
|
+
"keyLock": 0,
|
|
47
|
+
"signal": -55,
|
|
48
|
+
"onState": 1,
|
|
49
|
+
"timerEnable": 1,
|
|
50
|
+
"timerInterval": 60,
|
|
51
|
+
"ip": "192.168.31.123",
|
|
52
|
+
"ssid": "wifi",
|
|
53
|
+
"messageId": "abc",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
assert status.is_on is True
|
|
57
|
+
assert status.device_type == "Socket-mini"
|
|
58
|
+
assert status.ip == "192.168.31.123"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_power_model():
|
|
62
|
+
power = PowerStatus.from_payload(
|
|
63
|
+
{
|
|
64
|
+
"mac": "aabbccddeeff",
|
|
65
|
+
"voltage": 226.024,
|
|
66
|
+
"current": 1.027,
|
|
67
|
+
"power": 232.511,
|
|
68
|
+
"energy": 25.047,
|
|
69
|
+
"key": 1,
|
|
70
|
+
"messageId": "abc",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
assert power.voltage == pytest.approx(226.024)
|
|
74
|
+
assert power.energy == pytest.approx(25.047)
|
|
75
|
+
assert power.is_on is True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _PublishInfo:
|
|
79
|
+
rc = 0
|
|
80
|
+
|
|
81
|
+
def wait_for_publish(self, timeout=None):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _FakeClient:
|
|
86
|
+
def __init__(self, plug):
|
|
87
|
+
self.plug = plug
|
|
88
|
+
self.published = []
|
|
89
|
+
|
|
90
|
+
def publish(self, topic, payload, qos=0, retain=False):
|
|
91
|
+
self.published.append((topic, payload))
|
|
92
|
+
data = json.loads(payload)
|
|
93
|
+
if data["type"] in {"info", "statistic"}:
|
|
94
|
+
response = {"messageId": data["messageId"], "mac": self.plug.mac}
|
|
95
|
+
if data["type"] == "info":
|
|
96
|
+
response.update({"type": "Socket-mini", "key": 1, "ip": "192.168.31.9"})
|
|
97
|
+
else:
|
|
98
|
+
response.update({"voltage": 220.0, "current": 0.5, "power": 110.0, "energy": 1.25, "key": 1})
|
|
99
|
+
|
|
100
|
+
class Message:
|
|
101
|
+
payload = json.dumps(response).encode()
|
|
102
|
+
|
|
103
|
+
threading.Timer(0.01, lambda: self.plug._on_message(self, None, Message())).start()
|
|
104
|
+
return _PublishInfo()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_request_response_flow():
|
|
108
|
+
plug = SmartPlug(host="127.0.0.1", mac="AABBCCDDEEFF", timeout=1)
|
|
109
|
+
plug._connected.set()
|
|
110
|
+
plug._client = _FakeClient(plug)
|
|
111
|
+
|
|
112
|
+
status = plug.get_status()
|
|
113
|
+
power = plug.get_power()
|
|
114
|
+
|
|
115
|
+
assert status.device_type == "Socket-mini"
|
|
116
|
+
assert status.ip == "192.168.31.9"
|
|
117
|
+
assert power.power == pytest.approx(110.0)
|