python-mystrom 2.5.0__tar.gz → 2.7.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.
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2015-2025 Fabian Affolter <fabian@affolter-engineering.ch>
3
+ Copyright (c) 2015-2026 Fabian Affolter <fabian@affolter-engineering.ch>
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,17 +1,17 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: python-mystrom
3
- Version: 2.5.0
3
+ Version: 2.7.0
4
4
  Summary: Asynchronous Python API client for interacting with myStrom devices
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Keywords: myStrom,API,client,asynchronous
7
8
  Author: Fabian Affolter
8
9
  Author-email: fabian@affolter-engineering.ch
9
- Requires-Python: >=3.11
10
+ Requires-Python: >=3.13
10
11
  Classifier: License :: OSI Approved :: MIT License
11
12
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
13
  Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
15
  Requires-Dist: aiohttp
16
16
  Requires-Dist: click
17
17
  Requires-Dist: requests
@@ -58,7 +58,7 @@ On a Fedora-based system or on a CentOS/RHEL machine which has EPEL enabled.
58
58
 
59
59
  $ sudo dnf -y install python3-mystrom
60
60
 
61
- For Nix or NixOS users is a package available. Keep in mind that the lastest releases might only
61
+ For Nix or NixOS users is a package available. Keep in mind that the latest releases might only
62
62
  be present in the ``unstable`` channel.
63
63
 
64
64
  .. code:: bash
@@ -37,7 +37,7 @@ On a Fedora-based system or on a CentOS/RHEL machine which has EPEL enabled.
37
37
 
38
38
  $ sudo dnf -y install python3-mystrom
39
39
 
40
- For Nix or NixOS users is a package available. Keep in mind that the lastest releases might only
40
+ For Nix or NixOS users is a package available. Keep in mind that the latest releases might only
41
41
  be present in the ``unstable`` channel.
42
42
 
43
43
  .. code:: bash
@@ -20,12 +20,15 @@ async def _request(
20
20
  data: Optional[Any] = None,
21
21
  json_data: Optional[dict] = None,
22
22
  params: Optional[Mapping[str, str]] = None,
23
+ token: Optional[str] = None,
23
24
  ) -> Any:
24
25
  """Handle a request to the myStrom device."""
25
26
  headers = {
26
27
  "User-Agent": USER_AGENT,
27
28
  "Accept": "application/json, text/plain, */*",
28
29
  }
30
+ if token:
31
+ headers["Token"] = token
29
32
 
30
33
  if self._session is None:
31
34
  self._session = aiohttp.ClientSession()
@@ -53,7 +56,12 @@ async def _request(
53
56
  ) from exception
54
57
 
55
58
  content_type = response.headers.get("Content-Type", "")
56
- if (response.status // 100) in [4, 5]:
59
+ if response.status == 404:
60
+ raise MyStromConnectionError(
61
+ "Error occurred while authenticating with myStrom device."
62
+ )
63
+
64
+ elif (response.status // 100) in [4, 5]:
57
65
  response.close()
58
66
 
59
67
  if "application/json" in content_type:
@@ -21,6 +21,7 @@ class MyStromBulb:
21
21
  self,
22
22
  host: str,
23
23
  mac: str,
24
+ token: Optional[str] = None,
24
25
  session: aiohttp.client.ClientSession = None,
25
26
  ) -> None:
26
27
  """Initialize the bulb."""
@@ -38,10 +39,11 @@ class MyStromBulb:
38
39
  self._state = None
39
40
  self._transition_time = 0
40
41
  self.uri = URL.build(scheme="http", host=self._host).join(URI_BULB) / self._mac
42
+ self.token = token
41
43
 
42
44
  async def get_state(self) -> None:
43
45
  """Get the state of the bulb."""
44
- response = await request(self, uri=self.uri)
46
+ response = await request(self, uri=self.uri, token=self.token)
45
47
  self._consumption = response[self._mac]["power"]
46
48
  self._firmware = response[self._mac]["fw_version"]
47
49
  self._color = response[self._mac]["color"]
@@ -93,7 +95,7 @@ class MyStromBulb:
93
95
  async def set_on(self):
94
96
  """Turn the bulb on with the previous settings."""
95
97
  response = await request(
96
- self, uri=self.uri, method="POST", data={"action": "on"}
98
+ self, uri=self.uri, method="POST", data={"action": "on"}, token=self.token
97
99
  )
98
100
  return response
99
101
 
@@ -109,7 +111,9 @@ class MyStromBulb:
109
111
  "action": "on",
110
112
  "color": value,
111
113
  }
