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.
@@ -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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from setuptools import setup
2
+
3
+ setup()
@@ -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
+ 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)