node-red-contrib-atmospore 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # node-red-contrib-atmospore
2
+
3
+ <p align="center">
4
+ <a href="https://buymeacoffee.com/airdavid">
5
+ <img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" height="41">
6
+ </a>
7
+ </p>
8
+
9
+ <p align="center"><em>Built and maintained independently. If this saves you time, a coffee keeps it alive.</em></p>
10
+
11
+ ---
12
+
13
+ Node-RED nodes for the [Atmospore Pollen Forecast API](https://atmospore.com) — point forecasts, area averages, and top species rankings. Trigger automations based on forecasted pollen levels.
14
+
15
+ ## Nodes
16
+
17
+ | Node | Description |
18
+ |------|-------------|
19
+ | **atmospore-config** | Shared API key and default coordinates |
20
+ | **atmospore-pollen** | Interpolated point forecast for a coordinate |
21
+ | **atmospore-pollen-area** | Area averages (min/max/avg) within a radius |
22
+ | **atmospore-pollen-top** | Top species ranked by severity over a date range |
23
+ | **atmospore-species** | Metadata for all 21 supported species |
24
+
25
+ ## Setup
26
+
27
+ ### 1. Get an API key
28
+
29
+ Sign up at [atmospore.com](https://atmospore.com) and copy your API key from account settings.
30
+
31
+ ### 2. Add the config node
32
+
33
+ Add an **atmospore-config** node, enter your API key, and set a default latitude and longitude. Use **Test API key** to verify before saving.
34
+
35
+ ### 3. Add nodes to your flow
36
+
37
+ All nodes are triggered by any input message. Override coordinates, date, or forecast window at runtime via `msg` properties.
38
+
39
+ ## Node reference
40
+
41
+ ### atmospore-pollen
42
+
43
+ Interpolated pollen forecast at a specific coordinate. Returns one entry per day.
44
+
45
+ **Inputs**
46
+
47
+ | Property | Type | Description |
48
+ |----------|------|-------------|
49
+ | `payload` | any | Triggers a fetch |
50
+ | `lat` / `lon` _(optional)_ | number | Override config node coordinates |
51
+ | `dt` _(optional)_ | string | Start date `YYYY-MM-DD`. Defaults to today |
52
+ | `forecastDays` _(optional)_ | number | Days to forecast (1–14) |
53
+ | `species` _(optional)_ | string | `all`, `tree`, `grass`, `weed`, or comma-separated species |
54
+
55
+ **Output** — `msg.payload.data[]`, one entry per day:
56
+ ```json
57
+ {
58
+ "date": "2026-06-28",
59
+ "overall_risk": "high",
60
+ "species": {
61
+ "pinaceae": { "value": 124.6, "risk_level": "high", "display_name": "Pine family", "category": "tree" },
62
+ "timothy": { "value": 40.8, "risk_level": "high", "display_name": "Timothy Grass", "category": "grass" },
63
+ "poa": { "value": 29.1, "risk_level": "high", "display_name": "Bluegrass", "category": "grass" },
64
+ "urtica": { "value": 31.6, "risk_level": "moderate", "display_name": "Nettle", "category": "weed" },
65
+ "birch": { "value": 0, "risk_level": "low", "display_name": "Birch", "category": "tree" }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ### atmospore-pollen-area
71
+
72
+ Area-average pollen levels within a configurable radius. Each species includes `avg`, `min`, `max`, and `risk_level`.
73
+
74
+ Same inputs as **atmospore-pollen**, plus:
75
+
76
+ | Property | Type | Description |
77
+ |----------|------|-------------|
78
+ | `radius` _(optional)_ | number | Search radius in meters (max 50 000, default 25 000) |
79
+
80
+ ### atmospore-pollen-top
81
+
82
+ Top contributing species over a date range, ranked by severity. Good for a daily summary node.
83
+
84
+ **Output** — `msg.payload.data[]`, ranked by severity:
85
+ ```json
86
+ [
87
+ { "species": "birch", "display_name": "Birch", "category": "tree", "avg": 98, "max": 142, "risk_level": "high" },
88
+ { "species": "alder", "display_name": "Alder", "category": "tree", "avg": 14, "max": 22, "risk_level": "low" }
89
+ ]
90
+ ```
91
+
92
+ ### atmospore-species
93
+
94
+ Returns metadata for all 25 supported species — display names, multilingual names (English, Swedish, Norwegian), categories, and risk thresholds. No authentication required. Trigger once at startup and cache the result.
95
+
96
+ ## Example: close the windows when birch pollen is high
97
+
98
+ Wire up: **inject (every morning) → atmospore-pollen → function → your smart home node**
99
+
100
+ ```javascript
101
+ const today = msg.payload.data[0];
102
+ const birch = today?.species?.birch;
103
+
104
+ if (!birch) return null; // species not in response
105
+
106
+ if (birch.risk_level === 'high' || birch.risk_level === 'very high') {
107
+ msg.payload = { action: 'close_windows', reason: `Birch pollen: ${birch.risk_level}` };
108
+ return msg;
109
+ }
110
+ return null; // no action needed
111
+ ```
112
+
113
+ ## Requirements
114
+
115
+ - Node.js 14 or later
116
+ - Node-RED 2.0 or later
117
+ - Atmospore API key
118
+
119
+ ## License
120
+
121
+ MIT
122
+
123
+ ---
124
+
125
+ ## Support this project
126
+
127
+ **[☕ Buy me a coffee](https://buymeacoffee.com/airdavid)**
128
+
129
+ Bug reports and pull requests welcome on [GitHub](https://github.com/devdavidkarlsson/node-red-contrib-atmospore).
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const fetch = require('node-fetch');
4
+
5
+ const BASE_URL = 'https://pollenapi.com';
6
+ const REQUEST_TIMEOUT_MS = 15000;
7
+
8
+ function today() {
9
+ return new Date().toISOString().slice(0, 10);
10
+ }
11
+
12
+ class AtmosporeAPI {
13
+ constructor(apiKey) {
14
+ this.apiKey = apiKey;
15
+ }
16
+
17
+ async _fetch(path, auth = true) {
18
+ const headers = auth ? { 'x-api-key': this.apiKey } : {};
19
+ const response = await fetch(`${BASE_URL}${path}`, {
20
+ timeout: REQUEST_TIMEOUT_MS,
21
+ headers,
22
+ });
23
+
24
+ if (!response.ok) {
25
+ const text = await response.text();
26
+ throw new Error(`API error (${response.status}): ${text}`);
27
+ }
28
+
29
+ return response.json();
30
+ }
31
+
32
+ async getPollen({ lat, lon, dt, forecastDays = 1, species = 'all' } = {}) {
33
+ const params = new URLSearchParams({
34
+ lat, lon,
35
+ dt: dt || today(),
36
+ forecast_days: forecastDays,
37
+ species,
38
+ });
39
+ return this._fetch(`/v1/pollen?${params}`);
40
+ }
41
+
42
+ async getPollenArea({ lat, lon, dt, forecastDays = 1, species = 'all', radius = 25000 } = {}) {
43
+ const params = new URLSearchParams({
44
+ lat, lon,
45
+ dt: dt || today(),
46
+ forecast_days: forecastDays,
47
+ species,
48
+ radius,
49
+ });
50
+ return this._fetch(`/v1/pollen-area?${params}`);
51
+ }
52
+
53
+ async getPollenTop({ lat, lon, dt, forecastDays = 1 } = {}) {
54
+ const params = new URLSearchParams({
55
+ lat, lon,
56
+ dt: dt || today(),
57
+ forecast_days: forecastDays,
58
+ });
59
+ return this._fetch(`/v1/pollen-top?${params}`);
60
+ }
61
+
62
+ async getSpecies() {
63
+ return this._fetch('/v1/species', false);
64
+ }
65
+ }
66
+
67
+ module.exports = AtmosporeAPI;
@@ -0,0 +1,87 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('atmospore-config', {
3
+ category: 'config',
4
+ defaults: {
5
+ name: { value: '' },
6
+ defaultLat: { value: '' },
7
+ defaultLon: { value: '' },
8
+ },
9
+ credentials: {
10
+ apiKey: { type: 'password' },
11
+ },
12
+ label: function() { return this.name || 'Atmospore'; },
13
+ oneditprepare: function() {
14
+ var configNodeId = this.id;
15
+ $('#atmospore-test-btn').on('click', function() {
16
+ var btn = $(this);
17
+ var status = $('#atmospore-test-status');
18
+ var tokens = RED.settings.get('auth-tokens');
19
+ var authHeader = tokens ? { 'Authorization': 'Bearer ' + tokens.access_token } : {};
20
+
21
+ btn.prop('disabled', true);
22
+ status.css('color', '#aaa').text('Testing...');
23
+
24
+ function handleResult(result) {
25
+ status.css('color', result.ok ? '#009900' : '#c00')
26
+ .text(result.ok ? '✓ API key valid.' : '✗ ' + result.error);
27
+ btn.prop('disabled', false);
28
+ }
29
+
30
+ var apiKey = $('#node-config-input-apiKey').val();
31
+ var isPlaceholder = !apiKey || apiKey === '__CREDENTIAL_PLACEHOLDER__';
32
+
33
+ if (isPlaceholder && configNodeId) {
34
+ // Existing deployed node — secret is not in the browser
35
+ $.ajax({
36
+ url: '/atmospore/test-node?configId=' + encodeURIComponent(configNodeId),
37
+ type: 'GET', headers: authHeader,
38
+ success: handleResult,
39
+ error: function(xhr) { status.css('color','#c00').text('✗ Request failed: ' + xhr.status); btn.prop('disabled', false); }
40
+ });
41
+ } else if (!isPlaceholder) {
42
+ // New node or user has typed a new key — test raw credentials
43
+ $.ajax({
44
+ url: '/atmospore/test-key', method: 'POST',
45
+ contentType: 'application/json', headers: authHeader,
46
+ data: JSON.stringify({ apiKey: apiKey }),
47
+ success: handleResult,
48
+ error: function(xhr) { status.css('color','#c00').text('✗ Request failed: ' + xhr.status); btn.prop('disabled', false); }
49
+ });
50
+ } else {
51
+ status.css('color','#c00').text('Enter an API key first.');
52
+ btn.prop('disabled', false);
53
+ }
54
+ });
55
+ },
56
+ });
57
+ </script>
58
+
59
+ <script type="text/html" data-template-name="atmospore-config">
60
+ <div class="form-row">
61
+ <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
62
+ <input type="text" id="node-config-input-name" placeholder="Atmospore">
63
+ </div>
64
+ <div class="form-row">
65
+ <label for="node-config-input-apiKey"><i class="fa fa-key"></i> API Key</label>
66
+ <input type="password" id="node-config-input-apiKey" placeholder="API key from atmospore.com">
67
+ </div>
68
+ <div class="form-row">
69
+ <label></label>
70
+ <button type="button" id="atmospore-test-btn" class="red-ui-button">Test API key</button>
71
+ <span id="atmospore-test-status" style="margin-left:10px;font-size:0.9em"></span>
72
+ </div>
73
+ <div class="form-row">
74
+ <label for="node-config-input-defaultLat"><i class="fa fa-map-marker"></i> Latitude</label>
75
+ <input type="number" id="node-config-input-defaultLat" placeholder="e.g. 59.9139" step="any" style="width:70%">
76
+ </div>
77
+ <div class="form-row">
78
+ <label for="node-config-input-defaultLon"><i class="fa fa-map-marker"></i> Longitude</label>
79
+ <input type="number" id="node-config-input-defaultLon" placeholder="e.g. 10.7522" step="any" style="width:70%">
80
+ </div>
81
+ </script>
82
+
83
+ <script type="text/html" data-help-name="atmospore-config">
84
+ <p>Configuration node for the Atmospore Pollen Forecast API.</p>
85
+ <p>Get your API key from <a href="https://atmospore.com" target="_blank">atmospore.com</a> account settings.</p>
86
+ <p><strong>Default Lat/Lon</strong> is used by all pollen nodes unless overridden by <code>msg.lat</code> / <code>msg.lon</code> at runtime.</p>
87
+ </script>
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const AtmosporeAPI = require('../lib/atmospore-api');
4
+
5
+ module.exports = function(RED) {
6
+ // Test the API key without needing a deployed node
7
+ RED.httpAdmin.post('/atmospore/test-key', RED.auth.needsPermission('flows.write'), async (req, res) => {
8
+ const { apiKey } = req.body;
9
+ if (!apiKey) return res.json({ ok: false, error: 'API key is required' });
10
+ const api = new AtmosporeAPI(apiKey);
11
+ try {
12
+ // /v1/species requires no auth — use /v1/pollen with a dummy call to test the key
13
+ // Instead, just validate by calling species (no auth) won't test the key.
14
+ // Use a minimal pollen call to verify auth.
15
+ await api.getPollen({ lat: 59.9, lon: 10.7, forecastDays: 1 });
16
+ res.json({ ok: true });
17
+ } catch (err) {
18
+ res.json({ ok: false, error: err.message });
19
+ }
20
+ });
21
+
22
+ // Test via deployed node (key not in browser for existing nodes)
23
+ RED.httpAdmin.get('/atmospore/test-node', RED.auth.needsPermission('flows.read'), async (req, res) => {
24
+ const configNode = RED.nodes.getNode(req.query.configId);
25
+ if (!configNode || !configNode.api) {
26
+ return res.json({ ok: false, error: 'Config node not found or has no API key' });
27
+ }
28
+ try {
29
+ await configNode.api.getPollen({ lat: 59.9, lon: 10.7, forecastDays: 1 });
30
+ res.json({ ok: true });
31
+ } catch (err) {
32
+ res.json({ ok: false, error: err.message });
33
+ }
34
+ });
35
+
36
+ function AtmosporeConfigNode(config) {
37
+ RED.nodes.createNode(this, config);
38
+ this.name = config.name;
39
+ this.defaultLat = config.defaultLat;
40
+ this.defaultLon = config.defaultLon;
41
+
42
+ const apiKey = this.credentials.apiKey;
43
+ this.api = apiKey ? new AtmosporeAPI(apiKey) : null;
44
+ }
45
+
46
+ RED.nodes.registerType('atmospore-config', AtmosporeConfigNode, {
47
+ credentials: {
48
+ apiKey: { type: 'password' },
49
+ },
50
+ });
51
+ };
@@ -0,0 +1,64 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('atmospore-pollen-area', {
3
+ category: 'Atmospore',
4
+ color: '#7fb069',
5
+ defaults: {
6
+ name: { value: '' },
7
+ configNode: { value: '', type: 'atmospore-config', required: true },
8
+ forecastDays: { value: 1 },
9
+ species: { value: 'all' },
10
+ radius: { value: 25000 },
11
+ },
12
+ inputs: 1,
13
+ outputs: 1,
14
+ icon: 'font-awesome/fa-map-o',
15
+ label: function() { return this.name || 'pollen area'; },
16
+ paletteLabel: 'pollen area',
17
+ });
18
+ </script>
19
+
20
+ <script type="text/html" data-template-name="atmospore-pollen-area">
21
+ <div class="form-row">
22
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
23
+ <input type="text" id="node-input-name" placeholder="Name">
24
+ </div>
25
+ <div class="form-row">
26
+ <label for="node-input-configNode"><i class="fa fa-cog"></i> Config</label>
27
+ <input type="text" id="node-input-configNode">
28
+ </div>
29
+ <div class="form-row">
30
+ <label for="node-input-forecastDays"><i class="fa fa-calendar"></i> Days</label>
31
+ <input type="number" id="node-input-forecastDays" min="1" max="14" style="width:60px"> <span style="color:#aaa">(1–14)</span>
32
+ </div>
33
+ <div class="form-row">
34
+ <label for="node-input-species"><i class="fa fa-filter"></i> Species</label>
35
+ <select id="node-input-species" style="width:70%">
36
+ <option value="all">All</option>
37
+ <option value="tree">Trees only</option>
38
+ <option value="grass">Grass only</option>
39
+ <option value="weed">Weeds only</option>
40
+ </select>
41
+ </div>
42
+ <div class="form-row">
43
+ <label for="node-input-radius"><i class="fa fa-circle-o"></i> Radius</label>
44
+ <input type="number" id="node-input-radius" min="1" max="50000" style="width:80px"> <span style="color:#aaa">meters (max 50 000)</span>
45
+ </div>
46
+ </script>
47
+
48
+ <script type="text/html" data-help-name="atmospore-pollen-area">
49
+ <p>Average, minimum, and maximum pollen levels across a configurable radius. Good for regional risk assessment.</p>
50
+ <h3>Inputs</h3>
51
+ <dl class="message-properties">
52
+ <dt>payload <span class="property-type">any</span></dt><dd>Triggers a fetch.</dd>
53
+ <dt class="optional">lat / lon <span class="property-type">number</span></dt><dd>Override coordinates.</dd>
54
+ <dt class="optional">dt <span class="property-type">string</span></dt><dd>Start date (YYYY-MM-DD). Defaults to today.</dd>
55
+ <dt class="optional">forecastDays <span class="property-type">number</span></dt><dd>Number of days (1–14).</dd>
56
+ <dt class="optional">radius <span class="property-type">number</span></dt><dd>Search radius in meters (max 50 000).</dd>
57
+ <dt class="optional">species <span class="property-type">string</span></dt><dd><code>all</code>, <code>tree</code>, <code>grass</code>, <code>weed</code>, or comma-separated species.</dd>
58
+ </dl>
59
+ <h3>Outputs</h3>
60
+ <dl class="message-properties">
61
+ <dt>payload <span class="property-type">object</span></dt>
62
+ <dd><code>data[]</code> — one entry per day. Each species has <code>avg</code>, <code>min</code>, <code>max</code>, and <code>risk_level</code>.</dd>
63
+ </dl>
64
+ </script>
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ module.exports = function(RED) {
4
+ function AtmosporePollenAreaNode(config) {
5
+ RED.nodes.createNode(this, config);
6
+ const node = this;
7
+ node.configNode = RED.nodes.getNode(config.configNode);
8
+ node.forecastDays = config.forecastDays || 1;
9
+ node.species = config.species || 'all';
10
+ node.radius = config.radius || 25000;
11
+
12
+ if (!node.configNode || !node.configNode.api) {
13
+ node.error('No valid Atmospore config node');
14
+ node.status({ fill: 'red', shape: 'ring', text: 'no config' });
15
+ return;
16
+ }
17
+
18
+ node.on('input', async function(msg, send, done) {
19
+ const lat = msg.lat || node.configNode.defaultLat;
20
+ const lon = msg.lon || node.configNode.defaultLon;
21
+ if (!lat || !lon) { done(new Error('Latitude and longitude are required (set in config node or msg.lat / msg.lon)')); return; }
22
+
23
+ try {
24
+ node.status({ fill: 'blue', shape: 'dot', text: 'fetching...' });
25
+ const data = await node.configNode.api.getPollenArea({
26
+ lat, lon,
27
+ dt: msg.dt,
28
+ forecastDays: msg.forecastDays || node.forecastDays,
29
+ species: msg.species || node.species,
30
+ radius: msg.radius || node.radius,
31
+ });
32
+ msg.payload = data;
33
+ node.status({ fill: 'green', shape: 'dot', text: 'ok' });
34
+ send(msg);
35
+ done();
36
+ } catch (err) {
37
+ node.status({ fill: 'red', shape: 'ring', text: err.message });
38
+ done(err);
39
+ }
40
+ });
41
+ }
42
+
43
+ RED.nodes.registerType('atmospore-pollen-area', AtmosporePollenAreaNode);
44
+ };
@@ -0,0 +1,48 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('atmospore-pollen-top', {
3
+ category: 'Atmospore',
4
+ color: '#7fb069',
5
+ defaults: {
6
+ name: { value: '' },
7
+ configNode: { value: '', type: 'atmospore-config', required: true },
8
+ forecastDays: { value: 7 },
9
+ },
10
+ inputs: 1,
11
+ outputs: 1,
12
+ icon: 'font-awesome/fa-sort-amount-desc',
13
+ label: function() { return this.name || 'top pollen species'; },
14
+ paletteLabel: 'top species',
15
+ });
16
+ </script>
17
+
18
+ <script type="text/html" data-template-name="atmospore-pollen-top">
19
+ <div class="form-row">
20
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
21
+ <input type="text" id="node-input-name" placeholder="Name">
22
+ </div>
23
+ <div class="form-row">
24
+ <label for="node-input-configNode"><i class="fa fa-cog"></i> Config</label>
25
+ <input type="text" id="node-input-configNode">
26
+ </div>
27
+ <div class="form-row">
28
+ <label for="node-input-forecastDays"><i class="fa fa-calendar"></i> Days</label>
29
+ <input type="number" id="node-input-forecastDays" min="1" max="14" style="width:60px"> <span style="color:#aaa">(1–14)</span>
30
+ </div>
31
+ </script>
32
+
33
+ <script type="text/html" data-help-name="atmospore-pollen-top">
34
+ <p>Top contributing pollen species for a date range, ranked by severity.</p>
35
+ <h3>Inputs</h3>
36
+ <dl class="message-properties">
37
+ <dt>payload <span class="property-type">any</span></dt><dd>Triggers a fetch.</dd>
38
+ <dt class="optional">lat / lon <span class="property-type">number</span></dt><dd>Override coordinates.</dd>
39
+ <dt class="optional">dt <span class="property-type">string</span></dt><dd>Start date (YYYY-MM-DD). Defaults to today.</dd>
40
+ <dt class="optional">forecastDays <span class="property-type">number</span></dt><dd>Number of days to aggregate over (1–14).</dd>
41
+ </dl>
42
+ <h3>Outputs</h3>
43
+ <dl class="message-properties">
44
+ <dt>payload <span class="property-type">object</span></dt>
45
+ <dd><code>data[]</code> — species ranked by severity, each with <code>species</code>, <code>display_name</code>, <code>category</code>, <code>avg</code>, <code>max</code>, and <code>risk_level</code>.</dd>
46
+ </dl>
47
+ <p>Example: <code>msg.payload.data[0].display_name</code> → the worst species over the period.</p>
48
+ </script>
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ module.exports = function(RED) {
4
+ function AtmosporePollenTopNode(config) {
5
+ RED.nodes.createNode(this, config);
6
+ const node = this;
7
+ node.configNode = RED.nodes.getNode(config.configNode);
8
+ node.forecastDays = config.forecastDays || 7;
9
+
10
+ if (!node.configNode || !node.configNode.api) {
11
+ node.error('No valid Atmospore config node');
12
+ node.status({ fill: 'red', shape: 'ring', text: 'no config' });
13
+ return;
14
+ }
15
+
16
+ node.on('input', async function(msg, send, done) {
17
+ const lat = msg.lat || node.configNode.defaultLat;
18
+ const lon = msg.lon || node.configNode.defaultLon;
19
+ if (!lat || !lon) { done(new Error('Latitude and longitude are required (set in config node or msg.lat / msg.lon)')); return; }
20
+
21
+ try {
22
+ node.status({ fill: 'blue', shape: 'dot', text: 'fetching...' });
23
+ const data = await node.configNode.api.getPollenTop({
24
+ lat, lon,
25
+ dt: msg.dt,
26
+ forecastDays: msg.forecastDays || node.forecastDays,
27
+ });
28
+ msg.payload = data;
29
+ node.status({ fill: 'green', shape: 'dot', text: 'ok' });
30
+ send(msg);
31
+ done();
32
+ } catch (err) {
33
+ node.status({ fill: 'red', shape: 'ring', text: err.message });
34
+ done(err);
35
+ }
36
+ });
37
+ }
38
+
39
+ RED.nodes.registerType('atmospore-pollen-top', AtmosporePollenTopNode);
40
+ };
@@ -0,0 +1,59 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('atmospore-pollen', {
3
+ category: 'Atmospore',
4
+ color: '#7fb069',
5
+ defaults: {
6
+ name: { value: '' },
7
+ configNode: { value: '', type: 'atmospore-config', required: true },
8
+ forecastDays: { value: 1 },
9
+ species: { value: 'all' },
10
+ },
11
+ inputs: 1,
12
+ outputs: 1,
13
+ icon: 'font-awesome/fa-leaf',
14
+ label: function() { return this.name || 'pollen forecast'; },
15
+ paletteLabel: 'pollen forecast',
16
+ });
17
+ </script>
18
+
19
+ <script type="text/html" data-template-name="atmospore-pollen">
20
+ <div class="form-row">
21
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
22
+ <input type="text" id="node-input-name" placeholder="Name">
23
+ </div>
24
+ <div class="form-row">
25
+ <label for="node-input-configNode"><i class="fa fa-cog"></i> Config</label>
26
+ <input type="text" id="node-input-configNode">
27
+ </div>
28
+ <div class="form-row">
29
+ <label for="node-input-forecastDays"><i class="fa fa-calendar"></i> Days</label>
30
+ <input type="number" id="node-input-forecastDays" min="1" max="14" style="width:60px"> <span style="color:#aaa">(1–14, overridable via msg.forecastDays)</span>
31
+ </div>
32
+ <div class="form-row">
33
+ <label for="node-input-species"><i class="fa fa-filter"></i> Species</label>
34
+ <select id="node-input-species" style="width:70%">
35
+ <option value="all">All</option>
36
+ <option value="tree">Trees only</option>
37
+ <option value="grass">Grass only</option>
38
+ <option value="weed">Weeds only</option>
39
+ </select>
40
+ </div>
41
+ </script>
42
+
43
+ <script type="text/html" data-help-name="atmospore-pollen">
44
+ <p>Interpolated pollen forecast at a specific coordinate.</p>
45
+ <h3>Inputs</h3>
46
+ <dl class="message-properties">
47
+ <dt>payload <span class="property-type">any</span></dt><dd>Triggers a fetch.</dd>
48
+ <dt class="optional">lat / lon <span class="property-type">number</span></dt><dd>Override the config node's default coordinates.</dd>
49
+ <dt class="optional">dt <span class="property-type">string</span></dt><dd>Start date (YYYY-MM-DD). Defaults to today.</dd>
50
+ <dt class="optional">forecastDays <span class="property-type">number</span></dt><dd>Number of days (1–14).</dd>
51
+ <dt class="optional">species <span class="property-type">string</span></dt><dd><code>all</code>, <code>tree</code>, <code>grass</code>, <code>weed</code>, or a comma-separated species list.</dd>
52
+ </dl>
53
+ <h3>Outputs</h3>
54
+ <dl class="message-properties">
55
+ <dt>payload <span class="property-type">object</span></dt>
56
+ <dd><code>data[]</code> — one entry per day with <code>date</code>, <code>overall_risk</code>, and a <code>species</code> object keyed by species name.</dd>
57
+ </dl>
58
+ <p>Example: <code>msg.payload.data[0].species.birch.risk_level</code> → <code>"high"</code></p>
59
+ </script>
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ module.exports = function(RED) {
4
+ function AtmosporePollenNode(config) {
5
+ RED.nodes.createNode(this, config);
6
+ const node = this;
7
+ node.configNode = RED.nodes.getNode(config.configNode);
8
+ node.forecastDays = config.forecastDays || 1;
9
+ node.species = config.species || 'all';
10
+
11
+ if (!node.configNode || !node.configNode.api) {
12
+ node.error('No valid Atmospore config node');
13
+ node.status({ fill: 'red', shape: 'ring', text: 'no config' });
14
+ return;
15
+ }
16
+
17
+ node.on('input', async function(msg, send, done) {
18
+ const lat = msg.lat || node.configNode.defaultLat;
19
+ const lon = msg.lon || node.configNode.defaultLon;
20
+ if (!lat || !lon) { done(new Error('Latitude and longitude are required (set in config node or msg.lat / msg.lon)')); return; }
21
+
22
+ try {
23
+ node.status({ fill: 'blue', shape: 'dot', text: 'fetching...' });
24
+ const data = await node.configNode.api.getPollen({
25
+ lat, lon,
26
+ dt: msg.dt,
27
+ forecastDays: msg.forecastDays || node.forecastDays,
28
+ species: msg.species || node.species,
29
+ });
30
+ msg.payload = data;
31
+ node.status({ fill: 'green', shape: 'dot', text: 'ok' });
32
+ send(msg);
33
+ done();
34
+ } catch (err) {
35
+ node.status({ fill: 'red', shape: 'ring', text: err.message });
36
+ done(err);
37
+ }
38
+ });
39
+ }
40
+
41
+ RED.nodes.registerType('atmospore-pollen', AtmosporePollenNode);
42
+ };
@@ -0,0 +1,35 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('atmospore-species', {
3
+ category: 'Atmospore',
4
+ color: '#7fb069',
5
+ defaults: {
6
+ name: { value: '' },
7
+ configNode: { value: '', type: 'atmospore-config', required: true },
8
+ },
9
+ inputs: 1,
10
+ outputs: 1,
11
+ icon: 'font-awesome/fa-list',
12
+ label: function() { return this.name || 'species metadata'; },
13
+ paletteLabel: 'species metadata',
14
+ });
15
+ </script>
16
+
17
+ <script type="text/html" data-template-name="atmospore-species">
18
+ <div class="form-row">
19
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
20
+ <input type="text" id="node-input-name" placeholder="Name">
21
+ </div>
22
+ <div class="form-row">
23
+ <label for="node-input-configNode"><i class="fa fa-cog"></i> Config</label>
24
+ <input type="text" id="node-input-configNode">
25
+ </div>
26
+ </script>
27
+
28
+ <script type="text/html" data-help-name="atmospore-species">
29
+ <p>Fetches metadata for all 25 supported pollen species — display names, multilingual names, categories, and risk thresholds. No authentication required. Cache this response and refresh infrequently.</p>
30
+ <h3>Outputs</h3>
31
+ <dl class="message-properties">
32
+ <dt>payload <span class="property-type">object</span></dt>
33
+ <dd><code>data</code> — object keyed by species identifier, each with <code>display_name</code>, <code>category</code>, <code>names</code> (multilingual), and <code>risk_thresholds</code>.</dd>
34
+ </dl>
35
+ </script>
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ module.exports = function(RED) {
4
+ function AtmosporeSpeciesNode(config) {
5
+ RED.nodes.createNode(this, config);
6
+ const node = this;
7
+ node.configNode = RED.nodes.getNode(config.configNode);
8
+
9
+ if (!node.configNode || !node.configNode.api) {
10
+ node.error('No valid Atmospore config node');
11
+ node.status({ fill: 'red', shape: 'ring', text: 'no config' });
12
+ return;
13
+ }
14
+
15
+ node.on('input', async function(msg, send, done) {
16
+ try {
17
+ node.status({ fill: 'blue', shape: 'dot', text: 'fetching...' });
18
+ const data = await node.configNode.api.getSpecies();
19
+ msg.payload = data;
20
+ node.status({ fill: 'green', shape: 'dot', text: 'ok' });
21
+ send(msg);
22
+ done();
23
+ } catch (err) {
24
+ node.status({ fill: 'red', shape: 'ring', text: err.message });
25
+ done(err);
26
+ }
27
+ });
28
+ }
29
+
30
+ RED.nodes.registerType('atmospore-species', AtmosporeSpeciesNode);
31
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "node-red-contrib-atmospore",
3
+ "version": "0.1.0",
4
+ "description": "Node-RED nodes for the Atmospore Pollen Forecast API — point forecasts, area averages, top species",
5
+ "keywords": [
6
+ "node-red",
7
+ "atmospore",
8
+ "pollen",
9
+ "forecast",
10
+ "allergy",
11
+ "air quality"
12
+ ],
13
+ "author": "David Karlsson",
14
+ "license": "MIT",
15
+ "engines": {
16
+ "node": ">=14.0.0"
17
+ },
18
+ "node-red": {
19
+ "version": ">=2.0.0",
20
+ "nodes": {
21
+ "atmospore-config": "nodes/atmospore-config.js",
22
+ "atmospore-pollen": "nodes/atmospore-pollen.js",
23
+ "atmospore-pollen-area": "nodes/atmospore-pollen-area.js",
24
+ "atmospore-pollen-top": "nodes/atmospore-pollen-top.js",
25
+ "atmospore-species": "nodes/atmospore-species.js"
26
+ }
27
+ },
28
+ "dependencies": {
29
+ "node-fetch": "^2.7.0"
30
+ }
31
+ }