112
- response = await request(self, uri=self.uri, method="POST", data=data)
114
+ response = await request(
115
+ self, uri=self.uri, method="POST", data=data, token=self.token
116
+ )
113
117
  return response
114
118
 
115
119
  async def set_color_hsv(self, hue, saturation, value):
@@ -122,7 +126,9 @@ class MyStromBulb:
122
126
  # 'color': f"{hue};{saturation};{value}",
123
127
  # }
124
128
  data = "action=on&color={};{};{}".format(hue, saturation, value)
125
- response = await request(self, uri=self.uri, method="POST", data=data)
129
+ response = await request(
130
+ self, uri=self.uri, method="POST", data=data, token=self.token
131
+ )
126
132
  return response
127
133
 
128
134
  async def set_white(self):
@@ -144,7 +150,9 @@ class MyStromBulb:
144
150
  await self.set_transition_time((duration / max_brightness))
145
151
  for i in range(0, duration):
146
152
  data = "action=on&color=3;{}".format(i)
147
- await request(self, uri=self.uri, method="POST", data=data)
153
+ await request(
154
+ self, uri=self.uri, method="POST", data=data, token=self.token
155
+ )
148
156
  await asyncio.sleep(duration / max_brightness)
149
157
 
150
158
  async def set_flashing(self, duration, hsv1, hsv2):
@@ -159,14 +167,18 @@ class MyStromBulb:
159
167
  async def set_transition_time(self, value):
160
168
  """Set the transition time in ms."""
161
169
  response = await request(
162
- self, uri=self.uri, method="POST", data={"ramp": int(round(value))}
170
+ self,
171
+ uri=self.uri,
172
+ method="POST",
173
+ data={"ramp": int(round(value))},
174
+ token=self.token,
163
175
  )
164
176
  return response
165
177
 
166
178
  async def set_off(self):
167
179
  """Turn the bulb off."""
168
180
  response = await request(
169
- self, uri=self.uri, method="POST", data={"action": "off"}
181
+ self, uri=self.uri, method="POST", data={"action": "off"}, token=self.token
170
182
  )
171
183
  return response
172
184
 
@@ -47,11 +47,15 @@ def config():
47
47
  prompt="MAC address of the device",
48
48
  help="MAC address of the device.",
49
49
  )
50
- def read_config(ip, mac):
50
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
51
+ def read_config(ip, mac, token):
51
52
  """Read the current configuration of a myStrom device."""
52
53
  click.echo("Read configuration from %s" % ip)
53
54
  try:
54
- request = requests.get("http://{}/{}/{}/".format(ip, URI, mac), timeout=TIMEOUT)
55
+ headers = {"Token": token} if token else {}
56
+ request = requests.get(
57
+ "http://{}/{}/{}/".format(ip, URI, mac), timeout=TIMEOUT, headers=headers
58
+ )
55
59
  click.echo(request.json())
56
60
  except requests.exceptions.ConnectionError:
57
61
  click.echo("Communication issue with the device")
@@ -71,6 +75,7 @@ def button():
71
75
  prompt="MAC address of the button",
72
76
  help="MAC address of the button.",
73
77
  )
78
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
74
79
  @click.option(
75
80
  "--single",
76
81
  prompt="URL for a single tap",
@@ -87,7 +92,7 @@ def button():
87
92
  "--long", prompt="URL for a long tab", default="", help="URL for a long tab."
88
93
  )
89
94
  @click.option("--touch", prompt="URL for a touch", default="", help="URL for a touch.")
