Skip to content

Commit d2304af

Browse files
authored
SMHI: migrate to SNOW1gv1 API (replace deprecated PMP3gv2) (#4082)
## Summary This PR migrates the SMHI weather provider from the deprecated PMP3gv2 API to the new SNOW1gv1 API. The old API (pmp3g/version/2) started returning HTTP 404 on 2026-03-31. ## Changes - Updated API endpoint: - `pmp3g/version/2` → `snow1g/version/1` - Updated response parsing: - `validTime` → `time` - `parameters[]` → `data` (flat structure) - Updated parameter names: - `t` → `air_temperature` - `ws` → `wind_speed` - etc. - Updated precipitation handling to match new `predominant_precipitation_type_at_surface` - Updated coordinate parsing (flat `[lon, lat]`) - Added missing value handling (`9999 → null`) ## Notes - Maintains backward compatibility for `precipitationValue` config - No UI changes - Symbol mapping unchanged (same codes 1–27) ## Testing - Verified against live SMHI SNOW1gv1 API responses - Confirmed old API returns HTTP 404 ## Impact Fixes broken SMHI provider due to API deprecation.
1 parent 8e1630e commit d2304af

3 files changed

Lines changed: 2130 additions & 1943 deletions

File tree

defaultmodules/weather/providers/smhi.js

Lines changed: 145 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,32 @@ const HTTPFetcher = require("#http_fetcher");
55
/**
66
* Server-side weather provider for SMHI (Swedish Meteorological and Hydrological Institute)
77
* Sweden only, metric system
8-
* API: https://opendata.smhi.se/apidocs/metfcst/
8+
*
9+
* API: SNOW1gv1 — https://opendata.smhi.se/metfcst/snow1gv1
10+
* Migrated from PMP3gv2 (deprecated 2026-03-31, returns HTTP 404)
11+
*
12+
* Version: 2.0.1 (2026-04-02)
13+
*
14+
* Key differences from PMP3gv2:
15+
* - URL: snow1g/version/1 (was pmp3g/version/2)
16+
* - Time key: "time" (was "validTime")
17+
* - Data structure: flat object entry.data.X (was parameters[].find().values[0])
18+
* - Parameter names: human-readable (air_temperature, wind_speed, etc.)
19+
* - Coordinates: flat [lon, lat] (was nested [[lon, lat]])
20+
* - Precipitation types: different value mapping (1=rain, not snow)
921
*/
22+
23+
/**
24+
* Maps user-facing config precipitationValue to SNOW1gv1 parameter names.
25+
* Maintains backward compatibility with existing MagicMirror configs.
26+
*/
27+
const PRECIP_VALUE_MAP = {
28+
pmin: "precipitation_amount_min",
29+
pmean: "precipitation_amount_mean",
30+
pmedian: "precipitation_amount_median",
31+
pmax: "precipitation_amount_max"
32+
};
33+
1034
class SMHIProvider {
1135
constructor (config) {
1236
this.config = {
@@ -19,7 +43,7 @@ class SMHIProvider {
1943
};
2044

2145
// Validate precipitationValue
22-
if (!["pmin", "pmean", "pmedian", "pmax"].includes(this.config.precipitationValue)) {
46+
if (!Object.keys(PRECIP_VALUE_MAP).includes(this.config.precipitationValue)) {
2347
Log.warn(`[smhi] Invalid precipitationValue: ${this.config.precipitationValue}, using pmedian`);
2448
this.config.precipitationValue = "pmedian";
2549
}
@@ -152,14 +176,20 @@ class SMHIProvider {
152176
return this.#convertWeatherDataGroupedBy(filled, coordinates, "hour");
153177
}
154178

179+
/**
180+
* Find the time series entry closest to the current time.
181+
* SNOW1gv1 uses "time" instead of PMP3gv2's "validTime".
182+
* @param {Array<object>} times - Array of SNOW1gv1 time series entries.
183+
* @returns {object} The time series entry closest to the current time.
184+
*/
155185
#getClosestToCurrentTime (times) {
156186
const now = new Date();
157187
let minDiff = null;
158188
let closest = times[0];
159189

160190
for (const time of times) {
161-
const validTime = new Date(time.validTime);
162-
const diff = Math.abs(validTime - now);
191+
const entryTime = new Date(time.time);
192+
const diff = Math.abs(entryTime - now);
163193

164194
if (minDiff === null || diff < minDiff) {
165195
minDiff = diff;
@@ -170,18 +200,27 @@ class SMHIProvider {
170200
return closest;
171201
}
172202

203+
/**
204+
* Convert a single SNOW1gv1 time series entry to MagicMirror weather object.
205+
*
206+
* SNOW1gv1 data structure: entry.data.parameter_name (flat object)
207+
* PMP3gv2 used: entry.parameters[{name, values}] (array of objects)
208+
* @param {object} weatherData - A single SNOW1gv1 time series entry.
209+
* @param {object} coordinates - Object with lat and lon properties.
210+
* @returns {object} MagicMirror-formatted weather data object.
211+
*/
173212
#convertWeatherDataToObject (weatherData, coordinates) {
174-
const date = new Date(weatherData.validTime);
213+
const date = new Date(weatherData.time);
175214
const { sunrise, sunset } = getSunTimes(date, coordinates.lat, coordinates.lon);
176215
const isDay = isDayTime(date, sunrise, sunset);
177216

178217
const current = {
179218
date: date,
180-
humidity: this.#paramValue(weatherData, "r"),
181-
temperature: this.#paramValue(weatherData, "t"),
182-
windSpeed: this.#paramValue(weatherData, "ws"),
183-
windFromDirection: this.#paramValue(weatherData, "wd"),
184-
weatherType: this.#convertWeatherType(this.#paramValue(weatherData, "Wsymb2"), isDay),
219+
humidity: this.#paramValue(weatherData, "relative_humidity"),
220+
temperature: this.#paramValue(weatherData, "air_temperature"),
221+
windSpeed: this.#paramValue(weatherData, "wind_speed"),
222+
windFromDirection: this.#paramValue(weatherData, "wind_from_direction"),
223+
weatherType: this.#convertWeatherType(this.#paramValue(weatherData, "symbol_code"), isDay),
185224
feelsLikeTemp: this.#calculateApparentTemperature(weatherData),
186225
sunrise: sunrise,
187226
sunset: sunset,
@@ -190,28 +229,37 @@ class SMHIProvider {
190229
precipitationAmount: 0
191230
};
192231

193-
// Determine precipitation amount and category
194-
const precipitationValue = this.#paramValue(weatherData, this.config.precipitationValue);
195-
const pcat = this.#paramValue(weatherData, "pcat");
196-
232+
// Map user config (pmedian/pmean/pmin/pmax) to SNOW1gv1 parameter name
233+
const precipParamName = PRECIP_VALUE_MAP[this.config.precipitationValue];
234+
const precipitationValue = this.#paramValue(weatherData, precipParamName);
235+
const pcat = this.#paramValue(weatherData, "predominant_precipitation_type_at_surface");
236+
237+
// SNOW1gv1 precipitation type mapping (differs from PMP3gv2!):
238+
// 0 = no precipitation
239+
// 1 = rain
240+
// 2 = sleet (snow + rain mix)
241+
// 5 = snow / freezing rain
242+
// 6 = freezing mixed precipitation
243+
// 11 = drizzle / light rain
197244
switch (pcat) {
198-
case 1: // Snow
199-
current.snow = precipitationValue;
245+
case 1: // Rain
246+
case 11: // Drizzle / light rain
247+
current.rain = precipitationValue;
200248
current.precipitationAmount = precipitationValue;
201249
break;
202-
case 2: // Snow and rain (50/50 split)
250+
case 2: // Sleet / mixed rain and snow
203251
current.snow = precipitationValue / 2;
204252
current.rain = precipitationValue / 2;
205253
current.precipitationAmount = precipitationValue;
206254
break;
207-
case 3: // Rain
208-
case 4: // Drizzle
209-
case 5: // Freezing rain
210-
case 6: // Freezing drizzle
211-
current.rain = precipitationValue;
255+
case 5: // Snow / freezing rain
256+
case 6: // Freezing mixed precipitation
257+
current.snow = precipitationValue;
212258
current.precipitationAmount = precipitationValue;
213259
break;
214-
// case 0: No precipitation - defaults already set to 0
260+
case 0:
261+
default:
262+
break;
215263
}
216264

217265
return current;
@@ -285,23 +333,30 @@ class SMHIProvider {
285333
}
286334
}
287335

336+
/**
337+
* Fill gaps in time series data for forecast/hourly grouping.
338+
* SNOW1gv1 has variable time steps: 1h (0-48h), 2h (49-72h), 6h (73-132h), 12h (133h+).
339+
* Uses "time" key instead of PMP3gv2's "validTime".
340+
* @param {Array<object>} data - Array of SNOW1gv1 time series entries.
341+
* @returns {Array<object>} Time series with hourly gaps filled using previous entry data.
342+
*/
288343
#fillInGaps (data) {
289344
if (data.length === 0) return [];
290345

291346
const result = [];
292-
result.push(data[0]); // Keep first data point
347+
result.push(data[0]);
293348

294349
for (let i = 1; i < data.length; i++) {
295-
const from = new Date(data[i - 1].validTime);
296-
const to = new Date(data[i].validTime);
350+
const from = new Date(data[i - 1].time);
351+
const to = new Date(data[i].time);
297352
const hours = Math.floor((to - from) / (1000 * 60 * 60));
298353

299354
// Fill gaps with previous data point (start at j=1 since j=0 is already pushed)
300355
for (let j = 1; j < hours; j++) {
301356
const current = { ...data[i - 1] };
302357
const newTime = new Date(from);
303358
newTime.setHours(from.getHours() + j);
304-
current.validTime = newTime.toISOString();
359+
current.time = newTime.toISOString();
305360
result.push(current);
306361
}
307362

@@ -312,13 +367,21 @@ class SMHIProvider {
312367
return result;
313368
}
314369

370+
/**
371+
* Extract coordinates from SNOW1gv1 response.
372+
* SNOW1gv1 returns flat GeoJSON Point: { coordinates: [lon, lat] }
373+
* PMP3gv2 returned nested: { coordinates: [[lon, lat]] }
374+
* @param {object} data - The full SNOW1gv1 API response object.
375+
* @returns {object} Object with lat and lon properties.
376+
*/
315377
#resolveCoordinates (data) {
316-
// SMHI returns coordinates in [lon, lat] format
317-
// Fall back to config if response structure is unexpected
318-
if (data?.geometry?.coordinates?.[0] && Array.isArray(data.geometry.coordinates[0]) && data.geometry.coordinates[0].length >= 2) {
378+
const coords = data?.geometry?.coordinates;
379+
380+
if (Array.isArray(coords) && coords.length >= 2 && typeof coords[0] === "number") {
381+
// SNOW1gv1 flat format: [lon, lat]
319382
return {
320-
lat: data.geometry.coordinates[0][1],
321-
lon: data.geometry.coordinates[0][0]
383+
lat: coords[1],
384+
lon: coords[0]
322385
};
323386
}
324387

@@ -329,20 +392,57 @@ class SMHIProvider {
329392
};
330393
}
331394

395+
/**
396+
* Calculate apparent (feels-like) temperature using humidity and wind.
397+
* Uses SNOW1gv1 parameter names.
398+
* @param {object} weatherData - A single SNOW1gv1 time series entry.
399+
* @returns {number|null} Apparent temperature in °C, or raw temperature if data is missing.
400+
*/
332401
#calculateApparentTemperature (weatherData) {
333-
const Ta = this.#paramValue(weatherData, "t");
334-
const rh = this.#paramValue(weatherData, "r");
335-
const ws = this.#paramValue(weatherData, "ws");
336-
const p = (rh / 100) * 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
402+
const Ta = this.#paramValue(weatherData, "air_temperature");
403+
const rh = this.#paramValue(weatherData, "relative_humidity");
404+
const ws = this.#paramValue(weatherData, "wind_speed");
337405

406+
if (Ta === null || rh === null || ws === null) {
407+
return Ta; // Fallback to raw temperature if data missing
408+
}
409+
410+
const p = (rh / 100) * 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
338411
return Ta + 0.33 * p - 0.7 * ws - 4;
339412
}
340413

414+
/**
415+
* Get parameter value from SNOW1gv1 flat data structure.
416+
* SNOW1gv1: weatherData.data.parameter_name (direct property access)
417+
* PMP3gv2 used: weatherData.parameters.find(p => p.name === name).values[0]
418+
*
419+
* Returns null if parameter missing or equals SMHI missing value (9999).
420+
* @param {object} weatherData - A single SNOW1gv1 time series entry.
421+
* @param {string} name - The SNOW1gv1 parameter name to look up.
422+
* @returns {number|null} The parameter value, or null if missing.
423+
*/
341424
#paramValue (weatherData, name) {
342-
const param = weatherData.parameters.find((p) => p.name === name);
343-
return param ? param.values[0] : null;
425+
const value = weatherData.data?.[name];
426+
427+
if (value === undefined || value === null) {
428+
return null;
429+
}
430+
431+
// SMHI uses 9999 as missing value sentinel for all parameters
432+
if (value === 9999) {
433+
return null;
434+
}
435+
436+
return value;
344437
}
345438

439+
/**
440+
* Convert SMHI symbol_code (1-27) to MagicMirror weather icon names.
441+
* Symbol codes are identical between PMP3gv2 and SNOW1gv1.
442+
* @param {number} input - SMHI symbol_code value (1-27).
443+
* @param {boolean} isDayTime - Whether the current time is during daytime.
444+
* @returns {string|null} MagicMirror weather icon name, or null if unknown.
445+
*/
346446
#convertWeatherType (input, isDayTime) {
347447
switch (input) {
348448
case 1:
@@ -387,10 +487,16 @@ class SMHIProvider {
387487
}
388488
}
389489

490+
/**
491+
* Build SNOW1gv1 forecast URL.
492+
* Changed from: pmp3g/version/2
493+
* Changed to: snow1g/version/1
494+
* @returns {string} The full SNOW1gv1 API URL for the configured coordinates.
495+
*/
390496
#getUrl () {
391497
const lon = this.config.lon.toFixed(6);
392498
const lat = this.config.lat.toFixed(6);
393-
return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`;
499+
return `https://opendata-download-metfcst.smhi.se/api/category/snow1g/version/1/geotype/point/lon/${lon}/lat/${lat}/data.json`;
394500
}
395501
}
396502

0 commit comments

Comments
 (0)