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.
- nuasense-0.1.0/.gitignore +6 -0
- nuasense-0.1.0/CHANGELOG.md +7 -0
- nuasense-0.1.0/LICENSE +21 -0
- nuasense-0.1.0/PKG-INFO +142 -0
- nuasense-0.1.0/README.md +117 -0
- nuasense-0.1.0/pyproject.toml +52 -0
- nuasense-0.1.0/src/nuasense/__init__.py +18 -0
- nuasense-0.1.0/src/nuasense/_version.py +1 -0
- nuasense-0.1.0/src/nuasense/client.py +243 -0
- nuasense-0.1.0/src/nuasense/ecowitt.py +176 -0
- nuasense-0.1.0/src/nuasense/py.typed +0 -0
- nuasense-0.1.0/tests/test_client.py +150 -0
- nuasense-0.1.0/tests/test_ecowitt.py +94 -0
|
@@ -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.
|
nuasense-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
nuasense-0.1.0/README.md
ADDED
|
@@ -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()
|