90
- def write_config(ip, mac, single, double, long, touch):
95
+ def write_config(ip, mac, token, single, double, long, touch):
91
96
  """Write the current configuration of a myStrom button."""
92
97
  click.echo("Write configuration to device %s" % ip)
93
98
  data = {
@@ -97,8 +102,12 @@ def write_config(ip, mac, single, double, long, touch):
97
102
  "touch": touch,
98
103
  }
99
104
  try:
105
+ headers = {"Token": token} if token else {}
100
106
  request = requests.post(
101
- "http://{}/{}/{}/".format(ip, URI, mac), data=data, timeout=TIMEOUT
107
+ "http://{}/{}/{}/".format(ip, URI, mac),
108
+ data=data,
109
+ timeout=TIMEOUT,
110
+ headers=headers,
102
111
  )
103
112
 
104
113
  if request.status_code == 200:
@@ -116,6 +125,7 @@ def write_config(ip, mac, single, double, long, touch):
116
125
  prompt="MAC address of the button",
117
126
  help="MAC address of the button.",
118
127
  )
128
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
119
129
  @click.option(
120
130
  "--hass",
121
131
  prompt="IP address of the Home Assistant instance",
@@ -133,7 +143,7 @@ def write_config(ip, mac, single, double, long, touch):
133
143
  default="",
134
144
  help="ID of the myStrom button.",
135
145
  )
136
- def write_ha_config(ip, mac, hass, port, id):
146
+ def write_ha_config(ip, mac, token, hass, port, id):
137
147
  """Write the configuration for Home Assistant to a myStrom button."""
138
148
  click.echo("Write configuration for Home Assistant to device %s..." % ip)
139
149
 
@@ -145,8 +155,12 @@ def write_ha_config(ip, mac, hass, port, id):
145
155
  "touch": action.format("touch", hass, port, id),
146
156
  }
147
157
  try:
158
+ headers = {"Token": token} if token else {}
148
159
  request = requests.post(
149
- "http://{}/{}/{}/".format(ip, URI, mac), data=data, timeout=TIMEOUT
160
+ "http://{}/{}/{}/".format(ip, URI, mac),
161
+ data=data,
162
+ timeout=TIMEOUT,
163
+ headers=headers,
150
164
  )
151
165
 
152
166
  if request.status_code == 200:
@@ -170,7 +184,8 @@ def write_ha_config(ip, mac, hass, port, id):
170
184
  prompt="MAC address of the button",
171
185
  help="MAC address of the Wifi Button.",
172
186
  )
173
- def reset_config(ip, mac):
187
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
188
+ def reset_config(ip, mac, token):
174
189
  """Reset the current configuration of a myStrom WiFi Button."""
175
190
  click.echo("Reset configuration of button %s..." % ip)
176
191
  data = {
@@ -180,8 +195,12 @@ def reset_config(ip, mac):
180
195
  "touch": "",
181
196
  }
182
197
  try:
198
+ headers = {"Token": token} if token else {}
183
199
  request = requests.post(
184
- "http://{}/{}/{}/".format(ip, URI, mac), data=data, timeout=TIMEOUT
200
+ "http://{}/{}/{}/".format(ip, URI, mac),
201
+ data=data,
202
+ timeout=TIMEOUT,
203
+ headers=headers,
185
204
  )
186
205
 
187
206
  if request.status_code == 200:
@@ -201,11 +220,15 @@ def reset_config(ip, mac):
201
220
  prompt="MAC address of the button",
202
221
  help="MAC address of the Wifi Button.",
203
222
  )
204
- def read_config(ip, mac):
223
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
224
+ def read_config_button(ip, mac, token):
205
225
  """Read the current configuration of a myStrom WiFi Button."""
206
226
  click.echo("Read the configuration of button %s..." % ip)
207
227
  try:
208
- request = requests.get("http://{}/{}/{}/".format(ip, URI, mac), timeout=TIMEOUT)
228
+ headers = {"Token": token} if token else {}
229
+ request = requests.get(
230
+ "http://{}/{}/{}/".format(ip, URI, mac), timeout=TIMEOUT, headers=headers
231
+ )
209
232
  click.echo(request.json())
