nuasense 0.1.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,6 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-16)
4
+
5
+ - `send_reading()`: one reading from any station you build (temperature, humidity, pressure, wind, rain total, solar or light), no API key, only the station token. Same upload and PASSKEY as the Node-RED, ioBroker and n8n packages.
6
+ - `Client`: `first_look()` (frost tonight, watering today, spray window, plausibility), `stations()`, `catalog()`, `dataset()` / `rows()` for the farm and market datasets, `analysis()` for the ready-made analyses.
7
+ - Standard library only; Python 3.9 or newer.
nuasense-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NuaSense <info@nuasense.com>
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,142 @@
1
+ Metadata-Version: 2.5
2
+ Name: nuasense
3
+ Version: 0.1.0
4
+ Summary: NuaSense for Python: frost, watering and spray advice from your weather station, sending readings from any station you build, and East African farm and market data.
5
+ Project-URL: Homepage, https://nuasense.com
6
+ Project-URL: Documentation, https://api.nuasense.com/index.md
7
+ Project-URL: Source, https://github.com/nuasense/nuasense-python
8
+ Project-URL: Issues, https://github.com/nuasense/nuasense-python/issues
9
+ Project-URL: Changelog, https://github.com/nuasense/nuasense-python/blob/main/CHANGELOG.md
10
+ Author-email: NuaSense <info@nuasense.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: agriculture,east-africa,ecowitt,frost-warning,irrigation,kenya,market-data,raspberry-pi,weather-station
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Topic :: Home Automation
21
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+
26
+ # nuasense
27
+
28
+ NuaSense for Python: what your weather station means for house, garden and farm. Frost tonight, how much to water today, the next calm and dry spray window, and a plausibility check of your sensor. Send readings from any station you build (a Raspberry Pi with a BME280, an ESP32, a rain gauge on a GPIO pin), and read East African farm and market data.
29
+
30
+ ```
31
+ pip install nuasense
32
+ ```
33
+
34
+ Standard library only, Python 3.9 or newer, so it runs on any Raspberry Pi.
35
+
36
+ ## Send readings from your own station
37
+
38
+ In the console at [platform.nuasense.com](https://platform.nuasense.com): **External stations → Add a station**, pick *Home Assistant / weewx / Meteobridge / Node-RED*, and copy the token after `/station/` on the last screen. Sending needs no API key. The first three stations on an account are free.
39
+
40
+ ```python
41
+ import nuasense
42
+
43
+ nuasense.send_reading(
44
+ "kw7n-4hpd-x2vt", # your station token
45
+ device_id="garden-pi", # any stable name for this device
46
+ temperature=21.4, # °C
47
+ humidity=63, # %
48
+ pressure=1013.2, # hPa
49
+ wind_speed=2.1, # m/s (wind_unit="kmh" for km/h)
50
+ wind_direction=270, # degrees
51
+ rain_total=12.2, # mm, a counter that only grows
52
+ solar=412, # W/m² (or lux=...)
53
+ )
54
+ ```
55
+
56
+ Every value is optional; send what your station measures.
57
+
58
+ ### Raspberry Pi with a BME280
59
+
60
+ ```python
61
+ import time
62
+
63
+ import board
64
+ from adafruit_bme280 import basic as adafruit_bme280 # pip install adafruit-circuitpython-bme280
65
+
66
+ import nuasense
67
+
68
+ TOKEN = "kw7n-4hpd-x2vt"
69
+ bme = adafruit_bme280.Adafruit_BME280_I2C(board.I2C())
70
+
71
+ while True:
72
+ nuasense.send_reading(
73
+ TOKEN,
74
+ device_id="garden-pi",
75
+ temperature=bme.temperature,
76
+ humidity=bme.relative_humidity,
77
+ pressure=bme.pressure,
78
+ )
79
+ time.sleep(300)
80
+ ```
81
+
82
+ ## What your station means: frost, watering, spray window
83
+
84
+ Reading needs an API key: in the console under **API Keys**, verify your phone (a code by SMS or WhatsApp), then create a key. It is shown once. Keep it in an environment variable, not in your code.
85
+
86
+ ```python
87
+ import nuasense
88
+
89
+ ns = nuasense.Client() # reads NUASENSE_API_KEY
90
+
91
+ for station in ns.stations()["stations"]:
92
+ print(station["station_id"], station["name"], station["last_seen_at"])
93
+
94
+ look = ns.first_look("garden-west") # a station_id from stations() or the console
95
+ print(look["tonight"]["frost"], look["tonight"]["low_c"]) # e.g. "none", 7.8
96
+ print(look["garden"]["watering"]["need_mm"]["lawn"]) # mm to water today
97
+ ```
98
+
99
+ The watering need already subtracts the rain of the last 24 hours and the rain forecast for the next 24 hours, so on a day with rain coming it is 0.
100
+
101
+ ## Farm and market data
102
+
103
+ ```python
104
+ ns = nuasense.Client()
105
+
106
+ ns.catalog() # every dataset, its fields and filters your key reaches
107
+
108
+ prices = ns.rows("/market/prices", start="-14d", county="nakuru", commodity="maize")
109
+
110
+ import pandas as pd # optional
111
+ df = pd.DataFrame(prices)
112
+
113
+ ns.analysis("/fertilizer/rank", county="nakuru") # where fertilizer is cheapest
114
+ ```
115
+
116
+ `dataset()` returns the whole answer (rows plus what the call cost); `rows()` returns only the rows. Options: `start`, `end`, `limit`, `every` with `agg`, `fields`, `oldest_first`; filters as keyword arguments.
117
+
118
+ Market data needs no station. The datasets cover Kenya, Tanzania, Uganda and Rwanda: county crop and input prices, satellite demand, food security, rainfall against normal.
119
+
120
+ ## Errors
121
+
122
+ Every refusal raises `nuasense.NuaSenseError` with `.status`, `.body` and, on 429, `.retry_after`. The message says what to do: 401 a key, 402 credit, 403 scope or a station that is not yours, 429 wait.
123
+
124
+ ## Your data
125
+
126
+ When you connect a station, NuaSense stores its readings and computes your forecast, spray windows and risks from them. The readings are used aggregated and anonymised to improve forecasts and models for everyone; they are never passed on individually without your opt-in. You can disconnect the station in the console at any time and have all its data deleted.
127
+
128
+ ## Links
129
+
130
+ - Docs for developers and agents: https://api.nuasense.com/index.md
131
+ - OpenAPI: https://api.nuasense.com/openapi.json
132
+ - Console: https://platform.nuasense.com
133
+ - Other packages: [Home Assistant](https://github.com/nuasense/homeassistant-nuasense), [Node-RED](https://github.com/nuasense/node-red-contrib-nuasense), [weewx](https://github.com/nuasense/weewx-nuasense), [n8n](https://github.com/nuasense/n8n-nodes-nuasense)
134
+ - Issues: https://github.com/nuasense/nuasense-python/issues · info@nuasense.com
135
+
136
+ ## Development
137
+
138
+ `python -m unittest discover -s tests` (with `PYTHONPATH=src`). Releases: bump `src/nuasense/_version.py` and `CHANGELOG.md`, push a tag `vX.Y.Z`; GitHub Actions tests, builds and publishes to PyPI by Trusted Publishing.
139
+
140
+ ## License
141
+
142
+ MIT
@@ -0,0 +1,117 @@
1
+ # nuasense
2
+
3
+ NuaSense for Python: what your weather station means for house, garden and farm. Frost tonight, how much to water today, the next calm and dry spray window, and a plausibility check of your sensor. Send readings from any station you build (a Raspberry Pi with a BME280, an ESP32, a rain gauge on a GPIO pin), and read East African farm and market data.
4
+
5
+ ```
6
+ pip install nuasense
7
+ ```
8
+
9
+ Standard library only, Python 3.9 or newer, so it runs on any Raspberry Pi.
10
+
11
+ ## Send readings from your own station
12
+
13
+ In the console at [platform.nuasense.com](https://platform.nuasense.com): **External stations → Add a station**, pick *Home Assistant / weewx / Meteobridge / Node-RED*, and copy the token after `/station/` on the last screen. Sending needs no API key. The first three stations on an account are free.
14
+
15
+ ```python
16
+ import nuasense
17
+
18
+ nuasense.send_reading(
19
+ "kw7n-4hpd-x2vt", # your station token
20
+ device_id="garden-pi", # any stable name for this device
21
+ temperature=21.4, # °C
22
+ humidity=63, # %
23
+ pressure=1013.2, # hPa
24
+ wind_speed=2.1, # m/s (wind_unit="kmh" for km/h)
25
+ wind_direction=270, # degrees
26
+ rain_total=12.2, # mm, a counter that only grows
27
+ solar=412, # W/m² (or lux=...)
28
+ )
29
+ ```
30
+
31
+ Every value is optional; send what your station measures.
32
+
33
+ ### Raspberry Pi with a BME280
34
+
35
+ ```python
36
+ import time
37
+
38
+ import board
39
+ from adafruit_bme280 import basic as adafruit_bme280 # pip install adafruit-circuitpython-bme280
40
+
41
+ import nuasense
42
+
43
+ TOKEN = "kw7n-4hpd-x2vt"
44
+ bme = adafruit_bme280.Adafruit_BME280_I2C(board.I2C())
45
+
46
+ while True:
47
+ nuasense.send_reading(
48
+ TOKEN,
49
+ device_id="garden-pi",
50
+ temperature=bme.temperature,
51
+ humidity=bme.relative_humidity,
52
+ pressure=bme.pressure,
53
+ )
54
+ time.sleep(300)
55
+ ```
56
+
57
+ ## What your station means: frost, watering, spray window
58
+
59
+ Reading needs an API key: in the console under **API Keys**, verify your phone (a code by SMS or WhatsApp), then create a key. It is shown once. Keep it in an environment variable, not in your code.
60
+
61
+ ```python
62
+ import nuasense
63
+
64
+ ns = nuasense.Client() # reads NUASENSE_API_KEY
65
+
66
+ for station in ns.stations()["stations"]:
67
+ print(station["station_id"], station["name"], station["last_seen_at"])
68
+
69
+ look = ns.first_look("garden-west") # a station_id from stations() or the console
70
+ print(look["tonight"]["frost"], look["tonight"]["low_c"]) # e.g. "none", 7.8
71
+ print(look["garden"]["watering"]["need_mm"]["lawn"]) # mm to water today
72
+ ```
73
+
74
+ The watering need already subtracts the rain of the last 24 hours and the rain forecast for the next 24 hours, so on a day with rain coming it is 0.
75
+
76
+ ## Farm and market data
77
+
78
+ ```python
79
+ ns = nuasense.Client()
80
+
81
+ ns.catalog() # every dataset, its fields and filters your key reaches
82
+
83
+ prices = ns.rows("/market/prices", start="-14d", county="nakuru", commodity="maize")
84
+
85
+ import pandas as pd # optional
86
+ df = pd.DataFrame(prices)
87
+
88
+ ns.analysis("/fertilizer/rank", county="nakuru") # where fertilizer is cheapest
89
+ ```
90
+
91
+ `dataset()` returns the whole answer (rows plus what the call cost); `rows()` returns only the rows. Options: `start`, `end`, `limit`, `every` with `agg`, `fields`, `oldest_first`; filters as keyword arguments.
92
+
93
+ Market data needs no station. The datasets cover Kenya, Tanzania, Uganda and Rwanda: county crop and input prices, satellite demand, food security, rainfall against normal.
94
+
95
+ ## Errors
96
+
97
+ Every refusal raises `nuasense.NuaSenseError` with `.status`, `.body` and, on 429, `.retry_after`. The message says what to do: 401 a key, 402 credit, 403 scope or a station that is not yours, 429 wait.
98
+
99
+ ## Your data
100
+
101
+ When you connect a station, NuaSense stores its readings and computes your forecast, spray windows and risks from them. The readings are used aggregated and anonymised to improve forecasts and models for everyone; they are never passed on individually without your opt-in. You can disconnect the station in the console at any time and have all its data deleted.
102
+
103
+ ## Links
104
+
105
+ - Docs for developers and agents: https://api.nuasense.com/index.md
106
+ - OpenAPI: https://api.nuasense.com/openapi.json
107
+ - Console: https://platform.nuasense.com
108
+ - Other packages: [Home Assistant](https://github.com/nuasense/homeassistant-nuasense), [Node-RED](https://github.com/nuasense/node-red-contrib-nuasense), [weewx](https://github.com/nuasense/weewx-nuasense), [n8n](https://github.com/nuasense/n8n-nodes-nuasense)
109
+ - Issues: https://github.com/nuasense/nuasense-python/issues · info@nuasense.com
110
+
111
+ ## Development
112
+
113
+ `python -m unittest discover -s tests` (with `PYTHONPATH=src`). Releases: bump `src/nuasense/_version.py` and `CHANGELOG.md`, push a tag `vX.Y.Z`; GitHub Actions tests, builds and publishes to PyPI by Trusted Publishing.
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.33.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nuasense"
7
+ dynamic = ["version"]
8
+ description = "NuaSense for Python: frost, watering and spray advice from your weather station, sending readings from any station you build, and East African farm and market data."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "NuaSense", email = "info@nuasense.com" }]
14
+ keywords = [
15
+ "weather-station",
16
+ "raspberry-pi",
17
+ "frost-warning",
18
+ "irrigation",
19
+ "agriculture",
20
+ "ecowitt",
21
+ "east-africa",
22
+ "kenya",
23
+ "market-data",
24
+ ]
25
+ classifiers = [
26
+ "Development Status :: 4 - Beta",
27
+ "Intended Audience :: Developers",
28
+ "Intended Audience :: Science/Research",
29
+ "Operating System :: OS Independent",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3 :: Only",
32
+ "Topic :: Scientific/Engineering :: Atmospheric Science",
33
+ "Topic :: Home Automation",
34
+ "Typing :: Typed",
35
+ ]
36
+ dependencies = []
37
+
38
+ [project.urls]
39
+ Homepage = "https://nuasense.com"
40
+ Documentation = "https://api.nuasense.com/index.md"
41
+ Source = "https://github.com/nuasense/nuasense-python"
42
+ Issues = "https://github.com/nuasense/nuasense-python/issues"
43
+ Changelog = "https://github.com/nuasense/nuasense-python/blob/main/CHANGELOG.md"
44
+
45
+ [tool.hatch.version]
46
+ path = "src/nuasense/_version.py"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/nuasense"]
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ include = ["src/nuasense", "tests", "README.md", "CHANGELOG.md", "LICENSE"]
@@ -0,0 +1,18 @@
1
+ """NuaSense for Python: what your weather station means for house, garden and
2
+ farm, a way to send any station you build to NuaSense, and East African farm
3
+ and market data.
4
+
5
+ import nuasense
6
+
7
+ # Send a reading from your own station (no key, only the station token)
8
+ nuasense.send_reading("kw7n-4hpd-x2vt", temperature=21.4, humidity=63, rain_total=12.2)
9
+
10
+ # Read with an API key (or set NUASENSE_API_KEY)
11
+ ns = nuasense.Client()
12
+ ns.first_look("my-station-id")
13
+ """
14
+
15
+ from ._version import __version__
16
+ from .client import Client, NuaSenseError, send_reading
17
+
18
+ __all__ = ["Client", "NuaSenseError", "send_reading", "__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,243 @@
1
+ """The NuaSense API from Python: standard library only, so it runs on any Raspberry Pi."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any, Dict, Optional, Union
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.parse import urlencode, urlparse
10
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
11
+
12
+ from . import ecowitt
13
+ from ._version import __version__
14
+ from .ecowitt import Number, When
15
+
16
+ API = "https://api.nuasense.com"
17
+ API_HOST = "api.nuasense.com"
18
+ DATA = API + "/api/nuasense/v1"
19
+ ANALYSIS = API + "/api/partner/v1/market"
20
+ KEY_ENV = "NUASENSE_API_KEY"
21
+ TIMEOUT = 30
22
+
23
+ # Tells NuaSense the call came from this package. Changes nothing in price or limits.
24
+ CLIENT = "python/" + __version__
25
+ USER_AGENT = "nuasense-python/" + __version__
26
+
27
+ _HINTS = {
28
+ 401: "Missing or invalid key. Create one at https://platform.nuasense.com under API Keys "
29
+ "and pass it as Client(api_key=...) or set NUASENSE_API_KEY.",
30
+ 402: "This needs credit: history beyond the free 30 days, or prepaid credit spent.",
31
+ 403: "The key lacks the scope, or the station is not yours or not approved for API exposure.",
32
+ 429: "Rate or unit budget reached. Wait for Retry-After or ask with a smaller limit.",
33
+ 451: "This dataset is withheld on licence grounds; no key unlocks it.",
34
+ }
35
+
36
+
37
+ class NuaSenseError(Exception):
38
+ """An answer NuaSense refused, with the HTTP status and the body it sent."""
39
+
40
+ def __init__(self, message: str, status: Optional[int] = None, body: Any = None,
41
+ retry_after: Optional[str] = None) -> None:
42
+ super().__init__(message)
43
+ self.status = status
44
+ self.body = body
45
+ self.retry_after = retry_after
46
+
47
+
48
+ def _body_message(body: Any) -> str:
49
+ if isinstance(body, dict):
50
+ for key in ("error", "message", "detail"):
51
+ value = body.get(key)
52
+ if isinstance(value, str) and value:
53
+ return value
54
+ if isinstance(value, dict) and isinstance(value.get("message"), str):
55
+ return value["message"]
56
+ if isinstance(body, str):
57
+ return body.strip()[:200]
58
+ return ""
59
+
60
+
61
+ class _NoRedirect(HTTPRedirectHandler):
62
+ """Never follow a redirect: urllib would carry the Authorization header to
63
+ whatever host the redirect names, and could step down from https. A 3xx
64
+ comes back as an answer instead."""
65
+
66
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
67
+ return None
68
+
69
+
70
+ _opener = build_opener(_NoRedirect)
71
+
72
+
73
+ def urlopen(req: Request, timeout: float = TIMEOUT):
74
+ return _opener.open(req, timeout=timeout)
75
+
76
+
77
+ def _check_url(url: str) -> None:
78
+ parts = urlparse(url)
79
+ if parts.scheme != "https" or parts.hostname != API_HOST:
80
+ raise ValueError(f"nuasense only talks to {API}; refusing {parts.scheme}://{parts.hostname}")
81
+
82
+
83
+ def _http(method: str, url: str, headers: Dict[str, str], data: Optional[bytes] = None,
84
+ timeout: float = TIMEOUT):
85
+ """(status, text, headers) for any answer, also 3xx/4xx/5xx. Only https://api.nuasense.com."""
86
+ _check_url(url)
87
+ req = Request(url, data=data, method=method, headers=headers)
88
+ try:
89
+ with urlopen(req, timeout=timeout) as res:
90
+ return res.status, res.read().decode("utf-8", "replace"), res.headers
91
+ except HTTPError as err:
92
+ try:
93
+ text = err.read().decode("utf-8", "replace") if err.fp is not None else ""
94
+ finally:
95
+ err.close()
96
+ return err.code, text, err.headers
97
+ except URLError as err:
98
+ raise NuaSenseError(f"Could not reach NuaSense: {err.reason}") from err
99
+
100
+
101
+ def send_reading(
102
+ token: str,
103
+ *,
104
+ device_id: str = "python",
105
+ temperature: Optional[Number] = None,
106
+ humidity: Optional[Number] = None,
107
+ pressure: Optional[Number] = None,
108
+ wind_speed: Optional[Number] = None,
109
+ wind_unit: str = "ms",
110
+ wind_gusts: Optional[Number] = None,
111
+ wind_direction: Optional[Number] = None,
112
+ rain_total: Optional[Number] = None,
113
+ solar: Optional[Number] = None,
114
+ lux: Optional[Number] = None,
115
+ time: When = None,
116
+ timeout: float = TIMEOUT,
117
+ ) -> Dict[str, Any]:
118
+ """Send one reading from your own station. Needs no API key, only the station token.
119
+
120
+ Units: temperature °C, humidity %, pressure hPa, wind m/s (or km/h with
121
+ wind_unit="kmh"), wind_direction degrees, rain_total mm as a counter that
122
+ only grows, solar W/m² (or lux). The token is the part after /station/ on
123
+ the last screen of "Add a station" in the console.
124
+ """
125
+ url = ecowitt.upload_url(token)
126
+ fields = ecowitt.to_ecowitt_fields(
127
+ device_id=device_id, station_type=CLIENT.replace("/", "_"),
128
+ temperature=temperature, humidity=humidity, pressure=pressure,
129
+ wind_speed=wind_speed, wind_unit=wind_unit, wind_gusts=wind_gusts,
130
+ wind_direction=wind_direction, rain_total=rain_total, solar=solar, lux=lux, time=time,
131
+ )
132
+ if not ecowitt.has_values(fields):
133
+ raise ValueError("Add at least one value to the reading.")
134
+ status, text, _ = _http(
135
+ "POST", url,
136
+ {"Content-Type": "application/x-www-form-urlencoded", "X-NuaSense-Client": CLIENT,
137
+ "User-Agent": USER_AGENT},
138
+ ecowitt.encode_form(fields).encode("ascii"), timeout,
139
+ )
140
+ if status == 404:
141
+ raise NuaSenseError(
142
+ "NuaSense does not know this station token. Copy it again from the console, "
143
+ "or add the station first.", status, text)
144
+ if status >= 300:
145
+ raise NuaSenseError(f"NuaSense refused the reading ({status}): {text.strip()[:200]}", status, text)
146
+ return {"sent": True, "status": status, "answer": text.strip(), "fields": fields}
147
+
148
+
149
+ Params = Dict[str, Union[str, int, float, bool, None]]
150
+
151
+
152
+ class Client:
153
+ """Reads from NuaSense with an API key.
154
+
155
+ >>> import nuasense
156
+ >>> ns = nuasense.Client() # key from NUASENSE_API_KEY
157
+ >>> ns.first_look("my-station-id") # frost tonight, watering today, spray window
158
+ >>> ns.dataset("/market/prices", start="-14d", county="nakuru", commodity="maize")
159
+ """
160
+
161
+ def __init__(self, api_key: Optional[str] = None, *, timeout: float = TIMEOUT,
162
+ client_name: Optional[str] = None) -> None:
163
+ self.api_key = (api_key or os.environ.get(KEY_ENV) or "").strip() or None
164
+ self.timeout = timeout
165
+ # Optional: name your tool or agent, e.g. "garden-pi"; shown in your usage, nothing else.
166
+ self.client_header = CLIENT + (f"; app={client_name}" if client_name else "")
167
+
168
+ def __repr__(self) -> str:
169
+ return f"Client(api_key={'set' if self.api_key else 'missing'})"
170
+
171
+ def get(self, url: str, params: Optional[Params] = None) -> Any:
172
+ """GET a URL under https://api.nuasense.com with the key; returns the parsed JSON.
173
+ Any other host is refused before the key leaves the machine."""
174
+ _check_url(url)
175
+ query = {k: ("true" if v is True else "false" if v is False else v)
176
+ for k, v in (params or {}).items() if v is not None and v != ""}
177
+ if query:
178
+ url += ("&" if "?" in url else "?") + urlencode(query)
179
+ headers = {"Accept": "application/json", "X-NuaSense-Client": self.client_header,
180
+ "User-Agent": USER_AGENT}
181
+ if self.api_key:
182
+ headers["Authorization"] = "Bearer " + self.api_key
183
+ status, text, res_headers = _http("GET", url, headers, None, self.timeout)
184
+ try:
185
+ body: Any = json.loads(text) if text else None
186
+ except ValueError:
187
+ body = text
188
+ if status >= 300:
189
+ said = _body_message(body)
190
+ hint = _HINTS.get(status, "")
191
+ message = f"NuaSense answered {status}" + (f": {said}" if said else "") + (f". {hint}" if hint else "")
192
+ retry = res_headers.get("Retry-After") if res_headers is not None else None
193
+ raise NuaSenseError(message, status, body, retry)
194
+ return body
195
+
196
+ # --- your stations -------------------------------------------------------
197
+
198
+ def stations(self) -> Any:
199
+ """The external stations on your account, with when each last sent."""
200
+ return self.get(DATA + "/ingest/stations")
201
+
202
+ def first_look(self, station_id: str) -> Any:
203
+ """Frost tonight, watering today (mm for lawn, vegetables, beds), the next
204
+ spray window and a plausibility check of the latest reading."""
205
+ return self.get(DATA + "/ingest/first-look", {"station_id": station_id})
206
+
207
+ # --- datasets and analyses ----------------------------------------------
208
+
209
+ def catalog(self) -> Any:
210
+ """The datasets, fields and filters your key reaches."""
211
+ return self.get(DATA + "/catalog")
212
+
213
+ def dataset(self, path: str, *, start: str = "-24h", limit: int = 50, end: Optional[str] = None,
214
+ every: Optional[str] = None, agg: Optional[str] = None, fields: Optional[str] = None,
215
+ oldest_first: bool = False, **filters: Union[str, int, float, None]) -> Any:
216
+ """Rows from one dataset, e.g. dataset("/market/prices", county="nakuru").
217
+
218
+ `path` is a catalog path such as "/market/prices" or "/farm/weather".
219
+ Filters go as keyword arguments. Returns the API's answer; the rows are
220
+ under "rows" (pandas: pd.DataFrame(answer["rows"])).
221
+ """
222
+ params: Params = {"start": start, "limit": limit, "end": end, "every": every,
223
+ "agg": agg or ("mean" if every else None), "fields": fields,
224
+ "order": "asc" if oldest_first else None}
225
+ params.update(filters)
226
+ return self.get(DATA + _path(path), params)
227
+
228
+ def rows(self, path: str, **kwargs: Any) -> list:
229
+ """Only the rows of dataset(...), as a list of dicts."""
230
+ answer = self.dataset(path, **kwargs)
231
+ rows = answer.get("rows") if isinstance(answer, dict) else None
232
+ return rows if isinstance(rows, list) else []
233
+
234
+ def analysis(self, path: str, **params: Union[str, int, float, None]) -> Any:
235
+ """One of the ready-made analyses, e.g. analysis("/fertilizer/rank", county="nakuru")."""
236
+ return self.get(ANALYSIS + _path(path), params)
237
+
238
+
239
+ def _path(path: str) -> str:
240
+ p = str(path or "").strip()
241
+ if not p:
242
+ raise ValueError('Give a catalog path such as "/market/prices".')
243
+ return p if p.startswith("/") else "/" + p
@@ -0,0 +1,176 @@
1
+ """Metric readings in, an Ecowitt-format upload out.
2
+
3
+ NuaSense's station receiver speaks what consumer consoles speak, and the
4
+ Ecowitt format is the richest of them. So this module builds the same form a
5
+ GW2000 console would send: imperial units, rain as a running counter, and a
6
+ PASSKEY that names the device so the receiver can bind the station's path to
7
+ it. Same numbers as the Node-RED, ioBroker and n8n packages, so one device
8
+ keeps one PASSKEY whichever package sends for it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import re
15
+ from datetime import datetime, timezone
16
+ from typing import Dict, Optional, Union
17
+ from urllib.parse import quote
18
+
19
+ INHG_PER_HPA = 1 / 33.8639
20
+ MPH_PER_MS = 1 / 0.44704
21
+ IN_PER_MM = 1 / 25.4
22
+ # Daylight: 120 lux per W/m² of global radiation, the receiver's own factor
23
+ # used the other way, so a script that only has lux lands on the same number.
24
+ LUX_PER_WM2 = 120
25
+
26
+ UPLOAD_BASE = "https://api.nuasense.com/station/"
27
+ _TOKEN_RE = re.compile(r"^[a-z0-9-]{12,29}$")
28
+ _META = ("PASSKEY", "stationtype", "dateutc")
29
+
30
+ Number = Union[int, float]
31
+ When = Union[str, datetime, None]
32
+
33
+
34
+ def _num(value: object) -> Optional[float]:
35
+ if value is None or value == "" or isinstance(value, bool):
36
+ return None
37
+ try:
38
+ n = float(value) # type: ignore[arg-type]
39
+ except (TypeError, ValueError):
40
+ return None
41
+ return n if math.isfinite(n) else None
42
+
43
+
44
+ def _fix(n: float, digits: int) -> str:
45
+ """Rounded to `digits`, trailing zeros dropped: 29.920 -> "29.92", 500.00 -> "500"."""
46
+ r = float(f"{n:.{digits}f}")
47
+ if r == 0:
48
+ return "0"
49
+ if r.is_integer():
50
+ return str(int(r))
51
+ return repr(r)
52
+
53
+
54
+ def _round(n: float) -> int:
55
+ """JavaScript's Math.round: halves go up, not to even."""
56
+ return int(math.floor(n + 0.5))
57
+
58
+
59
+ def date_utc(when: When = None) -> str:
60
+ """Ecowitt's `dateutc`: "YYYY-MM-DD HH:MM:SS" in UTC.
61
+
62
+ Accepts a datetime or an ISO 8601 string; a time without an offset is
63
+ local time. Anything unreadable becomes now.
64
+ """
65
+ t: Optional[datetime] = None
66
+ if isinstance(when, datetime):
67
+ t = when
68
+ elif isinstance(when, str) and when.strip():
69
+ text = when.strip()
70
+ if text.endswith(("Z", "z")):
71
+ text = text[:-1] + "+00:00"
72
+ try:
73
+ t = datetime.fromisoformat(text)
74
+ except ValueError:
75
+ t = None
76
+ if t is None:
77
+ t = datetime.now(timezone.utc)
78
+ return t.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
79
+
80
+
81
+ def passkey_for(device_id: str) -> str:
82
+ """A stable 32-hex PASSKEY from the device name.
83
+
84
+ Four FNV-1a passes with different offsets over the name's UTF-16 code
85
+ units, exactly like the JavaScript packages. Not a secret and not a
86
+ security measure; it only has to stay the same for the same device,
87
+ because the receiver binds a station's path to the first device that
88
+ uses it.
89
+ """
90
+ text = str(device_id or "python")
91
+ raw = text.encode("utf-16-le")
92
+ units = [raw[i] | (raw[i + 1] << 8) for i in range(0, len(raw), 2)]
93
+ out = ""
94
+ for seed in (0x811C9DC5, 0x01000193, 0x6C62272E, 0x9E3779B9):
95
+ h = seed
96
+ for unit in units:
97
+ h ^= unit
98
+ h = (h * 0x01000193) & 0xFFFFFFFF
99
+ out += f"{h:08x}"
100
+ return out.upper()
101
+
102
+
103
+ def to_ecowitt_fields(
104
+ *,
105
+ device_id: str,
106
+ station_type: str,
107
+ temperature: Optional[Number] = None,
108
+ humidity: Optional[Number] = None,
109
+ pressure: Optional[Number] = None,
110
+ wind_speed: Optional[Number] = None,
111
+ wind_unit: str = "ms",
112
+ wind_gusts: Optional[Number] = None,
113
+ wind_direction: Optional[Number] = None,
114
+ rain_total: Optional[Number] = None,
115
+ solar: Optional[Number] = None,
116
+ lux: Optional[Number] = None,
117
+ time: When = None,
118
+ ) -> Dict[str, str]:
119
+ """The form fields, as strings, in the order a console sends them.
120
+
121
+ Units in: °C, %, hPa, m/s (or km/h with wind_unit="kmh"), degrees,
122
+ mm as a counter that only grows, W/m² (or lux).
123
+ """
124
+ fields: Dict[str, str] = {
125
+ "PASSKEY": passkey_for(device_id),
126
+ "stationtype": station_type,
127
+ "dateutc": date_utc(time),
128
+ }
129
+ t = _num(temperature)
130
+ if t is not None:
131
+ fields["tempf"] = _fix(t * 9 / 5 + 32, 2)
132
+ h = _num(humidity)
133
+ if h is not None:
134
+ fields["humidity"] = str(_round(h))
135
+ p = _num(pressure)
136
+ if p is not None:
137
+ fields["baromabsin"] = _fix(p * INHG_PER_HPA, 3)
138
+ to_ms = 1 / 3.6 if wind_unit == "kmh" else 1
139
+ ws = _num(wind_speed)
140
+ if ws is not None:
141
+ fields["windspeedmph"] = _fix(ws * to_ms * MPH_PER_MS, 2)
142
+ wg = _num(wind_gusts)
143
+ if wg is not None:
144
+ fields["windgustmph"] = _fix(wg * to_ms * MPH_PER_MS, 2)
145
+ wd = _num(wind_direction)
146
+ if wd is not None:
147
+ fields["winddir"] = str(_round(math.fmod(math.fmod(wd, 360) + 360, 360)))
148
+ rain = _num(rain_total)
149
+ if rain is not None:
150
+ fields["yearlyrainin"] = _fix(rain * IN_PER_MM, 3)
151
+ sol = _num(solar)
152
+ lx = _num(lux)
153
+ if sol is not None:
154
+ fields["solarradiation"] = _fix(sol, 2)
155
+ elif lx is not None:
156
+ fields["solarradiation"] = _fix(lx / LUX_PER_WM2, 2)
157
+ return fields
158
+
159
+
160
+ def encode_form(fields: Dict[str, str]) -> str:
161
+ return "&".join(f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in fields.items())
162
+
163
+
164
+ def upload_url(token: str) -> str:
165
+ """The upload URL for a token as the console shows it, e.g. kw7n-4hpd-x2vt."""
166
+ tok = str(token or "").strip().lower()
167
+ if not _TOKEN_RE.match(tok):
168
+ raise ValueError(
169
+ 'The station token looks wrong. Copy it from the last screen of "Add a station" in the console.'
170
+ )
171
+ return UPLOAD_BASE + tok
172
+
173
+
174
+ def has_values(fields: Dict[str, str]) -> bool:
175
+ """True when at least one measured value is present."""
176
+ return any(k not in _META for k in fields)
File without changes
@@ -0,0 +1,150 @@
1
+ import io
2
+ import json
3
+ import os
4
+ import unittest
5
+ from unittest import mock
6
+ from urllib.error import HTTPError, URLError
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ import nuasense
10
+ from nuasense import client as mod
11
+
12
+
13
+ class FakeResponse(io.BytesIO):
14
+ def __init__(self, body, status=200, headers=None):
15
+ super().__init__(body.encode("utf-8"))
16
+ self.status = status
17
+ self.headers = headers or {}
18
+
19
+ def __enter__(self):
20
+ return self
21
+
22
+ def __exit__(self, *exc):
23
+ self.close()
24
+
25
+
26
+ def answering(*replies):
27
+ """Patch urlopen; each reply is (status, body) or an exception. Records the requests."""
28
+ calls = []
29
+ queue = list(replies)
30
+
31
+ def fake(req, timeout=None):
32
+ calls.append(req)
33
+ reply = queue.pop(0)
34
+ if isinstance(reply, Exception):
35
+ raise reply
36
+ status, body = reply
37
+ text = body if isinstance(body, str) else json.dumps(body)
38
+ if status >= 400:
39
+ raise HTTPError(req.full_url, status, "error", {"Retry-After": "30"}, io.BytesIO(text.encode()))
40
+ return FakeResponse(text, status)
41
+
42
+ return mock.patch.object(mod, "urlopen", side_effect=fake), calls
43
+
44
+
45
+ def query(req):
46
+ return {k: v[0] for k, v in parse_qs(urlparse(req.full_url).query).items()}
47
+
48
+
49
+ class SendReadingTest(unittest.TestCase):
50
+ def test_posts_the_ecowitt_form_without_a_key(self):
51
+ patch, calls = answering((200, "OK"))
52
+ with patch:
53
+ out = nuasense.send_reading("abcd-efgh-ijkl", device_id="shed", temperature=20, rain_total=1.5)
54
+ req = calls[0]
55
+ self.assertEqual(req.full_url, "https://api.nuasense.com/station/abcd-efgh-ijkl")
56
+ self.assertEqual(req.get_method(), "POST")
57
+ self.assertIsNone(req.get_header("Authorization"))
58
+ self.assertEqual(req.get_header("X-nuasense-client"), "python/" + nuasense.__version__)
59
+ form = parse_qs(req.data.decode())
60
+ self.assertEqual(form["tempf"], ["68"])
61
+ self.assertEqual(form["PASSKEY"], [nuasense.ecowitt.passkey_for("shed")])
62
+ self.assertEqual(form["stationtype"], ["python_" + nuasense.__version__])
63
+ self.assertTrue(out["sent"])
64
+
65
+ def test_unknown_token_is_explained(self):
66
+ patch, _ = answering((404, "not found"))
67
+ with patch, self.assertRaisesRegex(nuasense.NuaSenseError, "does not know this station token"):
68
+ nuasense.send_reading("abcd-efgh-ijkl", temperature=1)
69
+
70
+ def test_nothing_to_send_is_refused_before_any_call(self):
71
+ patch, calls = answering()
72
+ with patch, self.assertRaisesRegex(ValueError, "at least one value"):
73
+ nuasense.send_reading("abcd-efgh-ijkl")
74
+ self.assertEqual(calls, [])
75
+
76
+ def test_offline_is_a_nuasense_error(self):
77
+ patch, _ = answering(URLError("no route"))
78
+ with patch, self.assertRaisesRegex(nuasense.NuaSenseError, "Could not reach"):
79
+ nuasense.send_reading("abcd-efgh-ijkl", temperature=1)
80
+
81
+
82
+ class ClientTest(unittest.TestCase):
83
+ def test_key_from_the_environment(self):
84
+ with mock.patch.dict(os.environ, {"NUASENSE_API_KEY": " nsk_test "}):
85
+ self.assertEqual(nuasense.Client().api_key, "nsk_test")
86
+ self.assertNotIn("nsk", repr(nuasense.Client("nsk_secret")))
87
+
88
+ def test_first_look(self):
89
+ patch, calls = answering((200, {"tonight": {"frost": "none"}}))
90
+ with patch:
91
+ out = nuasense.Client("k").first_look("st-1")
92
+ self.assertEqual(out["tonight"]["frost"], "none")
93
+ self.assertTrue(calls[0].full_url.startswith("https://api.nuasense.com/api/nuasense/v1/ingest/first-look?"))
94
+ self.assertEqual(query(calls[0]), {"station_id": "st-1"})
95
+ self.assertEqual(calls[0].get_header("Authorization"), "Bearer k")
96
+
97
+ def test_dataset_params_and_rows(self):
98
+ body = {"dataset": "market/prices", "rows": [{"price": 52}]}
99
+ patch, calls = answering((200, body), (200, body))
100
+ ns = nuasense.Client("k")
101
+ with patch:
102
+ ns.dataset("market/prices", start="-14d", every="1d", oldest_first=True, county="nakuru", end=None)
103
+ rows = ns.rows("/market/prices")
104
+ self.assertEqual(urlparse(calls[0].full_url).path, "/api/nuasense/v1/market/prices")
105
+ self.assertEqual(query(calls[0]), {"start": "-14d", "limit": "50", "every": "1d", "agg": "mean",
106
+ "order": "asc", "county": "nakuru"})
107
+ self.assertEqual(rows, [{"price": 52}])
108
+
109
+ def test_analysis_path(self):
110
+ patch, calls = answering((200, {"ok": True}))
111
+ with patch:
112
+ nuasense.Client("k").analysis("/fertilizer/rank", county="nakuru")
113
+ self.assertEqual(urlparse(calls[0].full_url).path, "/api/partner/v1/market/fertilizer/rank")
114
+
115
+ def test_refusal_carries_status_body_and_hint(self):
116
+ patch, _ = answering((429, {"error": "daily unit budget spent"}))
117
+ with patch, self.assertRaises(nuasense.NuaSenseError) as caught:
118
+ nuasense.Client("k").catalog()
119
+ err = caught.exception
120
+ self.assertEqual(err.status, 429)
121
+ self.assertEqual(err.retry_after, "30")
122
+ self.assertIn("daily unit budget spent", str(err))
123
+ self.assertIn("Retry-After", str(err))
124
+
125
+ def test_key_never_leaves_for_another_host(self):
126
+ patch, calls = answering()
127
+ ns = nuasense.Client("k")
128
+ with patch:
129
+ for url in ("https://evil.example/x", "http://api.nuasense.com/api/nuasense/v1/catalog",
130
+ "https://api.nuasense.com.evil.example/x"):
131
+ with self.assertRaisesRegex(ValueError, "only talks to"):
132
+ ns.get(url)
133
+ self.assertEqual(calls, [])
134
+
135
+ def test_redirects_are_answers_not_followed(self):
136
+ self.assertIsNone(mod._NoRedirect().redirect_request(None, None, 302, "Found", {}, "https://evil.example/"))
137
+ patch, _ = answering((301, ""))
138
+ with patch, self.assertRaises(nuasense.NuaSenseError) as caught:
139
+ nuasense.Client("k").catalog()
140
+ self.assertEqual(caught.exception.status, 301)
141
+
142
+ def test_missing_key_says_where_to_get_one(self):
143
+ patch, _ = answering((401, {"error": "missing key"}))
144
+ with mock.patch.dict(os.environ, {}, clear=True), patch, \
145
+ self.assertRaisesRegex(nuasense.NuaSenseError, "platform.nuasense.com"):
146
+ nuasense.Client().stations()
147
+
148
+
149
+ if __name__ == "__main__":
150
+ unittest.main()
@@ -0,0 +1,94 @@
1
+ import unittest
2
+ from datetime import datetime, timezone
3
+
4
+ from nuasense import ecowitt
5
+
6
+ # Reference values produced by the JavaScript packages (n8n-nodes-nuasense 0.1.1,
7
+ # lib ecowitt.js): one device must keep one PASSKEY whichever package sends.
8
+ JS_PASSKEYS = {
9
+ "shed": "985FCC5F91BFEB59B5C3D0E404026E73",
10
+ "pi-garden": "31512E4C3FD643BA6868A5B58E0A41D0",
11
+ "Gärtchen": "42F9C46C3FAECFC243B71173B1F8D088",
12
+ "🌧": "D22F55545B1470AE66FD13E3825DBDE8",
13
+ }
14
+
15
+ JS_FIELDS = {
16
+ "PASSKEY": "985FCC5F91BFEB59B5C3D0E404026E73",
17
+ "stationtype": "python_0.1.0",
18
+ "dateutc": "2026-11-16 08:30:00",
19
+ "tempf": "70.52",
20
+ "humidity": "63",
21
+ "baromabsin": "29.92",
22
+ "windspeedmph": "6.21",
23
+ "windgustmph": "3.11",
24
+ "winddir": "270",
25
+ "yearlyrainin": "0.48",
26
+ "solarradiation": "500",
27
+ }
28
+
29
+
30
+ class PasskeyTest(unittest.TestCase):
31
+ def test_matches_the_javascript_packages(self):
32
+ for name, expected in JS_PASSKEYS.items():
33
+ self.assertEqual(ecowitt.passkey_for(name), expected, name)
34
+
35
+ def test_stable_and_distinct(self):
36
+ self.assertRegex(ecowitt.passkey_for("shed"), r"^[0-9A-F]{32}$")
37
+ self.assertNotEqual(ecowitt.passkey_for("shed"), ecowitt.passkey_for("roof"))
38
+
39
+
40
+ class FieldsTest(unittest.TestCase):
41
+ def test_same_form_as_the_javascript_packages(self):
42
+ fields = ecowitt.to_ecowitt_fields(
43
+ device_id="shed", station_type="python_0.1.0",
44
+ temperature=21.4, humidity=63.4, pressure=1013.2,
45
+ wind_speed=10, wind_unit="kmh", wind_gusts=5, wind_direction=-90,
46
+ rain_total=12.2, lux=60000, time="2026-11-16T08:30:00Z",
47
+ )
48
+ self.assertEqual(list(fields.items()), list(JS_FIELDS.items()))
49
+
50
+ def test_solar_wins_over_lux_and_empty_values_are_left_out(self):
51
+ fields = ecowitt.to_ecowitt_fields(device_id="x", station_type="t", solar=412.345, lux=1,
52
+ humidity="", pressure=None, temperature=float("nan"))
53
+ self.assertEqual(fields["solarradiation"], "412.35")
54
+ for key in ("humidity", "baromabsin", "tempf"):
55
+ self.assertNotIn(key, fields)
56
+
57
+ def test_zero_is_a_value(self):
58
+ fields = ecowitt.to_ecowitt_fields(device_id="x", station_type="t", rain_total=0, temperature=-17.7778)
59
+ self.assertEqual(fields["yearlyrainin"], "0")
60
+ self.assertEqual(fields["tempf"], "0")
61
+ self.assertTrue(ecowitt.has_values(fields))
62
+
63
+ def test_half_rounds_up_like_javascript(self):
64
+ fields = ecowitt.to_ecowitt_fields(device_id="x", station_type="t", humidity=62.5, wind_direction=359.6)
65
+ self.assertEqual(fields["humidity"], "63")
66
+ self.assertEqual(fields["winddir"], "360")
67
+
68
+ def test_nothing_measured(self):
69
+ self.assertFalse(ecowitt.has_values(ecowitt.to_ecowitt_fields(device_id="x", station_type="t")))
70
+
71
+
72
+ class TimeTest(unittest.TestCase):
73
+ def test_datetime_and_offsets(self):
74
+ self.assertEqual(ecowitt.date_utc(datetime(2026, 11, 16, 11, 30, tzinfo=timezone.utc)), "2026-11-16 11:30:00")
75
+ self.assertEqual(ecowitt.date_utc("2026-11-16T11:30:00+03:00"), "2026-11-16 08:30:00")
76
+
77
+ def test_unreadable_time_becomes_now(self):
78
+ self.assertRegex(ecowitt.date_utc("yesterday"), r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$")
79
+
80
+
81
+ class UrlTest(unittest.TestCase):
82
+ def test_token(self):
83
+ self.assertEqual(ecowitt.upload_url(" KW7N-4hpd-x2vt "), "https://api.nuasense.com/station/kw7n-4hpd-x2vt")
84
+ for bad in ("", "short", "kw7n/4hpd/x2vt", "a" * 30):
85
+ with self.assertRaises(ValueError):
86
+ ecowitt.upload_url(bad)
87
+
88
+ def test_form(self):
89
+ self.assertEqual(ecowitt.encode_form({"dateutc": "2026-11-16 08:30:00", "a": "1"}),
90
+ "dateutc=2026-11-16%2008%3A30%3A00&a=1")
91
+
92
+
93
+ if __name__ == "__main__":
94
+ unittest.main()