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 —>

Leave a Reply

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