210
233
  except requests.exceptions.ConnectionError:
211
234
  click.echo("Communication issue with the device. No action performed")
@@ -222,9 +245,10 @@ def bulb():
222
245
  @click.option(
223
246
  "--mac", prompt="MAC address of the bulb", help="MAC address of the bulb."
224
247
  )
225
- async def on(ip, mac):
248
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
249
+ async def on(ip, mac, token):
226
250
  """Switch the bulb on."""
227
- async with MyStromBulb(ip, mac) as bulb:
251
+ async with MyStromBulb(ip=ip, mac=mac, token=token) as bulb:
228
252
  await bulb.set_color_hex("00FFFFFF")
229
253
 
230
254
 
@@ -234,6 +258,7 @@ async def on(ip, mac):
234
258
  @click.option(
235
259
  "--mac", prompt="MAC address of the bulb", help="MAC address of the bulb."
236
260
  )
261
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
237
262
  @click.option(
238
263
  "--hue", prompt="Set the hue of the bulb", help="Set the hue of the bulb."
239
264
  )
@@ -247,9 +272,9 @@ async def on(ip, mac):
247
272
  prompt="Set the value of the bulb",
248
273
  help="Set the value of the bulb.",
249
274
  )
250
- async def color(ip, mac, hue, saturation, value):
275
+ async def color(ip, mac, token, hue, saturation, value):
251
276
  """Switch the bulb on with the given color."""
252
- async with MyStromBulb(ip, mac) as bulb:
277
+ async with MyStromBulb(ip=ip, mac=mac, token=token) as bulb:
253
278
  await bulb.set_color_hsv(hue, saturation, value)
254
279
 
255
280
 
@@ -259,9 +284,10 @@ async def color(ip, mac, hue, saturation, value):
259
284
  @click.option(
260
285
  "--mac", prompt="MAC address of the bulb", help="MAC address of the bulb."
261
286
  )
262
- async def off(ip, mac):
287
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
288
+ async def off(ip, mac, token):
263
289
  """Switch the bulb off."""
264
- async with MyStromBulb(ip, mac) as bulb:
290
+ async with MyStromBulb(ip=ip, mac=mac, token=token) as bulb:
265
291
  await bulb.set_off()
266
292
 
267
293
 
@@ -271,15 +297,16 @@ async def off(ip, mac):
271
297
  @click.option(
272
298
  "--mac", prompt="MAC address of the bulb", help="MAC address of the bulb."
273
299
  )
300
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
274
301
  @click.option(
275
302
  "--time",
276
303
  prompt="Time to flash",
277
304
  help="Time to flash the bulb in seconds.",
278
305
  default=10,
279
306
  )
280
- async def flash(ip, mac, time):
307
+ async def flash(ip, mac, token, time):
281
308
  """Flash the bulb off."""
282
- async with MyStromBulb(ip, mac) as bulb:
309
+ async with MyStromBulb(ip=ip, mac=mac, token=token) as bulb:
283
310
  await bulb.set_flashing(time, [100, 50, 30], [200, 0, 71])
284
311
 
285
312
 
@@ -289,15 +316,16 @@ async def flash(ip, mac, time):
289
316
  @click.option(
290
317
  "--mac", prompt="MAC address of the bulb", help="MAC address of the bulb."
291
318
  )
319
+ @click.option("--token", prompt="Token of the device", help="Token of the device.")
292
320
  @click.option(
293
321
  "--time",
294
322
  prompt="Time for the complete rainbow",
295
323
  help="Time to perform the rainbow in seconds.",
296
324
  default=30,
297
325
  )
298
- async def rainbow(ip, mac, time):
326
+ async def rainbow(ip, mac, token, time):
299
327
  """Let the buld change the color and show a rainbow."""
300
- async with MyStromBulb(ip, mac) as bulb:
328
+ async with MyStromBulb(ip=ip, mac=mac, token=token) as bulb:
301
329
  await bulb.set_rainbow(time)
