GeekMagicWeatherClockAPI 0.1.0__tar.gz → 0.1.1__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,254 @@
1
+ import requests
2
+ from pathlib import Path
3
+ from urllib.parse import quote
4
+
5
+
6
+ class SmallTV:
7
+ """
8
+ HTTP client for controlling a GeekMagic SmallTV device.
9
+
10
+ All public methods return a standardized response dictionary::
11
+
12
+ {
13
+ "success": bool,
14
+ "status_code": int | None,
15
+ "response": str | None,
16
+ "error": str | None,
17
+ }
18
+ """
19
+
20
+ THEMES: dict[int, str] = {
21
+ 1: "Weather Clock Today",
22
+ 2: "Weather Forecast",
23
+ 3: "Photo Album",
24
+ 4: "Time Style 1",
25
+ 5: "Time Style 2",
26
+ 6: "Time Style 3",
27
+ 7: "Simple Weather Clock",
28
+ }
29
+
30
+ _THEME_LOOKUP: dict[str, int] = {} # populated lazily in __init__
31
+
32
+ def __init__(self, ip: str) -> None:
33
+ self.ip = ip
34
+ self.base_url = f"http://{ip}"
35
+
36
+ self._THEME_LOOKUP = {v.lower(): k for k, v in self.THEMES.items()}
37
+
38
+ self.session = requests.Session()
39
+ self.session.headers.update({"X-Requested-With": "XMLHttpRequest"})
40
+
41
+ # ------------------------------------------------------------------
42
+ # Internal helpers
43
+ # ------------------------------------------------------------------
44
+
45
+ @staticmethod
46
+ def _build_result(
47
+ success: bool,
48
+ status_code: int | None = None,
49
+ response: str | None = None,
50
+ error: str | None = None,
51
+ ) -> dict:
52
+ return {
53
+ "success": success,
54
+ "status_code": status_code,
55
+ "response": response,
56
+ "error": error,
57
+ }
58
+
59
+ def _get(self, endpoint: str, **kwargs) -> dict:
60
+ """
61
+ Perform a GET request against *endpoint* (relative path + query string).
62
+
63
+ Extra *kwargs* are forwarded to ``session.get()``.
64
+ """
65
+ url = f"{self.base_url}{endpoint}"
66
+ kwargs.setdefault("timeout", 10)
67
+
68
+ try:
69
+ r = self.session.get(url, **kwargs)
70
+ return self._build_result(
71
+ success=r.ok,
72
+ status_code=r.status_code,
73
+ response=r.text,
74
+ )
75
+ except requests.RequestException as exc:
76
+ return self._build_result(success=False, error=str(exc))
77
+ except Exception as exc:
78
+ return self._build_result(success=False, error=f"Unexpected error: {exc}")
79
+
80
+ def _post(self, endpoint: str, **kwargs) -> dict:
81
+ """
82
+ Perform a POST request against *endpoint* (relative path + query string).
83
+
84
+ Extra *kwargs* are forwarded to ``session.post()``.
85
+ """
86
+ url = f"{self.base_url}{endpoint}"
87
+ kwargs.setdefault("timeout", 30)
88
+
89
+ try:
90
+ r = self.session.post(url, **kwargs)
91
+ return self._build_result(
92
+ success=r.ok,
93
+ status_code=r.status_code,
94
+ response=r.text,
95
+ )
96
+ except requests.exceptions.InvalidHeader as exc:
97
+ # SmallTV firmware occasionally returns malformed response headers.
98
+ return self._build_result(
99
+ success=False,
100
+ error=f"Malformed response headers from device: {exc}",
101
+ )
102
+ except requests.RequestException as exc:
103
+ return self._build_result(success=False, error=str(exc))
104
+ except Exception as exc:
105
+ return self._build_result(success=False, error=f"Unexpected error: {exc}")
106
+
107
+ # ------------------------------------------------------------------
108
+ # Public API
109
+ # ------------------------------------------------------------------
110
+
111
+ def upload(self, file_path, retries: int = 3) -> dict:
112
+ """
113
+ Upload a GIF/image to the SmallTV.
114
+
115
+ Retries up to *retries* times to work around malformed-header
116
+ responses emitted by some SmallTV firmware versions.
117
+
118
+ Returns a standardized result dict.
119
+ """
120
+ file_path = Path(file_path)
121
+
122
+ if not file_path.exists():
123
+ return self._build_result(
124
+ success=False,
125
+ error=f"File not found: {file_path}",
126
+ )
127
+
128
+ filename = file_path.name
129
+
130
+ try:
131
+ gif_data = file_path.read_bytes()
132
+ except OSError as exc:
133
+ return self._build_result(success=False, error=str(exc))
134
+
135
+ last_result: dict = {}
136
+
137
+ for attempt in range(1, retries + 1):
138
+ # Clean up any partial upload from a previous failed attempt.
139
+ if attempt > 1:
140
+ self.delete(filename)
141
+
142
+ files = {
143
+ "update": (filename, gif_data, "image/gif"),
144
+ "image": (filename, gif_data, "image/gif"),
145
+ }
146
+
147
+ result = self._post("/doUpload?dir=/image/", files=files)
148
+ last_result = result
149
+
150
+ if result["success"]:
151
+ return result
152
+
153
+ # Only retry on malformed-header errors (firmware quirk).
154
+ if result.get("error") and "malformed" not in result["error"].lower():
155
+ return result
156
+
157
+ # All attempts exhausted — clean up and surface the last error.
158
+ self.delete(filename)
159
+ last_result["error"] = (
160
+ f"Upload of '{filename}' failed after {retries} attempt(s). "
161
+ f"Last error: {last_result.get('error')}"
162
+ )
163
+ return last_result
164
+
165
+ def set_image(self, filename: str) -> dict:
166
+ """
167
+ Set the currently displayed image on the device.
168
+ """
169
+ encoded = quote(filename)
170
+ return self._get(f"/set?img=/image/{encoded}")
171
+
172
+ def set_theme(self, theme) -> dict:
173
+ """
174
+ Set the active SmallTV theme.
175
+
176
+ *theme* may be an integer (1–7) or one of the theme name strings:
177
+
178
+ * ``"Weather Clock Today"``
179
+ * ``"Weather Forecast"``
180
+ * ``"Photo Album"``
181
+ * ``"Time Style 1"``
182
+ * ``"Time Style 2"``
183
+ * ``"Time Style 3"``
184
+ * ``"Simple Weather Clock"``
185
+ """
186
+ if isinstance(theme, str):
187
+ theme_id = self._THEME_LOOKUP.get(theme.lower())
188
+ if theme_id is None:
189
+ return self._build_result(
190
+ success=False,
191
+ error=f"Unknown theme '{theme}'. Valid themes: {list(self.THEMES.values())}",
192
+ )
193
+ else:
194
+ try:
195
+ theme_id = int(theme)
196
+ except (TypeError, ValueError) as exc:
197
+ return self._build_result(success=False, error=str(exc))
198
+
199
+ if theme_id not in self.THEMES:
200
+ return self._build_result(
201
+ success=False,
202
+ error=f"Theme ID must be between 1 and 7, got {theme_id}.",
203
+ )
204
+
205
+ return self._get(f"/set?theme={theme_id}")
206
+
207
+ def set_brightness(self, value) -> dict:
208
+ """
209
+ Set the display brightness (0–100).
210
+ """
211
+ try:
212
+ value = int(value)
213
+ except (TypeError, ValueError) as exc:
214
+ return self._build_result(success=False, error=str(exc))
215
+
216
+ if not 0 <= value <= 100:
217
+ return self._build_result(
218
+ success=False,
219
+ error=f"Brightness must be between 0 and 100, got {value}.",
220
+ )
221
+
222
+ return self._get(f"/set?brt={value}")
223
+
224
+ def delete(self, filename: str) -> dict:
225
+ """
226
+ Delete an image from the device.
227
+ """
228
+ encoded = quote(filename)
229
+ return self._get(f"/delete?file=/image/{encoded}")
230
+
231
+ def upload_and_set(self, file_path) -> dict:
232
+ """
233
+ Upload a file and immediately display it.
234
+
235
+ Returns the result of ``set_image`` on success, or the failed
236
+ ``upload`` result if the upload did not succeed.
237
+ """
238
+ file_path = Path(file_path)
239
+
240
+ upload_result = self.upload(file_path)
241
+ if not upload_result["success"]:
242
+ return upload_result
243
+
244
+ return self.set_image(file_path.name)
245
+
246
+ def replace(self, old_filename: str, new_file) -> dict:
247
+ """
248
+ Delete *old_filename*, upload *new_file*, and display it.
249
+
250
+ The delete step is best-effort; failure does not abort the operation.
251
+ Returns the result of ``upload_and_set``.
252
+ """
253
+ self.delete(old_filename)
254
+ return self.upload_and_set(new_file)
@@ -0,0 +1,253 @@
1
+ Metadata-Version: 2.4
2
+ Name: GeekMagicWeatherClockAPI
3
+ Version: 0.1.1
4
+ Summary: Python wrapper for controlling the GeekMagic SmallTV
5
+ Project-URL: Homepage, https://github.com/eman225511/GeekMagicWeatherClockAPI
6
+ Requires-Python: >=3.7
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: requests
10
+ Dynamic: license-file
11
+
12
+ # GeekMagicWeatherClockAPI
13
+
14
+ A Python wrapper for controlling the **GeekMagic SmallTV** over your local network. Supports uploading images/GIFs, switching themes, adjusting brightness, and managing files on the device.
15
+
16
+ [PyPI](https://pypi.org/project/GeekMagicWeatherClockAPI/)
17
+
18
+ ---
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install GeekMagicWeatherClockAPI
24
+ ```
25
+
26
+ ## Requirements
27
+
28
+ - Python 3.10+
29
+ - [`requests`](https://pypi.org/project/requests/) (installed automatically)
30
+
31
+ ---
32
+
33
+ ## Setup
34
+
35
+ Find your SmallTV's local IP address (check your router's device list or the SmallTV's settings menu), then:
36
+
37
+ ```python
38
+ from GeekMagicWeatherClockAPI import SmallTV
39
+
40
+ tv = SmallTV("192.168.1.85")
41
+ ```
42
+
43
+ A persistent HTTP session is created automatically. All requests reuse this session, so there's no need to manage connections manually.
44
+
45
+ ---
46
+
47
+ ## Return values
48
+
49
+ Every method returns the same dictionary — no exceptions are raised for network or validation errors:
50
+
51
+ ```python
52
+ {
53
+ "success": bool, # True if the device responded successfully
54
+ "status_code": int | None, # HTTP status code, or None if the request never completed
55
+ "response": str | None, # Raw response body from the device
56
+ "error": str | None, # Human-readable error message on failure, else None
57
+ }
58
+ ```
59
+
60
+ ### Checking results
61
+
62
+ ```python
63
+ result = tv.set_brightness(50)
64
+
65
+ if result["success"]:
66
+ print("Done!")
67
+ else:
68
+ print("Something went wrong:", result["error"])
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Usage
74
+
75
+ ### Upload an image
76
+
77
+ ```python
78
+ tv.upload("my_animation.gif")
79
+
80
+ # Increase retries for flaky firmware responses
81
+ tv.upload("my_animation.gif", retries=5)
82
+ ```
83
+
84
+ ### Display an image
85
+
86
+ ```python
87
+ tv.set_image("my_animation.gif")
88
+ ```
89
+
90
+ ### Upload and immediately display
91
+
92
+ ```python
93
+ tv.upload_and_set("my_animation.gif")
94
+ ```
95
+
96
+ ### Replace an image
97
+
98
+ Deletes the old file, uploads the new one, and displays it — all in one call.
99
+
100
+ ```python
101
+ tv.replace("old_animation.gif", "new_animation.gif")
102
+ ```
103
+
104
+ ### Delete an image
105
+
106
+ ```python
107
+ tv.delete("my_animation.gif")
108
+ ```
109
+
110
+ ### Set brightness
111
+
112
+ Accepts a value from `0` to `100`.
113
+
114
+ ```python
115
+ tv.set_brightness(75)
116
+ ```
117
+
118
+ ### Set theme
119
+
120
+ Accepts either an integer (`1`–`7`) or the theme name as a string (case-insensitive).
121
+
122
+ ```python
123
+ tv.set_theme(3)
124
+ tv.set_theme("Photo Album")
125
+ ```
126
+
127
+ | ID | Theme Name |
128
+ |----|----------------------|
129
+ | 1 | Weather Clock Today |
130
+ | 2 | Weather Forecast |
131
+ | 3 | Photo Album |
132
+ | 4 | Time Style 1 |
133
+ | 5 | Time Style 2 |
134
+ | 6 | Time Style 3 |
135
+ | 7 | Simple Weather Clock |
136
+
137
+ ---
138
+
139
+ ## Examples
140
+
141
+ ### Basic setup and display
142
+
143
+ ```python
144
+ from GeekMagicWeatherClockAPI import SmallTV
145
+
146
+ tv = SmallTV("192.168.1.85")
147
+
148
+ tv.upload_and_set("spaceman.gif")
149
+ tv.set_brightness(30)
150
+ tv.set_theme("Weather Clock Today")
151
+ ```
152
+
153
+ ### Checking every result
154
+
155
+ ```python
156
+ tv = SmallTV("192.168.1.85")
157
+
158
+ for step, result in [
159
+ ("upload", tv.upload("clock.gif")),
160
+ ("display", tv.set_image("clock.gif")),
161
+ ("brightness", tv.set_brightness(60)),
162
+ ("theme", tv.set_theme(1)),
163
+ ]:
164
+ status = "OK" if result["success"] else f"FAILED — {result['error']}"
165
+ print(f"{step:12} {status}")
166
+ ```
167
+
168
+ ### Batch upload a folder of GIFs
169
+
170
+ ```python
171
+ from pathlib import Path
172
+
173
+ tv = SmallTV("192.168.1.85")
174
+
175
+ gifs = list(Path("./animations").glob("*.gif"))
176
+
177
+ for gif in gifs:
178
+ result = tv.upload(gif)
179
+ if result["success"]:
180
+ print(f"Uploaded: {gif.name}")
181
+ else:
182
+ print(f"Failed: {gif.name} — {result['error']}")
183
+ ```
184
+
185
+ ### Rotate through themes on a schedule
186
+
187
+ ```python
188
+ import time
189
+
190
+ tv = SmallTV("192.168.1.85")
191
+
192
+ themes = list(tv.THEMES.keys()) # [1, 2, 3, 4, 5, 6, 7]
193
+
194
+ for theme_id in themes:
195
+ result = tv.set_theme(theme_id)
196
+ if result["success"]:
197
+ print(f"Now showing: {tv.THEMES[theme_id]}")
198
+ time.sleep(10)
199
+ ```
200
+
201
+ ### Night mode — dim at a set hour
202
+
203
+ ```python
204
+ from datetime import datetime
205
+
206
+ tv = SmallTV("192.168.1.85")
207
+
208
+ hour = datetime.now().hour
209
+ brightness = 10 if 22 <= hour or hour < 7 else 80
210
+
211
+ result = tv.set_brightness(brightness)
212
+ print(f"Brightness set to {brightness}" if result["success"] else result["error"])
213
+ ```
214
+
215
+ ### Upload with retry and graceful fallback
216
+
217
+ ```python
218
+ tv = SmallTV("192.168.1.85")
219
+
220
+ result = tv.upload("hero.gif", retries=5)
221
+
222
+ if result["success"]:
223
+ tv.set_image("hero.gif")
224
+ else:
225
+ print("Upload failed after all retries:", result["error"])
226
+ # Fall back to a theme instead
227
+ tv.set_theme("Weather Clock Today")
228
+ ```
229
+
230
+ ### Swap a live image safely
231
+
232
+ ```python
233
+ tv = SmallTV("192.168.1.85")
234
+
235
+ # Removes "old.gif", uploads "new.gif", and displays it
236
+ result = tv.replace("old.gif", "new.gif")
237
+
238
+ if not result["success"]:
239
+ print("Replace failed:", result["error"])
240
+ ```
241
+
242
+ ---
243
+
244
+ ## Notes
245
+
246
+ - The SmallTV firmware occasionally returns malformed `Content-Length` headers on upload responses. The `upload` method automatically retries and cleans up partial uploads when this happens.
247
+ - All methods return a structured dict and never raise — check `result["success"]` instead of wrapping calls in `try/except`.
248
+ - File management methods operate on the `/image/` directory on the device.
249
+ - The `THEMES` dict is a public class attribute and can be read directly to enumerate valid theme names: `SmallTV.THEMES`.
250
+
251
+ ---
252
+
253
+ > **Dev note:** on release, bump the version in `pyproject.toml` and run `python -m build` then `twine upload dist/*`.