File indexing completed on 2025-01-12 05:01:50
0001 /* 0002 SPDX-FileCopyrightText: 2007-2009, 2019 Shawn Starr <shawn.starr@rogers.com> 0003 0004 SPDX-License-Identifier: GPL-2.0-or-later 0005 */ 0006 0007 /* Ion for NOAA's National Weather Service XML data */ 0008 0009 #include "ion_noaa.h" 0010 0011 #include "ion_noaadebug.h" 0012 0013 #include <KIO/TransferJob> 0014 #include <KLocalizedString> 0015 #include <KUnitConversion/Converter> 0016 0017 #include <QJsonArray> 0018 #include <QJsonDocument> 0019 #include <QJsonObject> 0020 #include <QLocale> 0021 #include <QTimeZone> 0022 0023 using namespace Qt::StringLiterals; 0024 0025 WeatherData::WeatherData() 0026 : stationLatitude(qQNaN()) 0027 , stationLongitude(qQNaN()) 0028 , temperature_F(qQNaN()) 0029 , temperature_C(qQNaN()) 0030 , humidity(qQNaN()) 0031 , windSpeed(qQNaN()) 0032 , windGust(qQNaN()) 0033 , pressure(qQNaN()) 0034 , dewpoint_F(qQNaN()) 0035 , dewpoint_C(qQNaN()) 0036 , heatindex_F(qQNaN()) 0037 , heatindex_C(qQNaN()) 0038 , windchill_F(qQNaN()) 0039 , windchill_C(qQNaN()) 0040 , visibility(qQNaN()) 0041 { 0042 } 0043 0044 QMap<QString, IonInterface::WindDirections> NOAAIon::setupWindIconMappings() const 0045 { 0046 return QMap<QString, WindDirections>{ 0047 {QStringLiteral("north"), N}, 0048 {QStringLiteral("northeast"), NE}, 0049 {QStringLiteral("south"), S}, 0050 {QStringLiteral("southwest"), SW}, 0051 {QStringLiteral("east"), E}, 0052 {QStringLiteral("southeast"), SE}, 0053 {QStringLiteral("west"), W}, 0054 {QStringLiteral("northwest"), NW}, 0055 {QStringLiteral("calm"), VR}, 0056 }; 0057 } 0058 0059 QMap<QString, IonInterface::ConditionIcons> NOAAIon::setupConditionIconMappings() const 0060 { 0061 QMap<QString, ConditionIcons> conditionList; 0062 return conditionList; 0063 } 0064 0065 QMap<QString, IonInterface::ConditionIcons> const &NOAAIon::conditionIcons() const 0066 { 0067 static QMap<QString, ConditionIcons> const condval = setupConditionIconMappings(); 0068 return condval; 0069 } 0070 0071 QMap<QString, IonInterface::WindDirections> const &NOAAIon::windIcons() const 0072 { 0073 static QMap<QString, WindDirections> const wval = setupWindIconMappings(); 0074 return wval; 0075 } 0076 0077 // ctor, dtor 0078 NOAAIon::NOAAIon(QObject *parent) 0079 : IonInterface(parent) 0080 { 0081 // Get the real city XML URL so we can parse this 0082 getXMLSetup(); 0083 } 0084 0085 void NOAAIon::reset() 0086 { 0087 m_sourcesToReset = sources(); 0088 getXMLSetup(); 0089 } 0090 0091 NOAAIon::~NOAAIon() 0092 { 0093 // seems necessary to avoid crash 0094 removeAllSources(); 0095 } 0096 0097 QStringList NOAAIon::validate(const QString &source) const 0098 { 0099 QStringList placeList; 0100 QString station; 0101 QString sourceNormalized = source.toUpper(); 0102 0103 QHash<QString, NOAAIon::XMLMapInfo>::const_iterator it = m_places.constBegin(); 0104 // If the source name might look like a station ID, check these too and return the name 0105 bool checkState = source.count() == 2; 0106 0107 while (it != m_places.constEnd()) { 0108 if (checkState) { 0109 if (it.value().stateName == source) { 0110 placeList.append(QStringLiteral("place|").append(it.key())); 0111 } 0112 } else if (it.key().toUpper().contains(sourceNormalized)) { 0113 placeList.append(QStringLiteral("place|").append(it.key())); 0114 } else if (it.value().stationID == sourceNormalized) { 0115 station = QStringLiteral("place|").append(it.key()); 0116 } 0117 0118 ++it; 0119 } 0120 0121 placeList.sort(); 0122 if (!station.isEmpty()) { 0123 placeList.prepend(station); 0124 } 0125 0126 return placeList; 0127 } 0128 0129 bool NOAAIon::updateIonSource(const QString &source) 0130 { 0131 // We expect the applet to send the source in the following tokenization: 0132 // ionname:validate:place_name - Triggers validation of place 0133 // ionname:weather:place_name - Triggers receiving weather of place 0134 0135 QStringList sourceAction = source.split(QLatin1Char('|')); 0136 0137 // Guard: if the size of array is not 2 then we have bad data, return an error 0138 if (sourceAction.size() < 2) { 0139 setData(source, QStringLiteral("validate"), QStringLiteral("noaa|malformed")); 0140 return true; 0141 } 0142 0143 if (sourceAction[1] == QLatin1String("validate") && sourceAction.size() > 2) { 0144 QStringList result = validate(sourceAction[2]); 0145 0146 if (result.size() == 1) { 0147 setData(source, QStringLiteral("validate"), QStringLiteral("noaa|valid|single|").append(result.join(QLatin1Char('|')))); 0148 return true; 0149 } 0150 if (result.size() > 1) { 0151 setData(source, QStringLiteral("validate"), QStringLiteral("noaa|valid|multiple|").append(result.join(QLatin1Char('|')))); 0152 return true; 0153 } 0154 // result.size() == 0 0155 setData(source, QStringLiteral("validate"), QStringLiteral("noaa|invalid|single|").append(sourceAction[2])); 0156 return true; 0157 } 0158 0159 if (sourceAction[1] == QLatin1String("weather") && sourceAction.size() > 2) { 0160 getXMLData(source); 0161 return true; 0162 } 0163 0164 setData(source, QStringLiteral("validate"), QStringLiteral("noaa|malformed")); 0165 return true; 0166 } 0167 0168 KJob *NOAAIon::apiRequestJob(const QUrl &url, const QString &source) 0169 { 0170 KIO::TransferJob *getJob = KIO::get(url, KIO::Reload, KIO::HideProgressInfo); 0171 0172 m_jobData.insert(getJob, QByteArray()); 0173 if (!source.isEmpty()) { 0174 m_jobList.insert(getJob, source); 0175 } 0176 0177 qCDebug(IONENGINE_NOAA) << "Requesting URL:" << url; 0178 0179 connect(getJob, &KIO::TransferJob::data, this, [this](KIO::Job *job, const QByteArray &data) { 0180 if (data.isEmpty() || !m_jobData.contains(job)) { 0181 return; 0182 } 0183 m_jobData[job].append(data); 0184 }); 0185 0186 return getJob; 0187 } 0188 0189 // Parses city list and gets the correct city based on ID number 0190 void NOAAIon::getXMLSetup() 0191 { 0192 auto getJob = apiRequestJob(QUrl(QStringLiteral("https://www.weather.gov/data/current_obs/index.xml")), {}); 0193 connect(getJob, &KJob::result, this, &NOAAIon::setup_slotJobFinished); 0194 } 0195 0196 void NOAAIon::setup_slotJobFinished(KJob *job) 0197 { 0198 QXmlStreamReader reader = QXmlStreamReader(m_jobData.value(job)); 0199 if (!reader.atEnd()) { 0200 const bool success = readXMLSetup(reader); 0201 setInitialized(success); 0202 } 0203 0204 m_jobData.remove(job); 0205 0206 for (const QString &source : std::as_const(m_sourcesToReset)) { 0207 updateSourceEvent(source); 0208 } 0209 } 0210 0211 // Gets specific city XML data 0212 void NOAAIon::getXMLData(const QString &source) 0213 { 0214 for (const QString &fetching : std::as_const(m_jobList)) { 0215 if (fetching == source) { 0216 // already getting this source and awaiting the data 0217 return; 0218 } 0219 } 0220 0221 QString dataKey = source; 0222 dataKey.remove(QStringLiteral("noaa|weather|")); 0223 const QUrl url(m_places[dataKey].XMLurl); 0224 0225 // If this is empty we have no valid data, send out an error and abort. 0226 if (url.url().isEmpty()) { 0227 setData(source, QStringLiteral("validate"), QStringLiteral("noaa|malformed")); 0228 return; 0229 } 0230 0231 auto getJob = apiRequestJob(url, source); 0232 connect(getJob, &KJob::result, this, &NOAAIon::slotJobFinished); 0233 } 0234 0235 void NOAAIon::slotJobFinished(KJob *job) 0236 { 0237 // Dual use method, if we're fetching location data to parse we need to do this first 0238 const QString source(m_jobList.value(job)); 0239 removeAllData(source); 0240 0241 QXmlStreamReader reader = QXmlStreamReader(m_jobData.value(job)); 0242 readXMLData(source, reader); 0243 0244 // Now that we have the longitude and latitude, fetch the seven day forecast 0245 // and the alerts 0246 getForecast(source); 0247 getAlerts(source); 0248 0249 m_jobList.remove(job); 0250 m_jobData.remove(job); 0251 } 0252 0253 void NOAAIon::parseFloat(float &value, const QString &string) 0254 { 0255 bool ok = false; 0256 const float result = string.toFloat(&ok); 0257 if (ok) { 0258 value = result; 0259 } 0260 } 0261 0262 void NOAAIon::parseFloat(float &value, QXmlStreamReader &xml) 0263 { 0264 bool ok = false; 0265 const float result = xml.readElementText().toFloat(&ok); 0266 if (ok) { 0267 value = result; 0268 } 0269 } 0270 0271 void NOAAIon::parseDouble(double &value, QXmlStreamReader &xml) 0272 { 0273 bool ok = false; 0274 const double result = xml.readElementText().toDouble(&ok); 0275 if (ok) { 0276 value = result; 0277 } 0278 } 0279 0280 void NOAAIon::parseStationID(QXmlStreamReader &xml) 0281 { 0282 QString state; 0283 QString stationName; 0284 QString stationID; 0285 QString xmlurl; 0286 0287 while (!xml.atEnd()) { 0288 xml.readNext(); 0289 0290 const auto elementName = xml.name(); 0291 0292 if (xml.isEndElement() && elementName == QLatin1String("station")) { 0293 if (!xmlurl.isEmpty()) { 0294 NOAAIon::XMLMapInfo info; 0295 info.stateName = state; 0296 info.stationName = stationName; 0297 info.stationID = stationID; 0298 info.XMLurl = xmlurl; 0299 0300 QString tmp = stationName + QLatin1String(", ") + state; // Build the key name. 0301 m_places[tmp] = info; 0302 } 0303 break; 0304 } 0305 0306 if (xml.isStartElement()) { 0307 if (elementName == QLatin1String("station_id")) { 0308 stationID = xml.readElementText(); 0309 } else if (elementName == QLatin1String("state")) { 0310 state = xml.readElementText(); 0311 } else if (elementName == QLatin1String("station_name")) { 0312 stationName = xml.readElementText(); 0313 } else if (elementName == QLatin1String("xml_url")) { 0314 xmlurl = xml.readElementText().replace(QStringLiteral("http://"), QStringLiteral("http://www.")); 0315 } else { 0316 parseUnknownElement(xml); 0317 } 0318 } 0319 } 0320 } 0321 0322 void NOAAIon::parseStationList(QXmlStreamReader &xml) 0323 { 0324 while (!xml.atEnd()) { 0325 xml.readNext(); 0326 0327 if (xml.isEndElement()) { 0328 break; 0329 } 0330 0331 if (xml.isStartElement()) { 0332 if (xml.name() == QLatin1String("station")) { 0333 parseStationID(xml); 0334 } else { 0335 parseUnknownElement(xml); 0336 } 0337 } 0338 } 0339 } 0340 0341 // Parse the city list and store into a QMap 0342 bool NOAAIon::readXMLSetup(QXmlStreamReader &xml) 0343 { 0344 bool success = false; 0345 while (!xml.atEnd()) { 0346 xml.readNext(); 0347 0348 if (xml.isStartElement()) { 0349 if (xml.name() == QLatin1String("wx_station_index")) { 0350 parseStationList(xml); 0351 success = true; 0352 } 0353 } 0354 } 0355 return (!xml.error() && success); 0356 } 0357 0358 void NOAAIon::parseWeatherSite(WeatherData &data, QXmlStreamReader &xml) 0359 { 0360 data.temperature_C = qQNaN(); 0361 data.temperature_F = qQNaN(); 0362 data.dewpoint_C = qQNaN(); 0363 data.dewpoint_F = qQNaN(); 0364 data.weather = QStringLiteral("N/A"); 0365 data.stationID = i18n("N/A"); 0366 data.pressure = qQNaN(); 0367 data.visibility = qQNaN(); 0368 data.humidity = qQNaN(); 0369 data.windSpeed = qQNaN(); 0370 data.windGust = qQNaN(); 0371 data.windchill_F = qQNaN(); 0372 data.windchill_C = qQNaN(); 0373 data.heatindex_F = qQNaN(); 0374 data.heatindex_C = qQNaN(); 0375 0376 while (!xml.atEnd()) { 0377 xml.readNext(); 0378 0379 const auto elementName = xml.name(); 0380 0381 if (xml.isStartElement()) { 0382 if (elementName == QLatin1String("location")) { 0383 data.locationName = xml.readElementText(); 0384 } else if (elementName == QLatin1String("station_id")) { 0385 data.stationID = xml.readElementText(); 0386 } else if (elementName == QLatin1String("latitude")) { 0387 parseDouble(data.stationLatitude, xml); 0388 } else if (elementName == QLatin1String("longitude")) { 0389 parseDouble(data.stationLongitude, xml); 0390 } else if (elementName == QLatin1String("observation_time_rfc822")) { 0391 data.observationDateTime = QDateTime::fromString(xml.readElementText(), Qt::RFC2822Date); 0392 } else if (elementName == QLatin1String("observation_time")) { 0393 data.observationTime = xml.readElementText(); 0394 QStringList tmpDateStr = data.observationTime.split(QLatin1Char(' ')); 0395 data.observationTime = QStringLiteral("%1 %2").arg(tmpDateStr[6], tmpDateStr[7]); 0396 } else if (elementName == QLatin1String("weather")) { 0397 const QString weather = xml.readElementText(); 0398 data.weather = (weather.isEmpty() || weather == QLatin1String("NA")) ? QStringLiteral("N/A") : weather; 0399 // Pick which icon set depending on period of day 0400 } else if (elementName == QLatin1String("temp_f")) { 0401 parseFloat(data.temperature_F, xml); 0402 } else if (elementName == QLatin1String("temp_c")) { 0403 parseFloat(data.temperature_C, xml); 0404 } else if (elementName == QLatin1String("relative_humidity")) { 0405 parseFloat(data.humidity, xml); 0406 } else if (elementName == QLatin1String("wind_dir")) { 0407 data.windDirection = xml.readElementText(); 0408 } else if (elementName == QLatin1String("wind_mph")) { 0409 const QString windSpeed = xml.readElementText(); 0410 if (windSpeed == QLatin1String("NA")) { 0411 data.windSpeed = 0.0; 0412 } else { 0413 parseFloat(data.windSpeed, windSpeed); 0414 } 0415 } else if (elementName == QLatin1String("wind_gust_mph")) { 0416 const QString windGust = xml.readElementText(); 0417 if (windGust == QLatin1String("NA") || windGust == QLatin1String("N/A")) { 0418 data.windGust = 0.0; 0419 } else { 0420 parseFloat(data.windGust, windGust); 0421 } 0422 } else if (elementName == QLatin1String("pressure_in")) { 0423 parseFloat(data.pressure, xml); 0424 } else if (elementName == QLatin1String("dewpoint_f")) { 0425 parseFloat(data.dewpoint_F, xml); 0426 } else if (elementName == QLatin1String("dewpoint_c")) { 0427 parseFloat(data.dewpoint_C, xml); 0428 } else if (elementName == QLatin1String("heat_index_f")) { 0429 parseFloat(data.heatindex_F, xml); 0430 } else if (elementName == QLatin1String("heat_index_c")) { 0431 parseFloat(data.heatindex_C, xml); 0432 } else if (elementName == QLatin1String("windchill_f")) { 0433 parseFloat(data.windchill_F, xml); 0434 } else if (elementName == QLatin1String("windchill_c")) { 0435 parseFloat(data.windchill_C, xml); 0436 } else if (elementName == QLatin1String("visibility_mi")) { 0437 parseFloat(data.visibility, xml); 0438 } else { 0439 parseUnknownElement(xml); 0440 } 0441 } 0442 } 0443 } 0444 0445 // Parse Weather data main loop, from here we have to descend into each tag pair 0446 bool NOAAIon::readXMLData(const QString &source, QXmlStreamReader &xml) 0447 { 0448 WeatherData data; 0449 data.isForecastsDataPending = true; 0450 0451 while (!xml.atEnd()) { 0452 xml.readNext(); 0453 0454 if (xml.isEndElement()) { 0455 break; 0456 } 0457 0458 if (xml.isStartElement()) { 0459 if (xml.name() == QLatin1String("current_observation")) { 0460 parseWeatherSite(data, xml); 0461 } else { 0462 parseUnknownElement(xml); 0463 } 0464 } 0465 } 0466 0467 bool solarDataSourceNeedsConnect = false; 0468 Plasma5Support::DataEngine *timeEngine = dataEngine(QStringLiteral("time")); 0469 if (timeEngine) { 0470 const bool canCalculateElevation = (data.observationDateTime.isValid() && (!qIsNaN(data.stationLatitude) && !qIsNaN(data.stationLongitude))); 0471 if (canCalculateElevation) { 0472 data.solarDataTimeEngineSourceName = QStringLiteral("%1|Solar|Latitude=%2|Longitude=%3|DateTime=%4") 0473 .arg(QString::fromUtf8(data.observationDateTime.timeZone().id())) 0474 .arg(data.stationLatitude) 0475 .arg(data.stationLongitude) 0476 .arg(data.observationDateTime.toString(Qt::ISODate)); 0477 solarDataSourceNeedsConnect = true; 0478 } 0479 0480 // check any previous data 0481 const auto it = m_weatherData.constFind(source); 0482 if (it != m_weatherData.constEnd()) { 0483 const QString &oldSolarDataTimeEngineSource = it.value().solarDataTimeEngineSourceName; 0484 0485 if (oldSolarDataTimeEngineSource == data.solarDataTimeEngineSourceName) { 0486 // can reuse elevation source (if any), copy over data 0487 data.isNight = it.value().isNight; 0488 solarDataSourceNeedsConnect = false; 0489 } else if (!oldSolarDataTimeEngineSource.isEmpty()) { 0490 // drop old elevation source 0491 timeEngine->disconnectSource(oldSolarDataTimeEngineSource, this); 0492 } 0493 } 0494 } 0495 0496 m_weatherData[source] = data; 0497 0498 // connect only after m_weatherData has the data, so the instant data push handling can see it 0499 if (solarDataSourceNeedsConnect) { 0500 data.isSolarDataPending = true; 0501 timeEngine->connectSource(data.solarDataTimeEngineSourceName, this); 0502 } 0503 0504 return !xml.error(); 0505 } 0506 0507 // handle when no XML tag is found 0508 void NOAAIon::parseUnknownElement(QXmlStreamReader &xml) const 0509 { 0510 while (!xml.atEnd()) { 0511 xml.readNext(); 0512 0513 if (xml.isEndElement()) { 0514 break; 0515 } 0516 0517 if (xml.isStartElement()) { 0518 parseUnknownElement(xml); 0519 } 0520 } 0521 } 0522 0523 void NOAAIon::updateWeather(const QString &source) 0524 { 0525 const WeatherData &weatherData = m_weatherData[source]; 0526 0527 if (weatherData.isForecastsDataPending || weatherData.isSolarDataPending) { 0528 return; 0529 } 0530 0531 Plasma5Support::DataEngine::Data data; 0532 0533 data.insert(QStringLiteral("Place"), weatherData.locationName); 0534 data.insert(QStringLiteral("Station"), weatherData.stationID); 0535 0536 const bool stationCoordValid = (!qIsNaN(weatherData.stationLatitude) && !qIsNaN(weatherData.stationLongitude)); 0537 0538 if (stationCoordValid) { 0539 data.insert(QStringLiteral("Latitude"), weatherData.stationLatitude); 0540 data.insert(QStringLiteral("Longitude"), weatherData.stationLongitude); 0541 } 0542 0543 // Real weather - Current conditions 0544 if (weatherData.observationDateTime.isValid()) { 0545 data.insert(QStringLiteral("Observation Timestamp"), weatherData.observationDateTime); 0546 } 0547 0548 data.insert(QStringLiteral("Observation Period"), weatherData.observationTime); 0549 0550 const QString conditionI18n = weatherData.weather == QLatin1String("N/A") ? i18n("N/A") : i18nc("weather condition", weatherData.weather.toUtf8().data()); 0551 0552 data.insert(QStringLiteral("Current Conditions"), conditionI18n); 0553 qCDebug(IONENGINE_NOAA) << "i18n condition string: " << qPrintable(conditionI18n); 0554 0555 const QString weather = weatherData.weather.toLower(); 0556 ConditionIcons condition = getConditionIcon(weather, !weatherData.isNight); 0557 data.insert(QStringLiteral("Condition Icon"), getWeatherIcon(condition)); 0558 0559 if (!qIsNaN(weatherData.temperature_F)) { 0560 data.insert(QStringLiteral("Temperature"), weatherData.temperature_F); 0561 } 0562 0563 // Used for all temperatures 0564 data.insert(QStringLiteral("Temperature Unit"), KUnitConversion::Fahrenheit); 0565 0566 if (!qIsNaN(weatherData.windchill_F)) { 0567 data.insert(QStringLiteral("Windchill"), weatherData.windchill_F); 0568 } 0569 0570 if (!qIsNaN(weatherData.heatindex_F)) { 0571 data.insert(QStringLiteral("Heat Index"), weatherData.heatindex_F); 0572 } 0573 0574 if (!qIsNaN(weatherData.dewpoint_F)) { 0575 data.insert(QStringLiteral("Dewpoint"), weatherData.dewpoint_F); 0576 } 0577 0578 if (!qIsNaN(weatherData.pressure)) { 0579 data.insert(QStringLiteral("Pressure"), weatherData.pressure); 0580 data.insert(QStringLiteral("Pressure Unit"), KUnitConversion::InchesOfMercury); 0581 } 0582 0583 if (!qIsNaN(weatherData.visibility)) { 0584 data.insert(QStringLiteral("Visibility"), weatherData.visibility); 0585 data.insert(QStringLiteral("Visibility Unit"), KUnitConversion::Mile); 0586 } 0587 0588 if (!qIsNaN(weatherData.humidity)) { 0589 data.insert(QStringLiteral("Humidity"), weatherData.humidity); 0590 data.insert(QStringLiteral("Humidity Unit"), KUnitConversion::Percent); 0591 } 0592 0593 if (!qIsNaN(weatherData.windSpeed)) { 0594 data.insert(QStringLiteral("Wind Speed"), weatherData.windSpeed); 0595 } 0596 0597 if (!qIsNaN(weatherData.windSpeed) || !qIsNaN(weatherData.windGust)) { 0598 data.insert(QStringLiteral("Wind Speed Unit"), KUnitConversion::MilePerHour); 0599 } 0600 0601 if (!qIsNaN(weatherData.windGust)) { 0602 data.insert(QStringLiteral("Wind Gust"), weatherData.windGust); 0603 } 0604 0605 if (!qIsNaN(weatherData.windSpeed) && static_cast<int>(weatherData.windSpeed) == 0) { 0606 data.insert(QStringLiteral("Wind Direction"), QStringLiteral("VR")); // Variable/calm 0607 } else if (!weatherData.windDirection.isEmpty()) { 0608 data.insert(QStringLiteral("Wind Direction"), getWindDirectionIcon(windIcons(), weatherData.windDirection.toLower())); 0609 } 0610 0611 // Set number of forecasts per day/night supported 0612 data.insert(QStringLiteral("Total Weather Days"), weatherData.forecasts.size()); 0613 0614 int i = 0; 0615 for (const WeatherData::Forecast &forecast : weatherData.forecasts) { 0616 ConditionIcons icon = getConditionIcon(forecast.summary.toLower(), true); 0617 QString iconName = getWeatherIcon(icon); 0618 0619 /* Sometimes the forecast for the later days is unavailable, if so skip remianing days 0620 * since their forecast data is probably unavailable. 0621 */ 0622 if (forecast.low.isEmpty() || forecast.high.isEmpty()) { 0623 break; 0624 } 0625 0626 // Get the short day name for the forecast 0627 data.insert(QStringLiteral("Short Forecast Day %1").arg(i), 0628 QStringLiteral("%1|%2|%3|%4|%5|%6") 0629 .arg(forecast.day, iconName, i18nc("weather forecast", forecast.summary.toUtf8().data()), forecast.high, forecast.low) 0630 .arg(forecast.precipitation)); 0631 ++i; 0632 } 0633 0634 data.insert(u"Total Warnings Issued"_s, weatherData.alerts.size()); 0635 int alertNum = 0; 0636 for (const WeatherData::Alert &alert : weatherData.alerts) { 0637 // TODO: Add a Headline parameter to the engine and the applet 0638 data.insert(u"Warning Description %1"_s.arg(alertNum), u"<p><b>%1</b></p>%2"_s.arg(alert.headline, alert.description)); 0639 data.insert(u"Warning Timestamp %1"_s.arg(alertNum), QLocale().toString(alert.startTime, QLocale::ShortFormat)); 0640 data.insert(u"Warning Priority %1"_s.arg(alertNum), alert.priority); 0641 ++alertNum; 0642 } 0643 0644 data.insert(QStringLiteral("Credit"), i18nc("credit line, keep string short)", "Data from NOAA National\302\240Weather\302\240Service")); 0645 0646 setData(source, data); 0647 } 0648 0649 /** 0650 * Determine the condition icon based on the list of possible NOAA weather conditions as defined at 0651 * <https://www.weather.gov/xml/current_obs/weather.php> and 0652 * <https://graphical.weather.gov/xml/mdl/XML/Design/MDL_XML_Design.htm#_Toc141760782> 0653 * Since the number of NOAA weather conditions need to be fitted into the narowly defined groups in IonInterface::ConditionIcons, we 0654 * try to group the NOAA conditions as best as we can based on their priorities/severity. 0655 * TODO: summaries "Hot" & "Cold" have no proper matching entry in ConditionIcons, consider extending it 0656 */ 0657 IonInterface::ConditionIcons NOAAIon::getConditionIcon(const QString &weather, bool isDayTime) const 0658 { 0659 IonInterface::ConditionIcons result; 0660 // Consider any type of storm, tornado or funnel to be a thunderstorm. 0661 if (weather.contains(QLatin1String("thunderstorm")) || weather.contains(QLatin1String("funnel")) || weather.contains(QLatin1String("tornado")) 0662 || weather.contains(QLatin1String("storm")) || weather.contains(QLatin1String("tstms"))) { 0663 if (weather.contains(QLatin1String("vicinity")) || weather.contains(QLatin1String("chance"))) { 0664 result = isDayTime ? IonInterface::ChanceThunderstormDay : IonInterface::ChanceThunderstormNight; 0665 } else { 0666 result = IonInterface::Thunderstorm; 0667 } 0668 0669 } else if (weather.contains(QLatin1String("pellets")) || weather.contains(QLatin1String("crystals")) || weather.contains(QLatin1String("hail"))) { 0670 result = IonInterface::Hail; 0671 0672 } else if (((weather.contains(QLatin1String("rain")) || weather.contains(QLatin1String("drizzle")) || weather.contains(QLatin1String("showers"))) 0673 && weather.contains(QLatin1String("snow"))) 0674 || weather.contains(QLatin1String("wintry mix"))) { 0675 result = IonInterface::RainSnow; 0676 0677 } else if (weather.contains(QLatin1String("flurries"))) { 0678 result = IonInterface::Flurries; 0679 0680 } else if (weather.contains(QLatin1String("snow")) && weather.contains(QLatin1String("light"))) { 0681 result = IonInterface::LightSnow; 0682 0683 } else if (weather.contains(QLatin1String("snow"))) { 0684 if (weather.contains(QLatin1String("vicinity")) || weather.contains(QLatin1String("chance"))) { 0685 result = isDayTime ? IonInterface::ChanceSnowDay : IonInterface::ChanceSnowNight; 0686 } else { 0687 result = IonInterface::Snow; 0688 } 0689 0690 } else if (weather.contains(QLatin1String("freezing rain"))) { 0691 result = IonInterface::FreezingRain; 0692 0693 } else if (weather.contains(QLatin1String("freezing drizzle"))) { 0694 result = IonInterface::FreezingDrizzle; 0695 0696 } else if (weather.contains(QLatin1String("cold"))) { 0697 // temperature condition has not hint about air ingredients, so let's assume chance of snow 0698 result = isDayTime ? IonInterface::ChanceSnowDay : IonInterface::ChanceSnowNight; 0699 0700 } else if (weather.contains(QLatin1String("showers"))) { 0701 if (weather.contains(QLatin1String("vicinity")) || weather.contains(QLatin1String("chance"))) { 0702 result = isDayTime ? IonInterface::ChanceShowersDay : IonInterface::ChanceShowersNight; 0703 } else { 0704 result = IonInterface::Showers; 0705 } 0706 } else if (weather.contains(QLatin1String("light rain")) || weather.contains(QLatin1String("drizzle"))) { 0707 result = IonInterface::LightRain; 0708 0709 } else if (weather.contains(QLatin1String("rain"))) { 0710 result = IonInterface::Rain; 0711 0712 } else if (weather.contains(QLatin1String("few clouds")) || weather.contains(QLatin1String("mostly sunny")) 0713 || weather.contains(QLatin1String("mostly clear")) || weather.contains(QLatin1String("increasing clouds")) 0714 || weather.contains(QLatin1String("becoming cloudy")) || weather.contains(QLatin1String("clearing")) 0715 || weather.contains(QLatin1String("decreasing clouds")) || weather.contains(QLatin1String("becoming sunny"))) { 0716 if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) { 0717 result = isDayTime ? IonInterface::FewCloudsWindyDay : IonInterface::FewCloudsWindyNight; 0718 } else { 0719 result = isDayTime ? IonInterface::FewCloudsDay : IonInterface::FewCloudsNight; 0720 } 0721 0722 } else if (weather.contains(QLatin1String("partly cloudy")) || weather.contains(QLatin1String("partly sunny")) 0723 || weather.contains(QLatin1String("partly clear"))) { 0724 if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) { 0725 result = isDayTime ? IonInterface::PartlyCloudyWindyDay : IonInterface::PartlyCloudyWindyNight; 0726 } else { 0727 result = isDayTime ? IonInterface::PartlyCloudyDay : IonInterface::PartlyCloudyNight; 0728 } 0729 0730 } else if (weather.contains(QLatin1String("overcast")) || weather.contains(QLatin1String("cloudy"))) { 0731 if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) { 0732 result = IonInterface::OvercastWindy; 0733 } else { 0734 result = IonInterface::Overcast; 0735 } 0736 0737 } else if (weather.contains(QLatin1String("haze")) || weather.contains(QLatin1String("smoke")) || weather.contains(QLatin1String("dust")) 0738 || weather.contains(QLatin1String("sand"))) { 0739 result = IonInterface::Haze; 0740 0741 } else if (weather.contains(QLatin1String("fair")) || weather.contains(QLatin1String("clear")) || weather.contains(QLatin1String("sunny"))) { 0742 if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) { 0743 result = isDayTime ? IonInterface::ClearWindyDay : IonInterface::ClearWindyNight; 0744 } else { 0745 result = isDayTime ? IonInterface::ClearDay : IonInterface::ClearNight; 0746 } 0747 0748 } else if (weather.contains(QLatin1String("fog"))) { 0749 result = IonInterface::Mist; 0750 0751 } else if (weather.contains(QLatin1String("hot"))) { 0752 // temperature condition has not hint about air ingredients, so let's assume the sky is clear when it is hot 0753 if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) { 0754 result = isDayTime ? IonInterface::ClearWindyDay : IonInterface::ClearWindyNight; 0755 } else { 0756 result = isDayTime ? IonInterface::ClearDay : IonInterface::ClearNight; 0757 } 0758 0759 } else if (weather.contains(QLatin1String("breezy")) || weather.contains(QLatin1String("wind")) || weather.contains(QLatin1String("gust"))) { 0760 // Assume a clear sky when it's windy but no clouds have been mentioned 0761 result = isDayTime ? IonInterface::ClearWindyDay : IonInterface::ClearWindyNight; 0762 } else { 0763 result = IonInterface::NotAvailable; 0764 } 0765 0766 return result; 0767 } 0768 0769 void NOAAIon::getForecast(const QString &source) 0770 { 0771 const double lat = m_weatherData[source].stationLatitude; 0772 const double lon = m_weatherData[source].stationLongitude; 0773 if (qIsNaN(lat) || qIsNaN(lon)) { 0774 return; 0775 } 0776 0777 /* Assuming that we have the latitude and longitude data at this point, get the 7-day 0778 * forecast. 0779 */ 0780 const QUrl url(QLatin1String("https://graphical.weather.gov/xml/sample_products/browser_interface/" 0781 "ndfdBrowserClientByDay.php?lat=") 0782 + QString::number(lat) + QLatin1String("&lon=") + QString::number(lon) + QLatin1String("&format=24+hourly&numDays=7")); 0783 0784 auto getJob = apiRequestJob(url, source); 0785 connect(getJob, &KJob::result, this, &NOAAIon::forecast_slotJobFinished); 0786 } 0787 0788 void NOAAIon::forecast_slotJobFinished(KJob *job) 0789 { 0790 QXmlStreamReader reader = QXmlStreamReader(m_jobData.value(job)); 0791 const QString source = m_jobList.value(job); 0792 0793 if (!reader.atEnd()) { 0794 readForecast(source, reader); 0795 updateWeather(source); 0796 } 0797 0798 m_jobList.remove(job); 0799 m_jobData.remove(job); 0800 0801 if (m_sourcesToReset.contains(source)) { 0802 m_sourcesToReset.removeAll(source); 0803 0804 // so the weather engine updates it's data 0805 forceImmediateUpdateOfAllVisualizations(); 0806 0807 // update the clients of our engine 0808 Q_EMIT forceUpdate(this, source); 0809 } 0810 } 0811 0812 void NOAAIon::readForecast(const QString &source, QXmlStreamReader &xml) 0813 { 0814 WeatherData &weatherData = m_weatherData[source]; 0815 QList<WeatherData::Forecast> &forecasts = weatherData.forecasts; 0816 0817 // Clear the current forecasts 0818 forecasts.clear(); 0819 0820 while (!xml.atEnd()) { 0821 xml.readNext(); 0822 0823 if (xml.isStartElement()) { 0824 /* Read all reported days from <time-layout>. We check for existence of a specific 0825 * <layout-key> which indicates the separate day listings. The schema defines it to be 0826 * the first item before the day listings. 0827 */ 0828 if (xml.name() == QLatin1String("layout-key") && xml.readElementText() == QLatin1String("k-p24h-n7-1")) { 0829 // Read days until we get to end of parent (<time-layout>)tag 0830 while (!(xml.isEndElement() && xml.name() == QLatin1String("time-layout"))) { 0831 xml.readNext(); 0832 0833 if (xml.name() == QLatin1String("start-valid-time")) { 0834 QString data = xml.readElementText(); 0835 QDateTime date = QDateTime::fromString(data, Qt::ISODate); 0836 0837 WeatherData::Forecast forecast; 0838 forecast.day = QLocale().toString(date.date().day()); 0839 forecasts.append(forecast); 0840 // qCDebug(IONENGINE_NOAA) << forecast.day; 0841 } 0842 } 0843 0844 } else if (xml.name() == QLatin1String("temperature") && xml.attributes().value(QStringLiteral("type")) == QLatin1String("maximum")) { 0845 // Read max temps until we get to end tag 0846 int i = 0; 0847 while (!(xml.isEndElement() && xml.name() == QLatin1String("temperature")) && i < forecasts.count()) { 0848 xml.readNext(); 0849 0850 if (xml.name() == QLatin1String("value")) { 0851 forecasts[i].high = xml.readElementText(); 0852 // qCDebug(IONENGINE_NOAA) << forecasts[i].high; 0853 i++; 0854 } 0855 } 0856 } else if (xml.name() == QLatin1String("temperature") && xml.attributes().value(QStringLiteral("type")) == QLatin1String("minimum")) { 0857 // Read min temps until we get to end tag 0858 int i = 0; 0859 while (!(xml.isEndElement() && xml.name() == QLatin1String("temperature")) && i < forecasts.count()) { 0860 xml.readNext(); 0861 0862 if (xml.name() == QLatin1String("value")) { 0863 forecasts[i].low = xml.readElementText(); 0864 // qCDebug(IONENGINE_NOAA) << forecasts[i].low; 0865 i++; 0866 } 0867 } 0868 } else if (xml.name() == QLatin1String("probability-of-precipitation")) { 0869 // Precipitation is usually provided in 12-hour periods instead of 24. 0870 int periodHours = xml.attributes().value(QStringLiteral("type")).split(QLatin1Char(' '))[0].toInt(); 0871 if (!periodHours) { 0872 periodHours = 24; 0873 } 0874 0875 int i = 0; 0876 int hours = 0; 0877 while (!(xml.isEndElement() && xml.name() == QLatin1String("weather")) && i < forecasts.count()) { 0878 xml.readNext(); 0879 if (xml.name() != QLatin1String("value")) { 0880 continue; 0881 } 0882 0883 const int probability = xml.readElementText().toInt(); 0884 forecasts[i].precipitation = qMax(probability, forecasts[i].precipitation); 0885 hours += periodHours; 0886 0887 if (hours >= 24) { 0888 hours = 0; 0889 i++; 0890 } 0891 } 0892 } else if (xml.name() == QLatin1String("weather")) { 0893 // Read weather conditions until we get to end tag 0894 int i = 0; 0895 while (!(xml.isEndElement() && xml.name() == QLatin1String("weather")) && i < forecasts.count()) { 0896 xml.readNext(); 0897 0898 if (xml.name() == QLatin1String("weather-conditions") && xml.isStartElement()) { 0899 QString summary = xml.attributes().value(QStringLiteral("weather-summary")).toString(); 0900 forecasts[i].summary = summary; 0901 // qCDebug(IONENGINE_NOAA) << forecasts[i].summary; 0902 qCDebug(IONENGINE_NOAA) << "i18n summary string: " << i18nc("weather forecast", forecasts[i].summary.toUtf8().data()); 0903 i++; 0904 } 0905 } 0906 } 0907 } 0908 } 0909 0910 weatherData.isForecastsDataPending = false; 0911 } 0912 0913 void NOAAIon::getCountyID(const QString &source) 0914 { 0915 const double lat = m_weatherData[source].stationLatitude; 0916 const double lon = m_weatherData[source].stationLongitude; 0917 if (qIsNaN(lat) || qIsNaN(lon)) { 0918 return; 0919 } 0920 0921 const QUrl url(QStringLiteral("https://api.weather.gov/points/%1,%2").arg(lat).arg(lon)); 0922 0923 auto getJob = apiRequestJob(url, source); 0924 connect(getJob, &KJob::result, this, &NOAAIon::county_slotJobFinished); 0925 } 0926 0927 void NOAAIon::county_slotJobFinished(KJob *job) 0928 { 0929 const QString source = m_jobList.value(job); 0930 0931 if (!job->error()) { 0932 QJsonDocument doc = QJsonDocument::fromJson(m_jobData.value(job)); 0933 if (!doc.isEmpty()) { 0934 readCountyID(source, doc); 0935 } 0936 } else { 0937 qCWarning(IONENGINE_NOAA) << "Error getting coordinates info" << job->errorText(); 0938 } 0939 0940 m_jobList.remove(job); 0941 m_jobData.remove(job); 0942 } 0943 0944 void NOAAIon::readCountyID(const QString &source, const QJsonDocument &doc) 0945 { 0946 if (doc.isEmpty()) { 0947 return; 0948 } 0949 0950 const auto properties = doc[QStringLiteral("properties")]; 0951 if (!properties.isObject()) { 0952 return; 0953 } 0954 0955 const QString countyUrl = properties[QStringLiteral("county")].toString(); 0956 const QString countyID = countyUrl.split(QLatin1Char('/')).last(); 0957 m_weatherData[source].countyID = countyID; 0958 0959 getAlerts(source); 0960 } 0961 0962 void NOAAIon::getAlerts(const QString &source) 0963 { 0964 // We get the alerts by county because it includes all the events. 0965 // Using the forecast zone would miss some of them, and the lat/lon point 0966 // corresponds to the weather station, not necessarily the user location 0967 const QString countyID = m_weatherData[source].countyID; 0968 if (countyID.isEmpty()) { 0969 getCountyID(source); 0970 return; 0971 } 0972 0973 const QUrl url(QStringLiteral("https://api.weather.gov/alerts/active?zone=%1").arg(countyID)); 0974 0975 auto getJob = apiRequestJob(url, source); 0976 connect(getJob, &KJob::result, this, &NOAAIon::alerts_slotJobFinished); 0977 } 0978 0979 void NOAAIon::alerts_slotJobFinished(KJob *job) 0980 { 0981 const QString source = m_jobList.value(job); 0982 0983 if (!job->error()) { 0984 QJsonDocument doc = QJsonDocument::fromJson(m_jobData.value(job)); 0985 if (!doc.isEmpty()) { 0986 readAlerts(source, doc); 0987 } 0988 } else { 0989 qCWarning(IONENGINE_NOAA) << "Error getting alerts info" << job->errorText(); 0990 } 0991 0992 m_jobList.remove(job); 0993 m_jobData.remove(job); 0994 } 0995 0996 // Helpers to parse warnings 0997 int mapSeverity(const QString &severity) 0998 { 0999 if (severity == "Extreme"_L1) { 1000 return 4; 1001 } else if (severity == "Severe"_L1) { 1002 return 3; 1003 } else if (severity == "Moderate"_L1) { 1004 return 2; 1005 } else if (severity == "Minor"_L1) { 1006 return 1; 1007 } else { // severity: "Unknown" 1008 return 0; 1009 } 1010 }; 1011 1012 QString formatAlertDescription(QString description) 1013 { 1014 /* -- Example of an alert's description -- 1015 * WHAT...Minor flooding is occurring and minor flooding is forecast.\n 1016 \n 1017 * WHERE...Santee River near Jamestown.\n 1018 \n 1019 * WHEN...Until further notice.\n 1020 \n 1021 * IMPACTS...At 12.0 feet, several dirt logging roads are impassable.\n 1022 \n 1023 * ADDITIONAL DETAILS...\n 1024 - At 930 PM EST Tuesday, the stage was 11.4 feet.\n 1025 - Forecast...The river is expected to rise to a crest of 11.7\n 1026 feet Thursday evening.\n 1027 - Flood stage is 10.0 feet.\n 1028 */ 1029 description.replace("* "_L1, "<b>"_L1); 1030 description.replace("..."_L1, ":</b> "_L1); 1031 description.replace("\n\n"_L1, "<br/>"_L1); 1032 description.replace("\n-"_L1, "<br/>-"_L1); 1033 return description; 1034 } 1035 1036 void NOAAIon::readAlerts(const QString &source, const QJsonDocument &doc) 1037 { 1038 if (doc.isEmpty()) { 1039 return; 1040 } 1041 1042 auto &alerts = m_weatherData[source].alerts; 1043 alerts.clear(); 1044 1045 const auto features = doc[u"features"_s].toArray(); 1046 qCDebug(IONENGINE_NOAA) << u"Received %1 alert/s"_s.arg(features.count()); 1047 1048 for (const auto &alertInfo : features) { 1049 const auto properties = alertInfo[u"properties"_s]; 1050 if (!properties.isObject()) { 1051 continue; 1052 } 1053 1054 auto alert = WeatherData::Alert(); 1055 alert.startTime = QDateTime::fromString(properties[u"onset"_s].toString(), Qt::ISODate); 1056 alert.endTime = QDateTime::fromString(properties[u"ends"_s].toString(), Qt::ISODate); 1057 alert.priority = mapSeverity(properties[u"severity"_s].toString()); 1058 alert.headline = properties[u"parameters"_s][u"NWSheadline"_s][0].toString(); 1059 alert.description = formatAlertDescription(properties[u"description"_s].toString()); 1060 1061 alerts << alert; 1062 } 1063 1064 // Sort by higher priority and the lower start time 1065 std::sort(alerts.begin(), alerts.end(), [](auto a, auto b) { 1066 if (a.priority != b.priority) { 1067 return a.priority > b.priority; 1068 } 1069 return a.startTime < b.startTime; 1070 }); 1071 1072 updateWeather(source); 1073 forceImmediateUpdateOfAllVisualizations(); 1074 Q_EMIT forceUpdate(this, source); 1075 } 1076 1077 void NOAAIon::dataUpdated(const QString &sourceName, const Plasma5Support::DataEngine::Data &data) 1078 { 1079 const bool isNight = (data.value(QStringLiteral("Corrected Elevation")).toDouble() < 0.0); 1080 1081 for (auto end = m_weatherData.end(), it = m_weatherData.begin(); it != end; ++it) { 1082 auto &weatherData = it.value(); 1083 if (weatherData.solarDataTimeEngineSourceName == sourceName) { 1084 weatherData.isNight = isNight; 1085 weatherData.isSolarDataPending = false; 1086 updateWeather(it.key()); 1087 } 1088 } 1089 } 1090 1091 K_PLUGIN_CLASS_WITH_JSON(NOAAIon, "ion-noaa.json") 1092 1093 #include "ion_noaa.moc"