302
330
  await bulb.set_transition_time(1000)
303
331
 
@@ -1,6 +1,6 @@
1
1
  """Support for communicating with myStrom PIRs."""
2
2
 
3
- from typing import Any, Dict, Iterable, List, Optional, Union
3
+ from typing import Optional
4
4
 
5
5
  import aiohttp
6
6
  from yarl import URL
@@ -13,10 +13,16 @@ URI_PIR = URL("api/v1/")
13
13
  class MyStromPir:
14
14
  """A class for a myStrom PIR."""
15
15
 
16
- def __init__(self, host: str, session: aiohttp.client.ClientSession = None) -> None:
16
+ def __init__(
17
+ self,
18
+ host: str,
19
+ session: aiohttp.client.ClientSession = None,
20
+ token: Optional[str] = None,
21
+ ) -> None:
17
22
  """Initialize the switch."""
18
23
  self._close_session = False
19
24
  self._host = host
25
+ self._token = token
20
26
  self._session = session
21
27
  self._intensity = None
22
28
  self._day = None
@@ -36,25 +42,25 @@ class MyStromPir:
36
42
  async def get_settings(self) -> None:
37
43
  """Get the current settings from the PIR."""
38
44
  url = URL(self.uri).join(URL("settings"))
39
- response = await request(self, uri=url)
45
+ response = await request(self, uri=url, token=self._token)
40
46
  self._settings = response
41
47
 
42
48
  async def get_actions(self) -> None:
43
49
  """Get the current action settings from the PIR."""
44
50
  url = URL(self.uri).join(URL("action"))
45
- response = await request(self, uri=url)
51
+ response = await request(self, uri=url, token=self._token)
46
52
  self._actions = response
47
53
 
48
54
  async def get_pir(self) -> None:
49
55
  """Get the current PIR settings."""
50
56
  url = URL(self.uri).join(URL("settings/pir"))
51
- response = await request(self, uri=url)
57
+ response = await request(self, uri=url, token=self._token)
52
58
  self._pir = response
53
59
 
54
60
  async def get_sensors_state(self) -> None:
55
61
  """Get the state of the sensors from the PIR."""
56
62
  url = URL(self.uri).join(URL("sensors"))
57
- response = await request(self, uri=url)
63
+ response = await request(self, uri=url, token=self._token)
58
64
  # The return data has the be re-written as the temperature is not rounded
