MagicMirror Forum
    • Recent
    • Tags
    • Unsolved
    • Solved
    • MagicMirror² Repository
    • Documentation
    • 3rd-Party-Modules
    • Donate
    • Discord
    • Register
    • Login
    A New Chapter for MagicMirror: The Community Takes the Lead
    Read the statement by Michael Teeuw here.

    Swedish Weather provider SMHI has deprecated their API and replaced it

    Scheduled Pinned Locked Moved System
    2 Posts 2 Posters 13 Views 2 Watching
    Loading More Posts
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes
    Reply
    • Reply as topic
    Log in to reply
    This topic has been deleted. Only users with topic management privileges can see it.
    • C Offline
      cgillinger
      last edited by cgillinger

      Heads up for anyone using the Swedish SMHI weather provider in MagicMirror²!

      As of March 31, 2026, SMHI has deprecated their PMP3gv2 forecast API. It now returns HTTP 404.

      I noticed this, this week when my MM suddenly were empty of weather reports.

      The replacement is SNOW1gv1, hosted on the same domain but with a different data structure.

      What changed in the API

      • URL path: pmp3g/version/2 → snow1g/version/1
      • Time key: validTime → time
      • Data structure: The nested parameters[] array is replaced by a flat data object (e.g. entry.data.air_temperature instead of entry.parameters.find(p => p.name === 't').values[0])
      • Parameter names: Short codes replaced by human-readable names (t → air_temperature, ws → wind_speed, Wsymb2 → symbol_code, etc.)
      • Coordinates: Response now returns flat [lon, lat] instead of nested [[lon, lat]]
      • approvedtime.json endpoint: Removed entirely — use referenceTime from the data response instead

      Weather symbol codes (1–27) remain unchanged, so icon mapping still works as before.

      Updated provider

      I’ve updated the smhi.js provider to work with SNOW1gv1. Key changes:

      • New API URL
      • Rewritten data parser for the flat data object structure
      • All parameter names mapped to their new equivalents
      • Coordinate parsing updated for the new format
      • Added missing value handling (SMHI uses 9999 as sentinel)
      • Added support for precipitation type 11 (drizzle, new in SNOW1gv1)
      • Backward compatible — existing config.js settings (including precipitationValue) work without changes

      Tested and confirmed working with type: "daily" on a Raspberry Pi. The SNOW1gv1 API responds correctly and the weather module displays data as expected.

      Code:

      const Log = require("logger");
      const { getSunTimes, isDayTime, validateCoordinates } = require("../provider-utils");
      const HTTPFetcher = require("#http_fetcher");
      
      /**
       * Server-side weather provider for SMHI (Swedish Meteorological and Hydrological Institute)
       * Sweden only, metric system
       *
       * API: SNOW1gv1 — https://opendata.smhi.se/metfcst/snow1gv1
       * Migrated from PMP3gv2 (deprecated 2026-03-31, returns HTTP 404)
       *
       * Version: 2.0.1 (2026-04-02)
       *
       * Key differences from PMP3gv2:
       * - URL: snow1g/version/1 (was pmp3g/version/2)
       * - Time key: "time" (was "validTime")
       * - Data structure: flat object entry.data.X (was parameters[].find().values[0])
       * - Parameter names: human-readable (air_temperature, wind_speed, etc.)
       * - Coordinates: flat [lon, lat] (was nested [[lon, lat]])
       * - Precipitation types: different value mapping (1=rain, not snow)
       */
      
      /**
       * Maps user-facing config precipitationValue to SNOW1gv1 parameter names.
       * Maintains backward compatibility with existing MagicMirror configs.
       */
      const PRECIP_VALUE_MAP = {
      	pmin: "precipitation_amount_min",
      	pmean: "precipitation_amount_mean",
      	pmedian: "precipitation_amount_median",
      	pmax: "precipitation_amount_max"
      };
      
      class SMHIProvider {
      	constructor (config) {
      		this.config = {
      			lat: 0,
      			lon: 0,
      			precipitationValue: "pmedian", // pmin, pmean, pmedian, pmax
      			type: "current",
      			updateInterval: 5 * 60 * 1000,
      			...config
      		};
      
      		// Validate precipitationValue
      		if (!Object.keys(PRECIP_VALUE_MAP).includes(this.config.precipitationValue)) {
      			Log.warn(`[smhi] Invalid precipitationValue: ${this.config.precipitationValue}, using pmedian`);
      			this.config.precipitationValue = "pmedian";
      		}
      
      		this.fetcher = null;
      		this.onDataCallback = null;
      		this.onErrorCallback = null;
      	}
      
      	initialize () {
      		try {
      			// SMHI requires max 6 decimal places
      			validateCoordinates(this.config, 6);
      			this.#initializeFetcher();
      		} catch (error) {
      			Log.error("[smhi] Initialization failed:", error);
      			if (this.onErrorCallback) {
      				this.onErrorCallback({
      					message: error.message,
      					translationKey: "MODULE_ERROR_UNSPECIFIED"
      				});
      			}
      		}
      	}
      
      	setCallbacks (onData, onError) {
      		this.onDataCallback = onData;
      		this.onErrorCallback = onError;
      	}
      
      	start () {
      		if (this.fetcher) {
      			this.fetcher.startPeriodicFetch();
      		}
      	}
      
      	stop () {
      		if (this.fetcher) {
      			this.fetcher.clearTimer();
      		}
      	}
      
      	#initializeFetcher () {
      		const url = this.#getUrl();
      
      		this.fetcher = new HTTPFetcher(url, {
      			reloadInterval: this.config.updateInterval,
      			logContext: "weatherprovider.smhi"
      		});
      
      		this.fetcher.on("response", async (response) => {
      			try {
      				const data = await response.json();
      				this.#handleResponse(data);
      			} catch (error) {
      				Log.error("[smhi] Failed to parse JSON:", error);
      				if (this.onErrorCallback) {
      					this.onErrorCallback({
      						message: "Failed to parse API response",
      						translationKey: "MODULE_ERROR_UNSPECIFIED"
      					});
      				}
      			}
      		});
      
      		this.fetcher.on("error", (errorInfo) => {
      			if (this.onErrorCallback) {
      				this.onErrorCallback(errorInfo);
      			}
      		});
      	}
      
      	#handleResponse (data) {
      		try {
      			if (!data.timeSeries || !Array.isArray(data.timeSeries)) {
      				throw new Error("Invalid weather data");
      			}
      
      			const coordinates = this.#resolveCoordinates(data);
      			let weatherData;
      
      			switch (this.config.type) {
      				case "current":
      					weatherData = this.#generateCurrentWeather(data.timeSeries, coordinates);
      					break;
      				case "forecast":
      				case "daily":
      					weatherData = this.#generateForecast(data.timeSeries, coordinates);
      					break;
      				case "hourly":
      					weatherData = this.#generateHourly(data.timeSeries, coordinates);
      					break;
      				default:
      					Log.error(`[smhi] Unknown weather type: ${this.config.type}`);
      					if (this.onErrorCallback) {
      						this.onErrorCallback({
      							message: `Unknown weather type: ${this.config.type}`,
      							translationKey: "MODULE_ERROR_UNSPECIFIED"
      						});
      					}
      					return;
      			}
      
      			if (this.onDataCallback) {
      				this.onDataCallback(weatherData);
      			}
      		} catch (error) {
      			Log.error("[smhi] Error processing weather data:", error);
      			if (this.onErrorCallback) {
      				this.onErrorCallback({
      					message: error.message,
      					translationKey: "MODULE_ERROR_UNSPECIFIED"
      				});
      			}
      		}
      	}
      
      	#generateCurrentWeather (timeSeries, coordinates) {
      		const closest = this.#getClosestToCurrentTime(timeSeries);
      		return this.#convertWeatherDataToObject(closest, coordinates);
      	}
      
      	#generateForecast (timeSeries, coordinates) {
      		const filled = this.#fillInGaps(timeSeries);
      		return this.#convertWeatherDataGroupedBy(filled, coordinates, "day");
      	}
      
      	#generateHourly (timeSeries, coordinates) {
      		const filled = this.#fillInGaps(timeSeries);
      		return this.#convertWeatherDataGroupedBy(filled, coordinates, "hour");
      	}
      
      	/**
      	 * Find the time series entry closest to the current time.
      	 * SNOW1gv1 uses "time" instead of PMP3gv2's "validTime".
      	 */
      	#getClosestToCurrentTime (times) {
      		const now = new Date();
      		let minDiff = null;
      		let closest = times[0];
      
      		for (const time of times) {
      			const entryTime = new Date(time.time);
      			const diff = Math.abs(entryTime - now);
      
      			if (minDiff === null || diff < minDiff) {
      				minDiff = diff;
      				closest = time;
      			}
      		}
      
      		return closest;
      	}
      
      	/**
      	 * Convert a single SNOW1gv1 time series entry to MagicMirror weather object.
      	 *
      	 * SNOW1gv1 data structure: entry.data.parameter_name (flat object)
      	 * PMP3gv2 used: entry.parameters[{name, values}] (array of objects)
      	 */
      	#convertWeatherDataToObject (weatherData, coordinates) {
      		const date = new Date(weatherData.time);
      		const { sunrise, sunset } = getSunTimes(date, coordinates.lat, coordinates.lon);
      		const isDay = isDayTime(date, sunrise, sunset);
      
      		const current = {
      			date: date,
      			humidity: this.#paramValue(weatherData, "relative_humidity"),
      			temperature: this.#paramValue(weatherData, "air_temperature"),
      			windSpeed: this.#paramValue(weatherData, "wind_speed"),
      			windFromDirection: this.#paramValue(weatherData, "wind_from_direction"),
      			weatherType: this.#convertWeatherType(this.#paramValue(weatherData, "symbol_code"), isDay),
      			feelsLikeTemp: this.#calculateApparentTemperature(weatherData),
      			sunrise: sunrise,
      			sunset: sunset,
      			snow: 0,
      			rain: 0,
      			precipitationAmount: 0
      		};
      
      		// Map user config (pmedian/pmean/pmin/pmax) to SNOW1gv1 parameter name
      		const precipParamName = PRECIP_VALUE_MAP[this.config.precipitationValue];
      		const precipitationValue = this.#paramValue(weatherData, precipParamName);
      		const pcat = this.#paramValue(weatherData, "predominant_precipitation_type_at_surface");
      
      		// SNOW1gv1 precipitation type mapping (differs from PMP3gv2!):
      		//   0  = no precipitation
      		//   1  = rain
      		//   2  = sleet (snow + rain mix)
      		//   5  = snow / freezing rain
      		//   6  = freezing mixed precipitation
      		//   11 = drizzle / light rain
      		switch (pcat) {
      			case 1: // Rain
      			case 11: // Drizzle / light rain
      				current.rain = precipitationValue;
      				current.precipitationAmount = precipitationValue;
      				break;
      			case 2: // Sleet / mixed rain and snow
      				current.snow = precipitationValue / 2;
      				current.rain = precipitationValue / 2;
      				current.precipitationAmount = precipitationValue;
      				break;
      			case 5: // Snow / freezing rain
      			case 6: // Freezing mixed precipitation
      				current.snow = precipitationValue;
      				current.precipitationAmount = precipitationValue;
      				break;
      			case 0:
      			default:
      				break;
      		}
      
      		return current;
      	}
      
      	#convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") {
      		const result = [];
      		let currentWeather = null;
      		let dayWeatherTypes = [];
      
      		const allWeatherObjects = allWeatherData.map((data) => this.#convertWeatherDataToObject(data, coordinates));
      
      		for (const weatherObject of allWeatherObjects) {
      			const objDate = new Date(weatherObject.date);
      
      			// Check if we need a new group (day or hour change)
      			const needNewGroup = !currentWeather || !this.#isSamePeriod(currentWeather.date, objDate, groupBy);
      
      			if (needNewGroup) {
      				currentWeather = {
      					date: objDate,
      					temperature: weatherObject.temperature,
      					minTemperature: Infinity,
      					maxTemperature: -Infinity,
      					snow: 0,
      					rain: 0,
      					precipitationAmount: 0,
      					sunrise: weatherObject.sunrise,
      					sunset: weatherObject.sunset
      				};
      				dayWeatherTypes = [];
      				result.push(currentWeather);
      			}
      
      			// Track weather types during daytime
      			const { sunrise: daySunrise, sunset: daySunset } = getSunTimes(objDate, coordinates.lat, coordinates.lon);
      			const isDay = isDayTime(objDate, daySunrise, daySunset);
      
      			if (isDay) {
      				dayWeatherTypes.push(weatherObject.weatherType);
      			}
      
      			// Use median weather type from daytime hours
      			if (dayWeatherTypes.length > 0) {
      				currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)];
      			} else {
      				currentWeather.weatherType = weatherObject.weatherType;
      			}
      
      			// Aggregate min/max and precipitation
      			currentWeather.minTemperature = Math.min(currentWeather.minTemperature, weatherObject.temperature);
      			currentWeather.maxTemperature = Math.max(currentWeather.maxTemperature, weatherObject.temperature);
      			currentWeather.snow += weatherObject.snow;
      			currentWeather.rain += weatherObject.rain;
      			currentWeather.precipitationAmount += weatherObject.precipitationAmount;
      		}
      
      		return result;
      	}
      
      	#isSamePeriod (date1, date2, groupBy) {
      		if (groupBy === "hour") {
      			return date1.getFullYear() === date2.getFullYear()
      			  && date1.getMonth() === date2.getMonth()
      			  && date1.getDate() === date2.getDate()
      			  && date1.getHours() === date2.getHours();
      		} else { // day
      			return date1.getFullYear() === date2.getFullYear()
      			  && date1.getMonth() === date2.getMonth()
      			  && date1.getDate() === date2.getDate();
      		}
      	}
      
      	/**
      	 * Fill gaps in time series data for forecast/hourly grouping.
      	 * SNOW1gv1 has variable time steps: 1h (0-48h), 2h (49-72h), 6h (73-132h), 12h (133h+).
      	 * Uses "time" key instead of PMP3gv2's "validTime".
      	 */
      	#fillInGaps (data) {
      		if (data.length === 0) return [];
      
      		const result = [];
      		result.push(data[0]);
      
      		for (let i = 1; i < data.length; i++) {
      			const from = new Date(data[i - 1].time);
      			const to = new Date(data[i].time);
      			const hours = Math.floor((to - from) / (1000 * 60 * 60));
      
      			// Fill gaps with previous data point (start at j=1 since j=0 is already pushed)
      			for (let j = 1; j < hours; j++) {
      				const current = { ...data[i - 1] };
      				const newTime = new Date(from);
      				newTime.setHours(from.getHours() + j);
      				current.time = newTime.toISOString();
      				result.push(current);
      			}
      
      			// Push original data point
      			result.push(data[i]);
      		}
      
      		return result;
      	}
      
      	/**
      	 * Extract coordinates from SNOW1gv1 response.
      	 * SNOW1gv1 returns flat GeoJSON Point: { coordinates: [lon, lat] }
      	 * PMP3gv2 returned nested: { coordinates: [[lon, lat]] }
      	 */
      	#resolveCoordinates (data) {
      		const coords = data?.geometry?.coordinates;
      
      		if (Array.isArray(coords) && coords.length >= 2 && typeof coords[0] === "number") {
      			// SNOW1gv1 flat format: [lon, lat]
      			return {
      				lat: coords[1],
      				lon: coords[0]
      			};
      		}
      
      		Log.warn("[smhi] Invalid coordinate structure in response, using config values");
      		return {
      			lat: this.config.lat,
      			lon: this.config.lon
      		};
      	}
      
      	/**
      	 * Calculate apparent (feels-like) temperature using humidity and wind.
      	 * Uses SNOW1gv1 parameter names.
      	 */
      	#calculateApparentTemperature (weatherData) {
      		const Ta = this.#paramValue(weatherData, "air_temperature");
      		const rh = this.#paramValue(weatherData, "relative_humidity");
      		const ws = this.#paramValue(weatherData, "wind_speed");
      
      		if (Ta === null || rh === null || ws === null) {
      			return Ta; // Fallback to raw temperature if data missing
      		}
      
      		const p = (rh / 100) * 6.105 * Math.exp((17.27 * Ta) / (237.7 + Ta));
      		return Ta + 0.33 * p - 0.7 * ws - 4;
      	}
      
      	/**
      	 * Get parameter value from SNOW1gv1 flat data structure.
      	 * SNOW1gv1: weatherData.data.parameter_name (direct property access)
      	 * PMP3gv2 used: weatherData.parameters.find(p => p.name === name).values[0]
      	 *
      	 * Returns null if parameter missing or equals SMHI missing value (9999).
      	 */
      	#paramValue (weatherData, name) {
      		const value = weatherData.data?.[name];
      
      		if (value === undefined || value === null) {
      			return null;
      		}
      
      		// SMHI uses 9999 as missing value sentinel for all parameters
      		if (value === 9999) {
      			return null;
      		}
      
      		return value;
      	}
      
      	/**
      	 * Convert SMHI symbol_code (1-27) to MagicMirror weather icon names.
      	 * Symbol codes are identical between PMP3gv2 and SNOW1gv1.
      	 */
      	#convertWeatherType (input, isDayTime) {
      		switch (input) {
      			case 1:
      				return isDayTime ? "day-sunny" : "night-clear"; // Clear sky
      			case 2:
      				return isDayTime ? "day-sunny-overcast" : "night-partly-cloudy"; // Nearly clear sky
      			case 3:
      			case 4:
      				return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable/halfclear cloudiness
      			case 5:
      			case 6:
      				return "cloudy"; // Cloudy/overcast
      			case 7:
      				return "fog";
      			case 8:
      			case 9:
      			case 10:
      				return "showers"; // Light/moderate/heavy rain showers
      			case 11:
      			case 21:
      				return "thunderstorm";
      			case 12:
      			case 13:
      			case 14:
      			case 22:
      			case 23:
      			case 24:
      				return "sleet"; // Light/moderate/heavy sleet (showers)
      			case 15:
      			case 16:
      			case 17:
      			case 25:
      			case 26:
      			case 27:
      				return "snow"; // Light/moderate/heavy snow (showers/fall)
      			case 18:
      			case 19:
      			case 20:
      				return "rain"; // Light/moderate/heavy rain
      			default:
      				return null;
      		}
      	}
      
      	/**
      	 * Build SNOW1gv1 forecast URL.
      	 * Changed from: pmp3g/version/2
      	 * Changed to:   snow1g/version/1
      	 */
      	#getUrl () {
      		const lon = this.config.lon.toFixed(6);
      		const lat = this.config.lat.toFixed(6);
      		return `https://opendata-download-metfcst.smhi.se/api/category/snow1g/version/1/geotype/point/lon/${lon}/lat/${lat}/data.json`;
      	}
      }
      
      module.exports = SMHIProvider;
      
      S 1 Reply Last reply Reply Quote 0
      • S Do not disturb
        sdetweil @cgillinger
        last edited by sdetweil

        @cgillinger you should submit that as a pull request to get it included

        Nm I see you did

        Sam

        How to add modules

        learning how to use browser developers window for css changes

        1 Reply Last reply Reply Quote 0

        Hello! It looks like you're interested in this conversation, but you don't have an account yet.

        Getting fed up of having to scroll through the same posts each visit? When you register for an account, you'll always come back to exactly where you were before, and choose to be notified of new replies (either via email, or push notification). You'll also be able to save bookmarks and upvote posts to show your appreciation to other community members.

        With your input, this post could be even better 💗

        Register Login
        • 1 / 1
        • First post
          Last post
        Enjoying MagicMirror? Please consider a donation!
        MagicMirror created by Michael Teeuw.
        Forum managed by Sam, technical setup by Karsten.
        This forum is using NodeBB as its core | Contributors
        Contact | Privacy Policy