@@ -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+
1034class 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