59
65
  self._sensors = {
60
66
  "motion": response["motion"],
@@ -66,7 +72,7 @@ class MyStromPir:
66
72
  """Get the temperatures from the PIR."""
67
73
  # There is a different URL for the temp endpoint
68
74
  url = URL.build(scheme="http", host=self._host) / "temp"
69
- response = await request(self, uri=url)
75
+ response = await request(self, uri=url, token=self._token)
70
76
  self._temperature_raw = response
71
77
  self._temperature_measured = round(response["measured"], 2)
72
78
  self._temperature_compensated = round(response["compensated"], 2)
@@ -75,13 +81,13 @@ class MyStromPir:
75
81
  async def get_motion(self) -> None:
76
82
  """Get the state of the motion sensor from the PIR."""
77
83
  url = URL(self.uri).join(URL("motion"))
78
- response = await request(self, uri=url)
84
+ response = await request(self, uri=url, token=self._token)
79
85
  self._motion = response["motion"]
80
86
 
81
87
  async def get_light(self) -> None:
82
88
  """Get the state of the light sensor from the PIR."""
83
89
  url = URL(self.uri).join(URL("light"))
84
- response = await request(self, uri=url)
90
+ response = await request(self, uri=url, token=self._token)
85
91
  self._intensity = response["intensity"]
86
92
  self._day = response["day"]
87
93
  self._light_raw = response["raw"]
@@ -12,10 +12,16 @@ from .device_types import DEVICE_MAPPING_LITERAL, DEVICE_MAPPING_NUMERIC
12
12
  class MyStromSwitch:
13
13
  """A class for a myStrom switch/plug."""
14
14
 
15
- def __init__(self, host: str, session: aiohttp.client.ClientSession = None) -> None:
15
+ def __init__(
16
+ self,
17
+ host: str,
18
+ session: aiohttp.client.ClientSession = None,
19
+ token: Optional[str] = None,
20
+ ) -> None:
16
21
  """Initialize the switch."""
17
22
  self._close_session = False
18
23
  self._host = host
24
+ self._token = token
19
25
  self._session = session
20
26
  self._consumption = 0
21
27
  self._consumedWs = 0
@@ -33,26 +39,26 @@ class MyStromSwitch:
33
39
  """Turn the relay on."""
34
40
  parameters = {"state": "1"}
35
41
  url = URL(self.uri).join(URL("relay"))
36
- await request(self, uri=url, params=parameters)
42
+ await request(self, uri=url, params=parameters, token=self._token)
37
43
  await self.get_state()
38
44
 
39
45
  async def turn_off(self) -> None:
40
46
  """Turn the relay off."""
41
47
  parameters = {"state": "0"}
42
48
  url = URL(self.uri).join(URL("relay"))
43
- await request(self, uri=url, params=parameters)
49
+ await request(self, uri=url, params=parameters, token=self._token)
44
50
  await self.get_state()
45
51
 
46
52
  async def toggle(self) -> None:
47
53
  """Toggle the relay."""
48
54
  url = URL(self.uri).join(URL("toggle"))
49
- await request(self, uri=url)
55
+ await request(self, uri=url, token=self._token)
50
56
  await self.get_state()
51
57
 
52
58
  async def get_state(self) -> None:
53
59
  """Get the details from the switch/plug."""
54
60
  url = URL(self.uri).join(URL("report"))
55
- response = await request(self, uri=url)
61
+ response = await request(self, uri=url, token=self._token)
56
62
  try:
57
63
  self._consumption = response["power"]
58
64
  except KeyError:
@@ -81,11 +87,11 @@ class MyStromSwitch:
81
87
 
82
88
  # Try the new API (Devices with newer firmware)
83
89
  url = URL(self.uri).join(URL("api/v1/info"))
84
- response = await request(self, uri=url)
90
+ response = await request(self, uri=url, token=self._token)
85
91
  if not isinstance(response, dict):
86
92
  # Fall back to the old API version if the device runs with old firmware
87
93
  url = URL(self.uri).join(URL("info.json"))
88
- response = await request(self, uri=url)
94
+ response = await request(self, uri=url, token=self._token)
89
95
 
90
96
  # Tolerate missing keys on legacy firmware (e.g., v1 devices)
91
97
  self._firmware = response.get("version")
@@ -161,7 +167,7 @@ class MyStromSwitch:
161
167
  async def get_temperature_full(self) -> str:
162
168
  """Get current temperature in celsius."""
163
169
  url = URL(self.uri).join(URL("temp"))
164
- response = await request(self, uri=url)
170
+ response = await request(self, uri=url, token=self._token)
165
171
  return response
166
172
 
167
173
  async def close(self) -> None:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-mystrom"
3
- version = "2.5.0"
3
+ version = "2.7.0"
4
4
  description = "Asynchronous Python API client for interacting with myStrom devices"
5
5
  authors = ["Fabian Affolter <fabian@affolter-engineering.ch>"]
6
6
  license = "MIT"
@@ -13,11 +13,15 @@ packages = [
13
13
  ]
14
14
 
15
15
  [tool.poetry.dependencies]
16
- python = ">=3.11"
16
+ python = ">=3.13"
17
17
  aiohttp = "*"
18
18
  click = "*"
19
19
  requests = "*"
20
20
 
21
+ [tool.poetry.group.dev.dependencies]
22
+ pytest = "^9"
23
+ pytest-asyncio = "^0.21"
24
+
21
25
  [build-system]
22
26
  requires = ["poetry-core>=1.0.0"]
23
27
  build-backend = "poetry.core.masonry.api"