nuasense 0.1.0__py3-none-any.whl
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/__init__.py +18 -0
- nuasense/_version.py +1 -0
- nuasense/client.py +243 -0
- nuasense/ecowitt.py +176 -0
- nuasense/py.typed +0 -0
- nuasense-0.1.0.dist-info/METADATA +142 -0
- nuasense-0.1.0.dist-info/RECORD +9 -0
- nuasense-0.1.0.dist-info/WHEEL +4 -0
- nuasense-0.1.0.dist-info/licenses/LICENSE +21 -0
nuasense/__init__.py
ADDED
|
@@ -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__"]
|
nuasense/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
nuasense/client.py
ADDED
|
@@ -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
|
nuasense/ecowitt.py
ADDED
|
@@ -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)
|
nuasense/py.typed
ADDED
|
File without changes
|
|
@@ -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,9 @@
|
|
|
1
|
+
nuasense/__init__.py,sha256=ede8V4M_axmiXPrVTptOqvEduwFGuUY7J6lzhzcj8Co,643
|
|
2
|
+
nuasense/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
nuasense/client.py,sha256=5FiYAmv73EkYlRQuUNWBQ1rscO-cVoM0NNsDqtYZ8vk,10390
|
|
4
|
+
nuasense/ecowitt.py,sha256=UHXRQ9qA4Ie5oS5kQP8SWVygnhSwpo2_JKIbMnA0nw4,5852
|
|
5
|
+
nuasense/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
nuasense-0.1.0.dist-info/METADATA,sha256=Ak4TGKsdnd-ROKyLtopBId4iq_sk6IbUHyeMa_bXyfw,6067
|
|
7
|
+
nuasense-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
nuasense-0.1.0.dist-info/licenses/LICENSE,sha256=lPUZrLZWFKE3WKanLDZxxPnMA_Isp_UEvwgMzUsC-ys,1085
|
|
9
|
+
nuasense-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|