| | |

Local Weather Service Emulator

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 from
    https://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 —>

Related posts

Leave a Reply

Your email address will not be published. Required fields are marked *