Standalone emulator script
So this is an example how the script that emulates the Weather service could look like. At the beginning you need to define few things:
$cache_file– path to a local JSON file that contains the raw response fromhttps://api.met.no/weatherapi/locationforecast/2.0/compact?lat=...&lon=...- Station metadata (optional for Loxone, but good practice):
$station_id$station_name$longitude,$latitude$altitude_m$country$timezone$utc_offset_hours(your local offset vs. UTC)
The script is serving the data to Loxone. The timestamps you emit are in UTC; the Miniserver is responsible for converting to the local time zone based on its own settings. The official weather service normally only shows today + 2 days ahead. I kept the same behavior here.
<?php
/*
----------------------------------------------------------------------------
Minimal Loxone Weather Service Emulator using MET Norway (Yr.no)
----------------------------------------------------------------------------
- Use this as the target for the Miniserver's Weather Service URL.
- Loxone will typically call:
GET /forecast/?user=loxone_xxxxx&coord=LONG,LAT&asl=HEIGHT&format=2&new_api=1
- When format=2, this script outputs Weather4Loxone CSV built from a local
Yr.no Locationforecast JSON file.
- Assumes another script/cron writes the raw Yr.no JSON to $cache_file,
e.g. from:
https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=...&lon=...
- Thank you for supporting Smarthome.Exposed
----------------------------------------------------------------------------
*/
//// USER SETTINGS
// Path to the local Yr.no forecast cache (raw JSON from MET Norway).
// Example: you have another script that does file_put_contents($cache_file, $json);
$cache_file = __DIR__ . '/cache_forecast.json';
// Station metadata (for human readability and potential future use).
// Loxone's Weather4Loxone format does not strictly require these values,
// but they belong to the <mb_metadata> header row if you want to output them.
$station_id = 'local_station';
$station_name = 'My Local Weather';
$longitude = 16.3710; // in degrees East (example)
$latitude = 48.2080; // in degrees North (example)
$altitude_m = 300; // meters above sea level
$country = 'XX'; // e.g. 'CZ', 'DE', 'US'
$timezone = 'Europe/Prague'; // for information only (Loxone uses UTC)
$utc_offset_hours = 1; // for information only (Loxone converts by itself)
////////////////////////////////////////////////////////////////////////////
// Default to JSON until we know we must output CSV
header('Content-Type: application/json; charset=UTF-8');
// --- Read forecast cache (Yr.no Locationforecast JSON) ---
if (!file_exists($cache_file)) {
http_response_code(503);
echo json_encode([
'success' => false,
'error' => 'No weather data (cache file not found)'
]);
exit;
}
$cache_raw = file_get_contents($cache_file);
if ($cache_raw === false) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Failed to read cache file'
]);
exit;
}
$cache = json_decode($cache_raw, true);
if ($cache === null) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Malformed cache JSON'
]);
exit;
}
// Yr.no Locationforecast structure:
// data.properties.timeseries[0..N]
// Each timeseries entry has:
// time: ISO8601 UTC timestamp
// data.instant.details: {
// air_temperature,
// air_pressure_at_sea_level,
// cloud_area_fraction,
// relative_humidity,
// wind_speed,
// wind_from_direction,
// wind_speed_of_gust,
// ...
// }
// data.next_1_hours.summary.symbol_code
// data.next_1_hours.details.precipitation_amount
// (and possibly next_6_hours / next_12_hours for further future)
if (isset($cache['properties']['timeseries']) && is_array($cache['properties']['timeseries'])) {
$timeseries = $cache['properties']['timeseries'];
} elseif (isset($cache['data']['properties']['timeseries']) && is_array($cache['data']['properties']['timeseries'])) {
// Some people wrap the JSON differently (data->properties->timeseries)
$timeseries = $cache['data']['properties']['timeseries'];
} else {
// No usable timeseries; output simple debug text
header('Content-Type: text/plain; charset=UTF-8');
echo "<mb_metadata>\n";
echo "DEBUG: No timeseries found in cache JSON\n";
echo "First 512 chars of file:\n";
echo substr($cache_raw, 0, 512) . "\n";
exit;
}
// --- Yr.no symbol_code -> Loxone pictocode mapping -----------------------
// These numbers are the Weather Block icon IDs used by Loxone.
// Please note that I have determined these through successive experiments,
// they may not all be correct.
$Yrno2LoxoneIcon = [
// Sky / Clouds
"clearsky_day" => 1, // Full sun
"clearsky_night" => 2, // Night, moon, stars
"fair_day" => 13, // Mostly clear
"fair_night" => 2,
"partlycloudy_day" => 14, // Sun with some clouds
"partlycloudy_night" => 7, // Cloud+moon+stars
"cloudy" => 22, // Overcast
"fog" => 16, // Fog
"fog_day" => 16,
"fog_night" => 16,
// Rain & Showers
"lightrain" => 33,
"rain" => 23,
"heavyrain" => 25,
"rainshowers_day" => 31,
"rainshowers_night" => 31,
"heavyrainshowers_day" => 25,
"heavyrainshowers_night" => 25,
"lightrainshowers_day" => 31,
"lightrainshowers_night" => 31,
// Snow & Sleet
"snow" => 24,
"heavysnow" => 26,
"lightsnow" => 32,
"snowshowers_day" => 32,
"snowshowers_night" => 32,
"heavysnowshowers_day" => 26,
"heavysnowshowers_night" => 26,
"lightsnowshowers_day" => 32,
"lightsnowshowers_night" => 32,
"sleet" => 35,
"sleetshowers_day" => 35,
"sleetshowers_night" => 35,
// Hail (fallback)
"hail" => 18,
"hailshowers_day" => 18,
"hailshowers_night" => 18,
// Thunderstorms
"thunderstorm" => 28,
"thunderstormshowers_day" => 27,
"thunderstormshowers_night" => 27,
"thunderstorms" => 28,
"thunderstorms_day" => 27,
"thunderstorms_night" => 27,
// Rare/extreme events (rough fallbacks)
"tornado" => 27,
"tropical_storm" => 27,
"hurricane" => 27,
"sandstorm" => 16,
"volcanic_ash" => 22,
"hot" => 13,
"cold" => 22,
"unknown" => 22,
];
// Small helper: map Yr.no symbol code to Loxone pictocode
function map_yrno_icon($symbol_code, array $map): int {
return $map[$symbol_code] ?? 8; // 8 = some generic fallback icon
}
// --- Which format did Loxone request? ------------------------------------
$format = isset($_GET['format']) ? (int)$_GET['format'] : 1;
// ============================================================================
// FORMAT 2 (and 1): Weather4Loxone CSV
// ============================================================================
//
// Loxone expects a structure like:
//
// <mb_metadata>
// id;name;longitude;latitude;height (m.asl.);country;timezone;utc-timedifference;sunrise;sunset;
// local date;weekday;local time;temperature(C);feeledTemperature(C);windspeed(km/h);winddirection(degr);wind gust(km/h);low clouds(%);medium clouds(%);high clouds(%);precipitation(mm);probability of Precip(%);snowFraction;sea level pressure(hPa);relative humidity(%);CAPE;picto-code;radiation (W/m2);
// </mb_metadata>
// <valid_until>YYYY-MM-DD</valid_until>
// <station>
// 27.07.2025;Sun;16;18.9;18.9;4;328;0;0;100;0;0.0;0;0.0;1006;85;0;22;
// ...
// </station>
//
if ($format === 2 || $format === 1) {
header('Content-Type: text/plain; charset=UTF-8');
echo "<mb_metadata>\n";
// Header row describing station metadata fields:
echo "id;name;longitude;latitude;height (m.asl.);country;timezone;utc-timedifference;sunrise;sunset;\n";
// Header row describing the forecast rows:
echo "local date;weekday;local time;temperature(C);feeledTemperature(C);windspeed(km/h);winddirection(degr);wind gust(km/h);low clouds(%);medium clouds(%);high clouds(%);precipitation(mm);probability of Precip(%);snowFraction;sea level pressure(hPa);relative humidity(%);CAPE;picto-code;radiation (W/m2);\n";
echo "</mb_metadata>\n";
// Valid-until date – can be any date in the future, Loxone doesn't seem strict here
echo "<valid_until>2049-12-31</valid_until>\n";
echo "<station>\n";
$weekdayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
$dates_seen = [];
foreach ($timeseries as $entry) {
if (!isset($entry['time'], $entry['data']['instant']['details'])) {
continue;
}
// Times from Yr.no are in UTC. Loxone expects times in UTC as well and
// converts to local time by itself. DO NOT convert to local time here.
$dt = new DateTime($entry['time'], new DateTimeZone('UTC'));
$date_str = $dt->format('d.m.Y');
$weekday = $weekdayNames[(int)$dt->format('w')]; // 0 = Sunday
$hour = $dt->format('H');
// Limit to 3 unique dates (today + 2 next days), like the official service
if (!in_array($date_str, $dates_seen, true)) {
$dates_seen[] = $date_str;
if (count($dates_seen) > 3) {
break;
}
}
$details = $entry['data']['instant']['details'];
$temp = isset($details['air_temperature']) ? (float)$details['air_temperature'] : 0.0;
$feels = $temp; // could be replaced by a "feels like" computation if desired
$wind_spd_m = isset($details['wind_speed']) ? (float)$details['wind_speed'] : 0.0; // m/s
$wind_spd = (int)round($wind_spd_m * 3.6); // -> km/h
$wind_deg = isset($details['wind_from_direction']) ? (int)round($details['wind_from_direction']) : 0;
$wind_gust_m = isset($details['wind_speed_of_gust']) ? (float)$details['wind_speed_of_gust'] : 0.0;
$wind_gust = (int)round($wind_gust_m * 3.6); // km/h
$low_clouds = 0;
$med_clouds = isset($details['cloud_area_fraction']) ? (int)round($details['cloud_area_fraction']) : 0;
$hi_clouds = 0;
// Precipitation for the next hour (in mm)
$precip = 0.0;
if (isset($entry['data']['next_1_hours']['details']['precipitation_amount'])) {
$precip = (float)$entry['data']['next_1_hours']['details']['precipitation_amount'];
}
$prob_precip = $precip > 0 ? 100 : 0;
$snow_frac = 0.0;
$pressure = isset($details['air_pressure_at_sea_level']) ? round($details['air_pressure_at_sea_level'], 1) : 1017.0;
$humidity = isset($details['relative_humidity']) ? (int)round($details['relative_humidity']) : 0;
$cape = 0;
// Symbol code: try next_1_hours, then 6h, then 12h, else "unknown"
$symbol_code =
$entry['data']['next_1_hours']['summary']['symbol_code']
?? $entry['data']['next_6_hours']['summary']['symbol_code']
?? $entry['data']['next_12_hours']['summary']['symbol_code']
?? "unknown";
$icon = map_yrno_icon($symbol_code, $Yrno2LoxoneIcon);
$radiation = 0; // MET Norway solar radiation forecast is not yet public in this API (at the time of writing)
printf(
"%s;%s;%02d;%.1f;%.1f;%d;%d;%d;%d;%d;%d;%.1f;%d;%.1f;%d;%d;%d;%d;\n",
$date_str,
$weekday,
$hour,
$temp,
$feels,
$wind_spd,
$wind_deg,
$wind_gust,
$low_clouds,
$med_clouds,
$hi_clouds,
$precip,
$prob_precip,
$snow_frac,
$pressure,
$humidity,
$cape,
$icon,
$radiation
);
}
echo "</station>\n";
exit;
}
// ============================================================================
// FORMAT 3 (legacy) – simple text response
// ============================================================================
if ($format === 3) {
header('Content-Type: text/plain; charset=UTF-8');
echo "success@1\n";
echo "msg@Switch to format=2 for Loxone support\n";
exit;
}
// ============================================================================
// DEFAULT: simple JSON info
// ============================================================================
echo json_encode(['success' => true, 'msg' => 'Use format=2 for Loxone Weather Block.']);
exit;
?>
So this is what creates the format Loxone Miniserver can understand, render and display on the App. This script needs to be placed in a web server directory that your PHP-enabled HTTP server can serve (e.g. /var/www/weather/). To make the whole setup work, you also need one more script: the cache updater.
See next page for the cache updater script —>


