node-red-contrib-ipgeolocation 1.0.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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ipgeo-asn.js
3
+ *
4
+ * Node-RED node: "ipgeo asn"
5
+ *
6
+ * Performs an ASN (Autonomous System Number) lookup via the
7
+ * IPGeolocation.io /v3/asn endpoint (GET).
8
+ *
9
+ * Unlike the other ipgeo-* nodes, this endpoint accepts EITHER an `ip`
10
+ * OR an `asn` query parameter (mutually exclusive) — or neither, in
11
+ * which case the API auto-detects the caller's IP and returns ASN data
12
+ * for it.
13
+ *
14
+ * Features
15
+ * ─────────
16
+ * • Query mode: auto-detect caller IP | lookup by IP | lookup by ASN
17
+ * • Configurable `include` modules: peers, downstreams, upstreams,
18
+ * routes, whois_response (comma-separated)
19
+ * • `fields` and `excludes` for bandwidth optimisation
20
+ * • IP/ASN value can be sourced from node config OR msg/flow/global property
21
+ * • Two outputs: [success, error] — error output is optional
22
+ * • Exposes credits consumed on msg.ipgeo_credits (flat 1 credit/lookup)
23
+ * • Full retry + exponential backoff via shared api-client
24
+ *
25
+ * Shares the same `ipgeo-config` config node (API key) as the other
26
+ * ipgeo-* nodes.
27
+ */
28
+
29
+ "use strict";
30
+
31
+ const { request, IPGeoError } = require("./lib/api-client");
32
+ const {
33
+ setStatus,
34
+ resolveValue,
35
+ setOutputProperty,
36
+ handleNodeError,
37
+ buildCommonParams,
38
+ } = require("./lib/node-helpers");
39
+
40
+ module.exports = function (RED) {
41
+ function IPGeoASNNode(config) {
42
+ RED.nodes.createNode(this, config);
43
+
44
+ // ── Config node (API key) ─────────────────────────────────────────────
45
+ const configNode = RED.nodes.getNode(config.server);
46
+
47
+ // ── Node configuration ────────────────────────────────────────────────
48
+ this.name = config.name;
49
+ this.queryType = config.queryType || "auto"; // 'auto' | 'ip' | 'asn'
50
+ this.ipType = config.ipType || "str"; // 'str' | 'msg' | 'flow' | 'global'
51
+ this.ipValue = config.ipValue || "";
52
+ this.asnType = config.asnType || "str"; // 'str' | 'msg' | 'flow' | 'global'
53
+ this.asnValue = config.asnValue || "";
54
+ this.include = config.include || ""; // comma-sep list of modules
55
+ this.fields = config.fields || "";
56
+ this.excludes = config.excludes || "";
57
+ this.outputProp = config.outputProp || "payload"; // output property
58
+ this.outputType = config.outputType || "msg";
59
+ this.twoOutputs = config.twoOutputs !== false; // default true
60
+
61
+ const node = this;
62
+
63
+ // ── Pre-flight: warn if no config node ───────────────────────────────
64
+ if (!configNode) {
65
+ setStatus(node, "NO_KEY");
66
+ node.warn(
67
+ "No IPGeolocation.io config node selected — configure an API key first."
68
+ );
69
+ } else {
70
+ setStatus(node, "IDLE");
71
+ }
72
+
73
+ // ── Input handler ─────────────────────────────────────────────────────
74
+ node.on("input", async function (msg, send, done) {
75
+ // Node-RED 0.x compat
76
+ send =
77
+ send ||
78
+ function () {
79
+ node.send.apply(node, arguments);
80
+ };
81
+
82
+ // ── Validate API key availability ─────────────────────────────────
83
+ const apiKey =
84
+ configNode && configNode.credentials
85
+ ? configNode.credentials.apiKey
86
+ : null;
87
+
88
+ if (!apiKey) {
89
+ setStatus(node, "NO_KEY");
90
+ const err = new IPGeoError("No API key configured", "NO_API_KEY");
91
+ handleNodeError(node, err, msg, done, send, node.twoOutputs);
92
+ return;
93
+ }
94
+
95
+ // ── Resolve ip / asn depending on the selected query mode ─────────
96
+ // Only one of ip/asn is ever sent. If queryType is 'auto', neither
97
+ // is sent and the API auto-detects the caller's IP.
98
+ let ip = "";
99
+ let asn = "";
100
+
101
+ if (node.queryType === "ip") {
102
+ if (
103
+ node.ipType === "msg" ||
104
+ node.ipType === "flow" ||
105
+ node.ipType === "global"
106
+ ) {
107
+ ip =
108
+ resolveValue(RED, node, msg, {
109
+ value: node.ipValue,
110
+ type: node.ipType,
111
+ }) || "";
112
+ } else {
113
+ ip = (node.ipValue || "").trim();
114
+ }
115
+ ip = String(ip).trim();
116
+ } else if (node.queryType === "asn") {
117
+ if (
118
+ node.asnType === "msg" ||
119
+ node.asnType === "flow" ||
120
+ node.asnType === "global"
121
+ ) {
122
+ asn =
123
+ resolveValue(RED, node, msg, {
124
+ value: node.asnValue,
125
+ type: node.asnType,
126
+ }) || "";
127
+ } else {
128
+ asn = (node.asnValue || "").trim();
129
+ }
130
+ // Accept "AS24940" or "24940" — API expects the bare number.
131
+ asn = String(asn).trim().replace(/^as/i, "").trim();
132
+ }
133
+ // 'auto' — leave both ip and asn unset; caller IP is used.
134
+
135
+ // ── Build query parameters ────────────────────────────────────────
136
+ // ASN endpoint has no `lang` param — apiKey / ip|asn / include /
137
+ // fields / excludes.
138
+ const params = buildCommonParams(apiKey, {
139
+ fields: node.fields,
140
+ excludes: node.excludes,
141
+ });
142
+
143
+ if (ip) params.ip = ip;
144
+ if (asn) params.asn = asn;
145
+
146
+ // Build include list (may come from node config or msg override)
147
+ const includeList = (msg.ipgeo_include || node.include || "").trim();
148
+ if (includeList) params.include = includeList;
149
+
150
+ // ── Set working status ────────────────────────────────────────────
151
+ setStatus(node, "WORKING");
152
+
153
+ // ── API call ──────────────────────────────────────────────────────
154
+ try {
155
+ const { data, credits } = await request({
156
+ method: "GET",
157
+ path: "/asn",
158
+ params,
159
+ });
160
+
161
+ // ── Success ────────────────────────────────────────────────────
162
+ setStatus(
163
+ node,
164
+ "OK",
165
+ data.asn?.as_number || data.asn?.organization || data.ip || "ok"
166
+ );
167
+
168
+ // Write result to configured output property
169
+ setOutputProperty(
170
+ RED,
171
+ node,
172
+ msg,
173
+ { value: node.outputProp, type: node.outputType },
174
+ data
175
+ );
176
+
177
+ // Expose credits consumed (informational) — flat 1 credit/lookup
178
+ if (credits !== null) msg.ipgeo_credits = credits;
179
+
180
+ // Clear error property if previously set
181
+ delete msg.error;
182
+
183
+ send(node.twoOutputs ? [msg, null] : msg);
184
+ if (done) done();
185
+ } catch (err) {
186
+ handleNodeError(node, err, msg, done, send, node.twoOutputs);
187
+ }
188
+ });
189
+
190
+ // ── Clean up on node removal/redeploy ─────────────────────────────────
191
+ node.on("close", function () {
192
+ setStatus(node, "IDLE");
193
+ });
194
+ }
195
+
196
+ RED.nodes.registerType("ipgeo-asn", IPGeoASNNode);
197
+ };
@@ -0,0 +1,242 @@
1
+ <!-- ============================================================
2
+ ipgeo-astronomy.html
3
+ Astronomy (sun & moon) node
4
+ ============================================================ -->
5
+
6
+ <script type="text/javascript">
7
+ (function () {
8
+ 'use strict';
9
+
10
+ RED.nodes.registerType('ipgeo-astronomy', {
11
+ category: 'IPGeolocation',
12
+ color: '#5da0d0',
13
+ icon: 'font-awesome/fa-sun-o',
14
+ paletteLabel:'ipgeo astronomy',
15
+
16
+ defaults: {
17
+ name: { value: '' },
18
+ server: { value: '', type: 'ipgeo-config', required: true },
19
+ mode: { value: 'coords' },
20
+ inputType: { value: 'str' },
21
+ inputValue: { value: '' },
22
+ latType: { value: 'str' },
23
+ latValue: { value: '' },
24
+ lngType: { value: 'str' },
25
+ lngValue: { value: '' },
26
+ dateType: { value: 'str' },
27
+ dateValue: { value: '' },
28
+ tzType: { value: 'str' },
29
+ tzValue: { value: '' },
30
+ elevationType: { value: 'str' },
31
+ elevationValue: { value: '' },
32
+ langType: { value: 'str' },
33
+ langValue: { value: '' },
34
+ outputProp: { value: 'payload' },
35
+ outputType: { value: 'msg' },
36
+ twoOutputs: { value: true },
37
+ },
38
+
39
+ inputs: 1,
40
+ outputs: 2,
41
+ outputLabels: function (idx) { return idx === 0 ? 'success' : 'error'; },
42
+
43
+ label: function () { return this.name || 'ipgeo astronomy'; },
44
+
45
+ oneditprepare: function () {
46
+ const node = this;
47
+
48
+ const typedOpts = { types: ['msg', 'flow', 'global', 'str'] };
49
+
50
+ $('#node-input-inputValue').typedInput({ ...typedOpts, default: 'str', typeField: '#node-input-inputType' });
51
+ $('#node-input-latValue').typedInput({ ...typedOpts, default: 'str', typeField: '#node-input-latType' });
52
+ $('#node-input-lngValue').typedInput({ ...typedOpts, default: 'str', typeField: '#node-input-lngType' });
53
+ $('#node-input-dateValue').typedInput({ ...typedOpts, default: 'str', typeField: '#node-input-dateType' });
54
+ $('#node-input-tzValue').typedInput({ ...typedOpts, default: 'str', typeField: '#node-input-tzType' });
55
+ $('#node-input-elevationValue').typedInput({ ...typedOpts, default: 'str', typeField: '#node-input-elevationType' });
56
+ $('#node-input-langValue').typedInput({ ...typedOpts, default: 'str', typeField: '#node-input-langType' });
57
+
58
+ $('#node-input-outputProp').typedInput({
59
+ default: 'msg',
60
+ typeField: '#node-input-outputType',
61
+ types: ['msg', 'flow', 'global'],
62
+ });
63
+
64
+ $('#node-input-twoOutputs').prop('checked', node.twoOutputs !== false);
65
+
66
+ // Show only the inputs relevant to the selected mode.
67
+ const placeholders = {
68
+ location: 'e.g. New York, USA',
69
+ ip: 'e.g. 8.8.8.8',
70
+ };
71
+ function refreshMode() {
72
+ const mode = $('#node-input-mode').val();
73
+ $('.ipgeo-coords-row').toggle(mode === 'coords');
74
+ $('.ipgeo-single-row').toggle(mode === 'location' || mode === 'ip');
75
+ if (placeholders[mode]) {
76
+ $('#node-input-inputValue').attr('placeholder', placeholders[mode]);
77
+ }
78
+ }
79
+ $('#node-input-mode').on('change', refreshMode);
80
+ refreshMode();
81
+ },
82
+
83
+ oneditsave: function () {
84
+ this.twoOutputs = $('#node-input-twoOutputs').prop('checked');
85
+ },
86
+ });
87
+ }());
88
+ </script>
89
+
90
+ <!-- ── Edit template ── -->
91
+ <script type="text/html" data-template-name="ipgeo-astronomy">
92
+ <div class="form-row">
93
+ <label for="node-input-name"><i class="fa fa-tag"></i>&nbsp;Name</label>
94
+ <input type="text" id="node-input-name" placeholder="ipgeo astronomy" style="width:70%"/>
95
+ </div>
96
+ <div class="form-row">
97
+ <label for="node-input-server"><i class="fa fa-key"></i>&nbsp;API Config</label>
98
+ <input type="text" id="node-input-server" style="width:70%"/>
99
+ </div>
100
+ <hr/>
101
+
102
+ <!-- Lookup mode -->
103
+ <div class="form-row">
104
+ <label for="node-input-mode"><i class="fa fa-crosshairs"></i>&nbsp;Look up by</label>
105
+ <select id="node-input-mode" style="width:70%">
106
+ <option value="coords">Coordinates (lat &amp; long)</option>
107
+ <option value="location">Location / city address</option>
108
+ <option value="ip">IP address</option>
109
+ <option value="auto">Caller IP (no input)</option>
110
+ </select>
111
+ </div>
112
+
113
+ <!-- Coordinates -->
114
+ <div class="form-row ipgeo-coords-row">
115
+ <label for="node-input-latValue"><i class="fa fa-arrows-v"></i>&nbsp;Latitude</label>
116
+ <input type="text" id="node-input-latValue" placeholder="e.g. 40.7128" style="width:70%"/>
117
+ <input type="hidden" id="node-input-latType"/>
118
+ </div>
119
+ <div class="form-row ipgeo-coords-row">
120
+ <label for="node-input-lngValue"><i class="fa fa-arrows-h"></i>&nbsp;Longitude</label>
121
+ <input type="text" id="node-input-lngValue" placeholder="e.g. -74.0060" style="width:70%"/>
122
+ <input type="hidden" id="node-input-lngType"/>
123
+ </div>
124
+
125
+ <!-- Single value (location / ip) -->
126
+ <div class="form-row ipgeo-single-row">
127
+ <label for="node-input-inputValue"><i class="fa fa-map-marker"></i>&nbsp;Value</label>
128
+ <input type="text" id="node-input-inputValue" style="width:70%"/>
129
+ <input type="hidden" id="node-input-inputType"/>
130
+ </div>
131
+
132
+ <hr/>
133
+
134
+ <!-- Optional date -->
135
+ <div class="form-row">
136
+ <label for="node-input-dateValue"><i class="fa fa-calendar"></i>&nbsp;Date</label>
137
+ <input type="text" id="node-input-dateValue" placeholder="YYYY-MM-DD (leave empty for today)" style="width:70%"/>
138
+ <input type="hidden" id="node-input-dateType"/>
139
+ </div>
140
+
141
+ <!-- Optional timezone -->
142
+ <div class="form-row">
143
+ <label for="node-input-tzValue"><i class="fa fa-clock-o"></i>&nbsp;Time zone</label>
144
+ <input type="text" id="node-input-tzValue" placeholder="e.g. America/New_York (optional)" style="width:70%"/>
145
+ <input type="hidden" id="node-input-tzType"/>
146
+ <div style="margin-top:4px;font-size:0.82em;color:#888;">
147
+ Converts all event times into this IANA time zone. Leave empty to use
148
+ the location's local time.
149
+ </div>
150
+ </div>
151
+
152
+ <!-- Optional elevation -->
153
+ <div class="form-row">
154
+ <label for="node-input-elevationValue"><i class="fa fa-mountain"></i>&nbsp;Elevation</label>
155
+ <input type="text" id="node-input-elevationValue" placeholder="metres, 0 to 10000 (optional)" style="width:70%"/>
156
+ <input type="hidden" id="node-input-elevationType"/>
157
+ </div>
158
+
159
+ <!-- Optional language -->
160
+ <div class="form-row">
161
+ <label for="node-input-langValue"><i class="fa fa-language"></i>&nbsp;Language</label>
162
+ <input type="text" id="node-input-langValue" placeholder="e.g. en, de, fr (optional, paid plans)" style="width:70%"/>
163
+ <input type="hidden" id="node-input-langType"/>
164
+ </div>
165
+
166
+ <hr/>
167
+ <div class="form-row">
168
+ <label for="node-input-outputProp"><i class="fa fa-sign-out"></i>&nbsp;Output to</label>
169
+ <input type="text" id="node-input-outputProp" style="width:70%"/>
170
+ <input type="hidden" id="node-input-outputType"/>
171
+ </div>
172
+ <div class="form-row">
173
+ <label><i class="fa fa-random"></i>&nbsp;Outputs</label>
174
+ <input type="checkbox" id="node-input-twoOutputs" style="width:auto;margin-right:6px"/>
175
+ <label for="node-input-twoOutputs" style="display:inline;font-weight:normal">
176
+ Use second output for errors
177
+ </label>
178
+ </div>
179
+ </script>
180
+
181
+ <!-- ── Help panel ── -->
182
+ <script type="text/html" data-help-name="ipgeo-astronomy">
183
+ <p>
184
+ Retrieves sun and moon rise/set times, twilight phases, and positional
185
+ data via the <strong>IPGeolocation.io Astronomy API</strong>.
186
+ </p>
187
+
188
+ <h3>Look up by</h3>
189
+ <p>The location can be derived in one of four ways:</p>
190
+ <ul>
191
+ <li><b>Coordinates</b>: latitude + longitude.</li>
192
+ <li><b>Location / city address</b>: e.g. <code>New York, USA</code>.</li>
193
+ <li><b>IP address</b>: any IPv4 or IPv6 address.</li>
194
+ <li><b>Caller IP</b>: send no location and let the API use the caller's IP.</li>
195
+ </ul>
196
+ <p>
197
+ If more than one is supplied, the API prefers coordinates, then location,
198
+ then IP.
199
+ </p>
200
+
201
+ <h3>Inputs</h3>
202
+ <dl class="message-properties">
203
+ <dt class="optional">Latitude / Longitude <span class="property-type">number | string</span></dt>
204
+ <dd>Decimal degrees. Required in <i>Coordinates</i> mode.</dd>
205
+ <dt class="optional">Value <span class="property-type">string</span></dt>
206
+ <dd>The address or IP. Required in <i>Location</i> or <i>IP</i> mode.</dd>
207
+ <dt class="optional">Date <span class="property-type">string</span></dt>
208
+ <dd><code>YYYY-MM-DD</code>. Defaults to today at the location.</dd>
209
+ <dt class="optional">Time zone <span class="property-type">string</span></dt>
210
+ <dd>IANA name; converts event times into this zone.</dd>
211
+ <dt class="optional">Elevation <span class="property-type">number | string</span></dt>
212
+ <dd>Metres above sea level, 0 to 10000. Defaults to 0.</dd>
213
+ <dt class="optional">Language <span class="property-type">string</span></dt>
214
+ <dd>Language for the <code>location</code> object (paid plans).</dd>
215
+ </dl>
216
+
217
+ <h3>Output</h3>
218
+ <dl class="message-properties">
219
+ <dt>payload <span class="property-type">object</span></dt>
220
+ <dd>
221
+ Object with a <code>location</code> block and an <code>astronomy</code>
222
+ block. The latter holds <code>sunrise</code>, <code>sunset</code>,
223
+ <code>solar_noon</code>, <code>day_length</code>, <code>sun_altitude</code>,
224
+ <code>sun_azimuth</code>, <code>sun_distance</code>, <code>moonrise</code>,
225
+ <code>moonset</code>, <code>moon_phase</code>,
226
+ <code>moon_illumination_percentage</code>, plus <code>morning</code> and
227
+ <code>evening</code> twilight phases. When looked up by IP, an
228
+ <code>ip</code> field is also returned.
229
+ </dd>
230
+ <dt>ipgeo_credits <span class="property-type">number</span></dt>
231
+ <dd>Credits charged for the request (1 per astronomy lookup).</dd>
232
+ </dl>
233
+
234
+ <h3>References</h3>
235
+ <ul>
236
+ <li>
237
+ <a href="https://ipgeolocation.io/documentation/astronomy-api.html" target="_blank">
238
+ Astronomy API documentation
239
+ </a>
240
+ </li>
241
+ </ul>
242
+ </script>
@@ -0,0 +1,195 @@
1
+ /**
2
+ * ipgeo-astronomy.js
3
+ *
4
+ * Node-RED node: "ipgeo astronomy"
5
+ *
6
+ * Fetches sun & moon rise/set times and positional data via the
7
+ * IPGeolocation.io /v3/astronomy endpoint.
8
+ *
9
+ * Input modes (the API derives the location from one of these)
10
+ * ────────────────────────────────────────────────────────────
11
+ * auto — send no location parameter; the API uses the caller's IP
12
+ * coords — latitude + longitude (lat / long)
13
+ * location — a free-text address, preferably a city (location)
14
+ * ip — an IPv4 or IPv6 address (ip)
15
+ *
16
+ * Per the API: if several location parameters are present, lat/long win,
17
+ * then location, then ip; with none, the caller's IP is used.
18
+ *
19
+ * Optional inputs (any mode)
20
+ * ──────────────────────────
21
+ * date — "YYYY-MM-DD"; defaults to today at the location
22
+ * time_zone — IANA timezone name; event times are converted to it
23
+ * elevation — metres above sea level (0 to 10000); default 0
24
+ * lang — response language for the location object (paid plans)
25
+ *
26
+ * Every value can be static (node config) or sourced dynamically from
27
+ * msg / flow / global context.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const { request, IPGeoError } = require('./lib/api-client');
33
+ const {
34
+ setStatus,
35
+ resolveValue,
36
+ setOutputProperty,
37
+ handleNodeError,
38
+ } = require('./lib/node-helpers');
39
+
40
+ // Editor mode -> single-value query parameter name. 'coords' is handled
41
+ // separately because it maps to two parameters (lat + long); 'auto' sends
42
+ // no location parameter at all.
43
+ const MODE_PARAM = {
44
+ auto: null,
45
+ coords: null,
46
+ location: 'location',
47
+ ip: 'ip',
48
+ };
49
+
50
+ module.exports = function (RED) {
51
+
52
+ function IPGeoAstronomyNode(config) {
53
+ RED.nodes.createNode(this, config);
54
+
55
+ const configNode = RED.nodes.getNode(config.server);
56
+
57
+ this.name = config.name;
58
+ this.mode = config.mode || 'coords'; // auto | coords | location | ip
59
+ this.inputType = config.inputType || 'str'; // for location / ip modes
60
+ this.inputValue = config.inputValue || '';
61
+ this.latType = config.latType || 'str';
62
+ this.latValue = config.latValue || '';
63
+ this.lngType = config.lngType || 'str';
64
+ this.lngValue = config.lngValue || '';
65
+ this.dateType = config.dateType || 'str';
66
+ this.dateValue = config.dateValue || ''; // empty = API default (today)
67
+ this.tzType = config.tzType || 'str';
68
+ this.tzValue = config.tzValue || ''; // empty = API determines tz
69
+ this.elevationType = config.elevationType || 'str';
70
+ this.elevationValue = config.elevationValue || ''; // empty = API default (0)
71
+ this.langType = config.langType || 'str';
72
+ this.langValue = config.langValue || ''; // empty = API default (en)
73
+ this.outputProp = config.outputProp || 'payload';
74
+ this.outputType = config.outputType || 'msg';
75
+ this.twoOutputs = config.twoOutputs !== false;
76
+
77
+ const node = this;
78
+
79
+ if (!configNode) {
80
+ setStatus(node, 'NO_KEY');
81
+ } else {
82
+ setStatus(node, 'IDLE');
83
+ }
84
+
85
+ node.on('input', async function (msg, send, done) {
86
+ send = send || function () { node.send.apply(node, arguments); };
87
+
88
+ const apiKey = configNode && configNode.credentials
89
+ ? configNode.credentials.apiKey : null;
90
+
91
+ if (!apiKey) {
92
+ setStatus(node, 'NO_KEY');
93
+ const err = new IPGeoError('No API key configured', 'NO_API_KEY');
94
+ handleNodeError(node, err, msg, done, send, node.twoOutputs);
95
+ return;
96
+ }
97
+
98
+ const params = { apiKey };
99
+ const mode = node.mode;
100
+
101
+ try {
102
+ if (mode === 'coords') {
103
+ const lat = resolveValue(RED, node, msg,
104
+ { value: node.latValue, type: node.latType });
105
+ const lng = resolveValue(RED, node, msg,
106
+ { value: node.lngValue, type: node.lngType });
107
+
108
+ if (lat === undefined || lat === null || String(lat).trim() === '') {
109
+ throw new IPGeoError('Latitude is required for coords mode', 'INVALID_INPUT');
110
+ }
111
+ if (lng === undefined || lng === null || String(lng).trim() === '') {
112
+ throw new IPGeoError('Longitude is required for coords mode', 'INVALID_INPUT');
113
+ }
114
+
115
+ params.lat = String(lat).trim();
116
+ params.long = String(lng).trim();
117
+
118
+ } else if (mode === 'location' || mode === 'ip') {
119
+ const paramKey = MODE_PARAM[mode];
120
+ const inputVal = resolveValue(RED, node, msg,
121
+ { value: node.inputValue, type: node.inputType });
122
+
123
+ if (!inputVal && inputVal !== 0) {
124
+ throw new IPGeoError(`Input value is empty for mode "${mode}"`, 'INVALID_INPUT');
125
+ }
126
+
127
+ params[paramKey] = String(inputVal).trim();
128
+
129
+ } else if (mode !== 'auto') {
130
+ throw new IPGeoError(`Unknown lookup mode: ${mode}`, 'INVALID_MODE');
131
+ }
132
+ // 'auto' sends no location parameter; the API falls back to the caller IP.
133
+ } catch (err) {
134
+ handleNodeError(node, err, msg, done, send, node.twoOutputs);
135
+ return;
136
+ }
137
+
138
+ // ── Optional parameters (all modes) ───────────────────────────────
139
+ const date = resolveValue(RED, node, msg,
140
+ { value: node.dateValue, type: node.dateType });
141
+ if (date && String(date).trim()) {
142
+ params.date = String(date).trim();
143
+ }
144
+
145
+ const tz = resolveValue(RED, node, msg,
146
+ { value: node.tzValue, type: node.tzType });
147
+ if (tz && String(tz).trim()) {
148
+ params.time_zone = String(tz).trim();
149
+ }
150
+
151
+ const elevation = resolveValue(RED, node, msg,
152
+ { value: node.elevationValue, type: node.elevationType });
153
+ if (elevation !== undefined && elevation !== null && String(elevation).trim() !== '') {
154
+ params.elevation = String(elevation).trim();
155
+ }
156
+
157
+ const lang = resolveValue(RED, node, msg,
158
+ { value: node.langValue, type: node.langType });
159
+ if (lang && String(lang).trim()) {
160
+ params.lang = String(lang).trim();
161
+ }
162
+
163
+ setStatus(node, 'WORKING');
164
+
165
+ try {
166
+ const { data, credits } = await request({
167
+ method: 'GET',
168
+ path: '/astronomy',
169
+ params,
170
+ });
171
+
172
+ // The response nests the data under an `astronomy` object.
173
+ const sunrise = data && data.astronomy ? data.astronomy.sunrise : undefined;
174
+ setStatus(node, 'OK', sunrise ? `sunrise ${sunrise}` : 'ok');
175
+
176
+ setOutputProperty(RED, node, msg,
177
+ { value: node.outputProp, type: node.outputType }, data);
178
+
179
+ if (credits !== null) msg.ipgeo_credits = credits;
180
+ delete msg.error;
181
+
182
+ send(node.twoOutputs ? [msg, null] : msg);
183
+ if (done) done();
184
+
185
+ } catch (err) {
186
+ handleNodeError(node, err, msg, done, send, node.twoOutputs);
187
+ }
188
+ });
189
+
190
+ node.on('close', () => setStatus(node, 'IDLE'));
191
+ }
192
+
193
+ RED.nodes.registerType('ipgeo-astronomy', IPGeoAstronomyNode);
194
+
195
+ };