diff --git a/src/app/timelinemodel.cpp b/src/app/timelinemodel.cpp index f03f915..36b0344 100644 --- a/src/app/timelinemodel.cpp +++ b/src/app/timelinemodel.cpp @@ -1,618 +1,644 @@ /* Copyright (C) 2018 Volker Krause This program is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ #include "timelinemodel.h" #include "countryinformation.h" #include "pkpassmanager.h" #include "reservationmanager.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace KItinerary; static bool needsSplitting(const QVariant &res) { return JsonLd::isA(res) || JsonLd::isA(res); } static QDateTime relevantDateTime(const QVariant &res, TimelineModel::RangeType range) { if (range == TimelineModel::RangeBegin || range == TimelineModel::SelfContained) { return SortUtil::startDateTime(res); } if (range == TimelineModel::RangeEnd) { return SortUtil::endtDateTime(res); } return {}; } static TimelineModel::ElementType elementType(const QVariant &res) { if (JsonLd::isA(res)) { return TimelineModel::Flight; } if (JsonLd::isA(res)) { return TimelineModel::Hotel; } if (JsonLd::isA(res)) { return TimelineModel::TrainTrip; } if (JsonLd::isA(res)) { return TimelineModel::BusTrip; } if (JsonLd::isA(res)) { return TimelineModel::Restaurant; } if (JsonLd::isA(res)) { return TimelineModel::TouristAttraction; } if (JsonLd::isA(res)) { return TimelineModel::Event; } if (JsonLd::isA(res)) { return TimelineModel::CarRental; } return {}; } static QString destinationCountry(const QVariant &res) { if (JsonLd::isA(res)) { return res.value().reservationFor().value().arrivalAirport().address().addressCountry(); } if (JsonLd::isA(res)) { return res.value().reservationFor().value().arrivalStation().address().addressCountry(); } if (JsonLd::isA(res)) { return res.value().reservationFor().value().address().addressCountry(); } if (JsonLd::isA(res)) { return res.value().reservationFor().value().arrivalBusStop().address().addressCountry(); } if (JsonLd::isA(res)) { return res.value().reservationFor().value().address().addressCountry(); } if (JsonLd::isA(res)) { return res.value().touristAttraction().address().addressCountry(); } if (JsonLd::isA(res)) { return res.value().reservationFor().value().location().value().address().addressCountry(); } if (JsonLd::isA(res)) { return res.value().dropoffLocation().address().addressCountry(); } return {}; } static GeoCoordinates geoCoordinate(const QVariant &res) { if (JsonLd::isA(res)) { return res.value().reservationFor().value().arrivalAirport().geo(); } if (JsonLd::isA(res)) { return res.value().reservationFor().value().arrivalStation().geo(); } if (JsonLd::isA(res)) { return res.value().reservationFor().value().arrivalBusStop().geo(); } if (JsonLd::isA(res)) { return res.value().reservationFor().value().geo(); } if (JsonLd::isA(res)) { return res.value().reservationFor().value().geo(); } if (JsonLd::isA(res)) { return res.value().touristAttraction().geo(); } if (JsonLd::isA(res)) { return res.value().dropoffLocation().geo(); } return {}; } static bool isLocationChange(const QVariant &res) { return JsonLd::isA(res) || JsonLd::isA(res) || JsonLd::isA(res); } TimelineModel::Element::Element(TimelineModel::ElementType type, const QDateTime &dateTime, const QVariant &data) : content(data) , dt(dateTime) , elementType(type) { } TimelineModel::Element::Element(const QString& resId, const QVariant& res, RangeType rt) : dt(relevantDateTime(res, rt)) , elementType(::elementType(res)) , rangeType(rt) { ids.push_back(resId); } TimelineModel::TimelineModel(QObject *parent) : QAbstractListModel(parent) { } TimelineModel::~TimelineModel() = default; void TimelineModel::setReservationManager(ReservationManager* mgr) { // for auto tests only if (Q_UNLIKELY(!mgr)) { beginResetModel(); disconnect(m_resMgr, &ReservationManager::reservationAdded, this, &TimelineModel::reservationAdded); disconnect(m_resMgr, &ReservationManager::reservationUpdated, this, &TimelineModel::reservationUpdated); disconnect(m_resMgr, &ReservationManager::reservationRemoved, this, &TimelineModel::reservationRemoved); m_resMgr = mgr; m_elements.clear(); endResetModel(); return; } beginResetModel(); m_resMgr = mgr; for (const auto &resId : mgr->reservations()) { const auto res = m_resMgr->reservation(resId); if (needsSplitting(res)) { m_elements.push_back(Element{resId, res, RangeBegin}); m_elements.push_back(Element{resId, res, RangeEnd}); } else { m_elements.push_back(Element{resId, res, SelfContained}); } } - m_elements.push_back(Element{TodayMarker, QDateTime(QDate::currentDate(), QTime(0, 0))}); + m_elements.push_back(Element{TodayMarker, QDateTime(today(), QTime(0, 0))}); std::sort(m_elements.begin(), m_elements.end(), [](const Element &lhs, const Element &rhs) { return lhs.dt < rhs.dt; }); // merge multi-traveler elements QDateTime prevDt; for (auto it = m_elements.begin(); it != m_elements.end();) { if ((*it).dt != prevDt || !prevDt.isValid()) { prevDt = (*it).dt; ++it; continue; } prevDt = (*it).dt; auto prevIt = it - 1; if ((*prevIt).rangeType != (*it).rangeType || (*prevIt).elementType != (*it).elementType || (*prevIt).ids.isEmpty() || (*it).ids.isEmpty()) { ++it; continue; } const auto prevRes = m_resMgr->reservation((*prevIt).ids.at(0)); const auto curRes = m_resMgr->reservation((*it).ids.at(0)); if (prevRes.isNull() || curRes.isNull() || prevRes.userType() != curRes.userType() || !JsonLd::canConvert(prevRes)) { ++it; continue; } const auto prevTrip = JsonLd::convert(prevRes).reservationFor(); const auto curTrip = JsonLd::convert(curRes).reservationFor(); if (MergeUtil::isSame(prevTrip, curTrip)) { Q_ASSERT((*it).ids.size() == 1); (*prevIt).ids.push_back((*it).ids.at(0)); it = m_elements.erase(it); } else { ++it; } } connect(mgr, &ReservationManager::reservationAdded, this, &TimelineModel::reservationAdded); connect(mgr, &ReservationManager::reservationUpdated, this, &TimelineModel::reservationUpdated); connect(mgr, &ReservationManager::reservationRemoved, this, &TimelineModel::reservationRemoved); endResetModel(); updateInformationElements(); emit todayRowChanged(); } void TimelineModel::setWeatherForecastManager(WeatherForecastManager* mgr) { m_weatherMgr = mgr; updateWeatherElements(); connect(m_weatherMgr, &WeatherForecastManager::forecastUpdated, this, &TimelineModel::updateWeatherElements); } void TimelineModel::setHomeCountryIsoCode(const QString &isoCode) { m_homeCountry = isoCode; updateInformationElements(); } int TimelineModel::rowCount(const QModelIndex& parent) const { if (parent.isValid() || !m_resMgr) { return 0; } return m_elements.size(); } QVariant TimelineModel::data(const QModelIndex& index, int role) const { if (!index.isValid() || !m_resMgr) { return {}; } const auto &elem = m_elements.at(index.row()); const auto res = m_resMgr->reservation(elem.ids.value(0)); switch (role) { case SectionHeader: { if (elem.dt.isNull()) { return {}; } - if (elem.dt.date() == QDate::currentDate()) { + if (elem.dt.date() == today()) { return i18n("Today"); } return i18nc("weekday, date", "%1, %2", QLocale().dayName(elem.dt.date().dayOfWeek(), QLocale::LongFormat), QLocale().toString(elem.dt.date(), QLocale::ShortFormat)); } case ReservationIdsRole: return elem.ids; case ElementTypeRole: return elem.elementType; case TodayEmptyRole: if (elem.elementType == TodayMarker) { - return index.row() == (int)(m_elements.size() - 1) || m_elements.at(index.row() + 1).dt.date() > QDate::currentDate(); + return index.row() == (int)(m_elements.size() - 1) || m_elements.at(index.row() + 1).dt.date() > today(); } return {}; case IsTodayRole: - return elem.dt.date() == QDate::currentDate(); + return elem.dt.date() == today(); case ElementRangeRole: return elem.rangeType; case CountryInformationRole: + if (elem.elementType == CountryInfo) + return elem.content; + break; case WeatherForecastRole: - return elem.content; + if (elem.elementType == WeatherForecast) + return elem.content; + break; } return {}; } QHash TimelineModel::roleNames() const { auto names = QAbstractListModel::roleNames(); names.insert(SectionHeader, "sectionHeader"); names.insert(ReservationIdsRole, "reservationIds"); names.insert(ElementTypeRole, "type"); names.insert(TodayEmptyRole, "isTodayEmpty"); names.insert(IsTodayRole, "isToday"); names.insert(ElementRangeRole, "rangeType"); names.insert(CountryInformationRole, "countryInformation"); names.insert(WeatherForecastRole, "weatherForecast"); return names; } int TimelineModel::todayRow() const { const auto it = std::find_if(m_elements.begin(), m_elements.end(), [](const Element &e) { return e.elementType == TodayMarker; }); return std::distance(m_elements.begin(), it); } void TimelineModel::reservationAdded(const QString &resId) { const auto res = m_resMgr->reservation(resId); if (needsSplitting(res)) { insertElement(Element{resId, res, RangeBegin}); insertElement(Element{resId, res, RangeEnd}); } else { insertElement(Element{resId, res, SelfContained}); } updateInformationElements(); emit todayRowChanged(); } void TimelineModel::insertElement(Element &&elem) { auto it = std::lower_bound(m_elements.begin(), m_elements.end(), elem.dt, [](const Element &lhs, const QDateTime &rhs) { return lhs.dt < rhs; }); const auto row = std::distance(m_elements.begin(), it); // check if we can merge with an existing element if (it != m_elements.end() && (*it).dt == elem.dt && elem.ids.size() == 1 && (*it).elementType == elem.elementType && (*it).rangeType == elem.rangeType && !(*it).ids.isEmpty()) { const auto prevRes = m_resMgr->reservation((*it).ids.at(0)); const auto curRes = m_resMgr->reservation(elem.ids.at(0)); if (prevRes.userType() == curRes.userType() && !prevRes.isNull() && !curRes.isNull() && JsonLd::canConvert(prevRes)) { const auto prevTrip = JsonLd::convert(prevRes).reservationFor(); const auto curTrip = JsonLd::convert(curRes).reservationFor(); if (MergeUtil::isSame(prevTrip, curTrip)) { (*it).ids.push_back(elem.ids.at(0)); emit dataChanged(index(row, 0), index(row, 0)); return; } } } beginInsertRows({}, row, row); m_elements.insert(it, std::move(elem)); endInsertRows(); } void TimelineModel::reservationUpdated(const QString &resId) { const auto res = m_resMgr->reservation(resId); if (needsSplitting(res)) { updateElement(resId, res, RangeBegin); updateElement(resId, res, RangeEnd); } else { updateElement(resId, res, SelfContained); } updateInformationElements(); } void TimelineModel::updateElement(const QString &resId, const QVariant &res, TimelineModel::RangeType rangeType) { const auto it = std::find_if(m_elements.begin(), m_elements.end(), [resId, rangeType](const Element &e) { return e.ids.contains(resId) && e.rangeType == rangeType; }); if (it == m_elements.end()) { return; } const auto row = std::distance(m_elements.begin(), it); const auto newDt = relevantDateTime(res, rangeType); const auto isMulti = (*it).ids.size() > 1; if ((*it).dt != newDt) { // element moved if (isMulti) { (*it).ids.removeAll(resId); emit dataChanged(index(row, 0), index(row, 0)); } else { beginRemoveRows({}, row, row); m_elements.erase(it); endRemoveRows(); } insertElement(Element{resId, res, rangeType}); } else { emit dataChanged(index(row, 0), index(row, 0)); } } void TimelineModel::reservationRemoved(const QString &resId) { const auto it = std::find_if(m_elements.begin(), m_elements.end(), [resId](const Element &e) { return e.ids.contains(resId); }); if (it == m_elements.end()) { return; } const auto isSplit = (*it).rangeType == RangeBegin; const auto row = std::distance(m_elements.begin(), it); const auto isMulti = (*it).ids.size() > 1; if (isMulti) { (*it).ids.removeAll(resId); emit dataChanged(index(row, 0), index(row, 0)); } else { beginRemoveRows({}, row, row); m_elements.erase(it); endRemoveRows(); emit todayRowChanged(); } if (isSplit) { reservationRemoved(resId); } updateInformationElements(); } void TimelineModel::updateInformationElements() { // the country information is shown before transitioning into a country that // differs in one or more properties from the home country and we where that // differences is introduced by the transition CountryInformation homeCountry; homeCountry.setIsoCode(m_homeCountry); auto previousCountry = homeCountry; for (auto it = m_elements.begin(); it != m_elements.end(); ++it) { switch ((*it).elementType) { case TodayMarker: case WeatherForecast: it = erasePreviousCountyInfo(it); continue; case CountryInfo: previousCountry = (*it).content.value(); it = erasePreviousCountyInfo(it); // purge multiple consecutive country info elements continue; default: break; } auto newCountry = homeCountry; newCountry.setIsoCode(destinationCountry(m_resMgr->reservation((*it).ids.value(0)))); if (newCountry == previousCountry) { continue; } if (newCountry == homeCountry) { assert(it != m_elements.begin()); // previousCountry == homeCountry in this case // purge outdated country info element it = erasePreviousCountyInfo(it); previousCountry = newCountry; continue; } // add new country info element auto row = std::distance(m_elements.begin(), it); beginInsertRows({}, row, row); it = m_elements.insert(it, Element{CountryInfo, (*it).dt, QVariant::fromValue(newCountry)}); endInsertRows(); previousCountry = newCountry; } updateWeatherElements(); } std::vector::iterator TimelineModel::erasePreviousCountyInfo(std::vector::iterator it) { if (it == m_elements.begin()) { return it; } auto it2 = it; --it2; if ((*it2).elementType == CountryInfo) { const auto row = std::distance(m_elements.begin(), it2); beginRemoveRows({}, row, row); it = m_elements.erase(it2); endRemoveRows(); } return it; } void TimelineModel::updateWeatherElements() { if (!m_weatherMgr || m_elements.empty()) { return; } qDebug() << "recomputing weather elements"; GeoCoordinates geo; // look through the past, clean up weather elements there and figure out where we are auto it = m_elements.begin(); - for (; it != m_elements.end() && (*it).dt < QDateTime::currentDateTimeUtc();) { + for (; it != m_elements.end() && (*it).dt < now();) { if ((*it).elementType == WeatherForecast) { const auto row = std::distance(m_elements.begin(), it); beginRemoveRows({}, row, row); it = m_elements.erase(it); endRemoveRows(); continue; } const auto res = m_resMgr->reservation((*it).ids.value(0)); const auto newGeo = geoCoordinate(res); if (isLocationChange(res) || newGeo.isValid()) { geo = newGeo; } ++it; } - auto date = QDateTime::currentDateTime(); + auto date = now(); date.setTime(QTime(date.time().hour() + 1, 0)); - while(it != m_elements.end() && date < m_weatherMgr->maximumForecastTime()) { + while(it != m_elements.end() && date < m_weatherMgr->maximumForecastTime(today())) { if ((*it).dt < date || (*it).elementType == TodayMarker) { // clean up outdated weather elements (happens when merging previously split ranges) if ((*it).elementType == WeatherForecast) { const auto row = std::distance(m_elements.begin(), it); beginRemoveRows({}, row, row); it = m_elements.erase(it); endRemoveRows(); if (it == m_elements.end()) { break; } continue; } // track where we are const auto res = m_resMgr->reservation((*it).ids.value(0)); const auto newGeo = geoCoordinate(res); if (isLocationChange(res) || newGeo.isValid()) { geo = newGeo; } ++it; continue; } // determine the length of the forecast range (at most until the end of the day) auto endTime = date; endTime.setTime(QTime(23, 59, 59)); auto nextStartTime = endTime; GeoCoordinates newGeo = geo; for (auto it2 = it; it2 != m_elements.end(); ++it2) { if ((*it2).dt >= endTime) { break; } const auto res = m_resMgr->reservation((*it2).ids.value(0)); if (isLocationChange(res)) { // exclude the actual travel time from forecast ranges endTime = std::min(endTime, relevantDateTime(res, RangeBegin)); nextStartTime = std::max(endTime, relevantDateTime(res, RangeEnd)); newGeo = geoCoordinate(res); break; } } ::WeatherForecast fc; if (geo.isValid()) { m_weatherMgr->monitorLocation(geo.latitude(), geo.longitude()); fc = m_weatherMgr->forecast(geo.latitude(), geo.longitude(), date, endTime); } geo = newGeo; // case 1: we have forecast data, and a matching weather element: update if (fc.isValid() && (*it).dt == date && (*it).elementType == WeatherForecast) { (*it).content = QVariant::fromValue(fc); const auto idx = index(std::distance(m_elements.begin(), it), 0); emit dataChanged(idx, idx); } // case 2: we have forecast data, but no matching weather element: insert else if (fc.isValid()) { const auto row = std::distance(m_elements.begin(), it); beginInsertRows({}, row, row); it = m_elements.insert(it, Element{WeatherForecast, date, QVariant::fromValue(fc)}); endInsertRows(); } // case 3: we have no forecast data, but a matching weather element: remove else if ((*it).elementType == WeatherForecast && (*it).dt == date) { const auto row = std::distance(m_elements.begin(), it); beginRemoveRows({}, row, row); it = m_elements.erase(it); endRemoveRows(); } date = nextStartTime.addSecs(1); ++it; } // append weather elements beyond the end of the list if necessary - while (date < m_weatherMgr->maximumForecastTime() && geo.isValid()) { + while (date < m_weatherMgr->maximumForecastTime(today()) && geo.isValid()) { auto endTime = date; endTime.setTime(QTime(23, 59, 59)); m_weatherMgr->monitorLocation(geo.latitude(), geo.longitude()); const auto fc = m_weatherMgr->forecast(geo.latitude(), geo.longitude(), date, endTime); if (fc.isValid()) { const auto row = std::distance(m_elements.begin(), it); beginInsertRows({}, row, row); it = m_elements.insert(it, Element{WeatherForecast, date, QVariant::fromValue(fc)}); ++it; endInsertRows(); } date = endTime.addSecs(1); } qDebug() << "weather recomputation done"; } + +QDateTime TimelineModel::now() const +{ + if (Q_UNLIKELY(m_unitTestTime.isValid())) { + return m_unitTestTime; + } + return QDateTime::currentDateTime(); +} + +QDate TimelineModel::today() const +{ + if (Q_UNLIKELY(m_unitTestTime.isValid())) { + return m_unitTestTime.date(); + } + return QDate::currentDate(); +} + +void TimelineModel::setCurrentDateTime(const QDateTime &dt) +{ + m_unitTestTime = dt; +} diff --git a/src/app/timelinemodel.h b/src/app/timelinemodel.h index a09464d..77f9935 100644 --- a/src/app/timelinemodel.h +++ b/src/app/timelinemodel.h @@ -1,116 +1,123 @@ /* Copyright (C) 2018 Volker Krause This program is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ #ifndef TIMELINEMODEL_H #define TIMELINEMODEL_H #include #include class ReservationManager; class WeatherForecastManager; namespace KItinerary { class GeoCoordinates; } class TimelineModel : public QAbstractListModel { Q_OBJECT Q_PROPERTY(int todayRow READ todayRow NOTIFY todayRowChanged) public: enum Role { SectionHeader = Qt::UserRole + 1, ReservationIdsRole, ElementTypeRole, TodayEmptyRole, IsTodayRole, ElementRangeRole, CountryInformationRole, WeatherForecastRole }; enum ElementType { Undefined, Flight, TrainTrip, BusTrip, Hotel, Restaurant, TouristAttraction, Event, CarRental, TodayMarker, CountryInfo, WeatherForecast }; Q_ENUM(ElementType) // indicates whether an element is self-contained or the beginning/end of a longer timespan/range enum RangeType { SelfContained, RangeBegin, RangeEnd }; Q_ENUM(RangeType) explicit TimelineModel(QObject *parent = nullptr); ~TimelineModel(); void setReservationManager(ReservationManager *mgr); void setWeatherForecastManager(WeatherForecastManager *mgr); void setHomeCountryIsoCode(const QString &isoCode); QVariant data(const QModelIndex& index, int role) const override; int rowCount(const QModelIndex& parent = QModelIndex()) const override; QHash roleNames() const override; int todayRow() const; + // for unit testing + void setCurrentDateTime(const QDateTime &dt); + signals: void todayRowChanged(); private: struct Element { explicit Element(ElementType type, const QDateTime &dateTime, const QVariant &data = {}); explicit Element(const QString &resId, const QVariant &res, RangeType rt); QStringList ids; // reservation ids (multiple entries in case of mult-traveller merging), QStringList as we need QML compatibility... QVariant content; // non-reservation content QDateTime dt; // relevant date/time ElementType elementType; RangeType rangeType = SelfContained; }; void reservationAdded(const QString &resId); void insertElement(Element &&elem); void reservationUpdated(const QString &resId); void updateElement(const QString &resId, const QVariant &res, RangeType rangeType); void reservationRemoved(const QString &resId); void updateInformationElements(); std::vector::iterator erasePreviousCountyInfo(std::vector::iterator it); void updateWeatherElements(); + QDateTime now() const; + QDate today() const; + ReservationManager *m_resMgr = nullptr; WeatherForecastManager *m_weatherMgr = nullptr; std::vector m_elements; QString m_homeCountry; + QDateTime m_unitTestTime; }; #endif // TIMELINEMODEL_H diff --git a/src/weather/weatherforecastmanager.cpp b/src/weather/weatherforecastmanager.cpp index 9a5f89b..f1dfabb 100644 --- a/src/weather/weatherforecastmanager.cpp +++ b/src/weather/weatherforecastmanager.cpp @@ -1,515 +1,517 @@ /* Copyright (C) 2018 Volker Krause This program is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ #include "weatherforecastmanager.h" #include "weatherforecast.h" #include "weathertile.h" #include #include #include #include #include #include #include #include #include #include #include static void alignToHour(QDateTime &dt) { dt.setTime(QTime(dt.time().hour(), 0, 0, 0)); } static void roundToHour(QDateTime &dt) { if (dt.time().minute() >= 30) { alignToHour(dt); dt = dt.addSecs(3600); } else { alignToHour(dt); } } /* * ATTENTION! * Before touching anything in here, especially regarding the network operations * make sure to read and understand https://api.met.no/conditions_service.html! */ WeatherForecastManager::WeatherForecastManager(QObject *parent) : QObject(parent) { connect(&m_updateTimer, &QTimer::timeout, this, &WeatherForecastManager::updateAll); m_updateTimer.setSingleShot(true); } WeatherForecastManager::~WeatherForecastManager() = default; void WeatherForecastManager::setAllowNetworkAccess(bool enabled) { m_allowNetwork = enabled; if (enabled) { scheduleUpdate(); } else { m_updateTimer.stop(); } fetchNext(); } void WeatherForecastManager::monitorLocation(float latitude, float longitude) { WeatherTile t{latitude, longitude}; qDebug() << latitude << longitude << t.lat << t.lon; auto it = std::lower_bound(m_monitoredTiles.begin(), m_monitoredTiles.end(), t); if (it != m_monitoredTiles.end() && (*it) == t) { return; } m_monitoredTiles.insert(it, t); fetchTile(t); } WeatherForecast WeatherForecastManager::forecast(float latitude, float longitude, const QDateTime &dt) const { return forecast(latitude, longitude, dt, dt.addSecs(3600)); } WeatherForecast WeatherForecastManager::forecast(float latitude, float longitude, const QDateTime &begin, const QDateTime &end) const { + if (Q_UNLIKELY(m_testMode)) { + WeatherForecast fc; + auto beginDt = begin; + roundToHour(beginDt); + fc.setDateTime(beginDt); + fc.setTile({latitude, longitude}); + fc.setMinimumTemperature(std::min(latitude, longitude)); + fc.setMaximumTemperature(std::max(latitude, longitude)); + fc.setPrecipitation(23.0f); + fc.setSymbolType(WeatherForecast::LightClouds); + return fc; + } + auto beginDt = std::max(begin, QDateTime::currentDateTimeUtc()); roundToHour(beginDt); auto endDt = std::max(end, QDateTime::currentDateTimeUtc()); roundToHour(endDt); if (!beginDt.isValid() || !endDt.isValid() || beginDt > endDt) { return {}; } if (beginDt == endDt) { endDt = endDt.addSecs(3600); } const auto range = beginDt.secsTo(endDt) / 3600; - if (Q_UNLIKELY(m_testMode)) { - WeatherForecast fc; - fc.setDateTime(beginDt); - fc.setTile({latitude, longitude}); - fc.setMinimumTemperature(std::min(latitude, longitude)); - fc.setMaximumTemperature(std::max(latitude, longitude)); - fc.setPrecipitation(23.0f); - fc.setSymbolType(WeatherForecast::LightClouds); - return fc; - } - WeatherTile tile{latitude, longitude}; if (!loadForecastData(tile)) { return {}; } const auto &forecasts = m_forecastData[tile]; const auto beginIt = std::lower_bound(forecasts.begin(), forecasts.end(), beginDt, [](const WeatherForecast &lhs, const QDateTime &rhs) { return lhs.dateTime() < rhs; }); if (beginIt == forecasts.end()) { return {}; } const auto endIt = std::lower_bound(forecasts.begin(), forecasts.end(), endDt, [](const WeatherForecast &lhs, const QDateTime &rhs) { return lhs.dateTime() < rhs; }); WeatherForecast fc(*beginIt); fc.setRange(range); for (auto it = beginIt; it != endIt; ++it) { fc.merge(*it); } return fc; } void WeatherForecastManager::fetchTile(WeatherTile tile) { QFileInfo fi(cachePath(tile) + QLatin1String("forecast.xml")); if (fi.exists() && fi.lastModified().toUTC().addSecs(3600 * 2) >= QDateTime::currentDateTimeUtc()) { // cache is already new enough return; } m_pendingTiles.push_back(tile); fetchNext(); } void WeatherForecastManager::fetchNext() { if (!m_allowNetwork || m_pendingReply || m_pendingTiles.empty()) { return; } const auto tile = m_pendingTiles.front(); m_pendingTiles.pop_front(); if (!m_nam) { m_nam = new QNetworkAccessManager(this); } QUrl url; url.setScheme(QStringLiteral("https")); url.setHost(QStringLiteral("api.met.no")); url.setPath(QStringLiteral("/weatherapi/locationforecast/1.9/")); QUrlQuery query; query.addQueryItem(QStringLiteral("lat"), QString::number(tile.latitude())); query.addQueryItem(QStringLiteral("lon"), QString::number(tile.longitude())); url.setQuery(query); qDebug() << url; QNetworkRequest req(url); req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); req.setAttribute(QNetworkRequest::User, QVariant::fromValue(tile)); // see §Identification on https://api.met.no/conditions_service.html req.setHeader(QNetworkRequest::UserAgentHeader, QString(QCoreApplication::applicationName() + QLatin1Char(' ') + QCoreApplication::applicationVersion() + QLatin1String(" (kde-pim@kde.org)"))); // TODO see §Cache on https://api.met.no/conditions_service.html // see §Compression on https://api.met.no/conditions_service.html req.setRawHeader("Accept-Encoding", "gzip"); m_pendingReply = m_nam->get(req); connect(m_pendingReply, &QNetworkReply::finished, this, &WeatherForecastManager::tileDownloaded); } void WeatherForecastManager::tileDownloaded() { // TODO handle 304 Not Modified // TODO handle 429 Too Many Requests if (m_pendingReply->error() != QNetworkReply::NoError) { qWarning() << m_pendingReply->errorString(); } else { writeToCacheFile(m_pendingReply); } m_pendingReply->deleteLater(); m_pendingReply = nullptr; if (m_pendingTiles.empty()) { emit forecastUpdated(); } fetchNext(); } QString WeatherForecastManager::cachePath(WeatherTile tile) const { const auto path = QString(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QLatin1String("/weather/") + QString::number(tile.lat) + QLatin1Char('/') + QString::number(tile.lon) + QLatin1Char('/')); QDir().mkpath(path); return path; } void WeatherForecastManager::writeToCacheFile(QNetworkReply* reply) const { const auto tile = reply->request().attribute(QNetworkRequest::User).value(); qDebug() << tile.lat << tile.lon; qDebug() << reply->rawHeaderPairs(); QFile f(cachePath(tile) + QLatin1String("forecast.xml")); if (!f.open(QFile::WriteOnly)) { qWarning() << "Failed to open weather cache location:" << f.errorString(); return; } const auto contentEncoding = reply->rawHeader("Content-Encoding"); if (contentEncoding == "gzip") { const auto data = reply->readAll(); if (data.size() < 4 || data.at(0) != 0x1f || data.at(1) != char(0x8b)) { qWarning() << "Invalid gzip format"; return; } z_stream stream; unsigned char buffer[1024]; stream.zalloc = nullptr; stream.zfree = nullptr; stream.opaque = nullptr; stream.avail_in = data.size(); stream.next_in = reinterpret_cast(const_cast(data.data())); auto ret = inflateInit2(&stream, 15 + 32); // see docs, the magic numbers enable gzip decoding if (ret != Z_OK) { qWarning() << "Failed to initialize zlib stream."; return; } do { stream.avail_out = sizeof(buffer); stream.next_out = buffer; ret = inflate(&stream, Z_NO_FLUSH); if (ret != Z_OK && ret != Z_STREAM_END) { qWarning() << "Zlib decoding failed!" << ret; break; } f.write(reinterpret_cast(buffer), sizeof(buffer) - stream.avail_out); } while (stream.avail_out == 0); inflateEnd(&stream); } else { f.write(reply->readAll()); } m_forecastData.erase(tile); } bool WeatherForecastManager::loadForecastData(WeatherTile tile) const { const auto it = m_forecastData.find(tile); if (it != m_forecastData.end()) { return true; } QFile f(cachePath(tile) + QLatin1String("forecast.xml")); if (!f.exists() || !f.open(QFile::ReadOnly)) { return false; } QXmlStreamReader reader(&f); auto forecasts = parseForecast(reader, tile); mergeForecasts(forecasts); if (forecasts.empty()) { return false; } m_forecastData.insert(it, {tile, std::move(forecasts)}); return true; } void WeatherForecastManager::mergeForecasts(std::vector& forecasts) const { std::stable_sort(forecasts.begin(), forecasts.end(), [](const WeatherForecast &lhs, const WeatherForecast &rhs) { if (lhs.dateTime() == rhs.dateTime()) return lhs.range() < rhs.range(); return lhs.dateTime() < rhs.dateTime(); }); // merge duplicated time slices auto storeIt = forecasts.begin(); for (auto it = forecasts.begin(); it != forecasts.end();) { (*storeIt) = (*it); auto mergeIt = it; for (; mergeIt != forecasts.end(); ++mergeIt) { if ((*it).dateTime() == (*mergeIt).dateTime()) { (*storeIt).merge(*mergeIt); } else { (*mergeIt).setRange(1); break; } } ++storeIt; it = mergeIt; } forecasts.erase(storeIt, forecasts.end()); } std::vector WeatherForecastManager::parseForecast(QXmlStreamReader &reader, WeatherTile tile) const { std::vector result; auto beginDt = QDateTime::currentDateTimeUtc(); alignToHour(beginDt); while (!reader.atEnd()) { if (reader.tokenType() == QXmlStreamReader::StartElement) { if (reader.name() == QLatin1String("weatherdata") || reader.name() == QLatin1String("product")) { reader.readNext(); // enter these elements continue; } if (reader.name() == QLatin1String("time") && reader.attributes().value(QLatin1String("datatype")) == QLatin1String("forecast")) { // normalize time ranges to 1 hour auto from = QDateTime::fromString(reader.attributes().value(QLatin1String("from")).toString(), Qt::ISODate); from = std::max(from, beginDt); alignToHour(from); auto to = QDateTime::fromString(reader.attributes().value(QLatin1String("to")).toString(), Qt::ISODate); alignToHour(to); const auto range = from.secsTo(to) / 3600; if (to == from) { to = to.addSecs(3600); } if (to < beginDt || to <= from || !to.isValid() || !from.isValid()) { reader.skipCurrentElement(); continue; } auto fc = parseForecastElement(reader); for (int i = 0; i < from.secsTo(to); i += 3600) { fc.setTile(tile); fc.setDateTime(from.addSecs(i)); fc.setRange(range); result.push_back(fc); } continue; } // unknown element reader.skipCurrentElement(); } else { reader.readNext(); } } return result; } // Icon mapping: https://api.met.no/weatherapi/weathericon/1.1/documentation struct symbol_map_t { uint8_t id; WeatherForecast::SymbolType type; }; static const symbol_map_t symbol_map[] = { { 1, WeatherForecast::Clear }, // 1 Sun { 2, WeatherForecast::Clear | WeatherForecast::LightClouds }, // 2 LightCloud { 3, WeatherForecast::Clear | WeatherForecast::Clouds }, // 3 PartlyCloud { 4, WeatherForecast::Clouds }, // 4 Cloud { 5, WeatherForecast::Clear | WeatherForecast::LightRain }, // 5 LightRainSun { 6, WeatherForecast::Clear | WeatherForecast::LightRain | WeatherForecast::ThunderStorm }, // 6 LightRainThunderSun { 7, WeatherForecast::Clear | WeatherForecast::Hail }, // 7 SleetSun { 8, WeatherForecast::Clear | WeatherForecast::Snow }, // 8 SnowSun { 9, WeatherForecast::LightRain }, // 9 LightRain { 10, WeatherForecast::Rain }, // 10 Rain { 11, WeatherForecast::Rain | WeatherForecast::ThunderStorm }, // 11 RainThunder { 12, WeatherForecast::Hail }, // 12 Sleet { 13, WeatherForecast::Snow }, // 13 Snow { 14, WeatherForecast::Snow | WeatherForecast::ThunderStorm }, // 14 SnowThunder { 15, WeatherForecast::Fog }, // 15 Fog { 20, WeatherForecast::Clear | WeatherForecast::Hail | WeatherForecast::ThunderStorm }, // 20 SleetSunThunder { 21, WeatherForecast::Clear | WeatherForecast::Snow | WeatherForecast::ThunderStorm }, // 21 SnowSunThunder { 22, WeatherForecast::LightRain | WeatherForecast::ThunderStorm }, // 22 LightRainThunder { 23, WeatherForecast::Hail | WeatherForecast::ThunderStorm }, // 23 SleetThunder { 24, WeatherForecast::Clear | WeatherForecast::LightRain | WeatherForecast::ThunderStorm }, // 24 DrizzleThunderSun { 25, WeatherForecast::Clear | WeatherForecast::Rain | WeatherForecast::ThunderStorm }, // 25 RainThunderSun { 26, WeatherForecast::Clear | WeatherForecast::Hail | WeatherForecast::ThunderStorm }, // 26 LightSleetThunderSun { 27, WeatherForecast::Clear | WeatherForecast::Hail | WeatherForecast::ThunderStorm }, // 27 HeavySleetThunderSun { 28, WeatherForecast::Clear | WeatherForecast::LightSnow | WeatherForecast::ThunderStorm }, // 28 LightSnowThunderSun { 29, WeatherForecast::Clear | WeatherForecast::Snow | WeatherForecast::ThunderStorm }, // 29 HeavySnowThunderSun { 30, WeatherForecast::LightRain | WeatherForecast::ThunderStorm }, // 30 DrizzleThunder { 31, WeatherForecast::Hail | WeatherForecast::ThunderStorm }, // 31 LightSleetThunder { 32, WeatherForecast::Hail | WeatherForecast::ThunderStorm }, // 32 HeavySleetThunder { 33, WeatherForecast::LightSnow | WeatherForecast::ThunderStorm }, // 33 LightSnowThunder { 34, WeatherForecast::Snow | WeatherForecast::ThunderStorm }, // 34 HeavySnowThunder { 40, WeatherForecast::Clear | WeatherForecast::LightRain }, // 40 DrizzleSun { 41, WeatherForecast::Clear | WeatherForecast::Rain}, // 41 RainSun { 42, WeatherForecast::Clear | WeatherForecast::Hail }, // 42 LightSleetSun { 43, WeatherForecast::Clear | WeatherForecast::Hail }, // 43 HeavySleetSun { 44, WeatherForecast::Clear | WeatherForecast::LightSnow }, // 44 LightSnowSun { 45, WeatherForecast::Clear | WeatherForecast::Snow }, // 45 HeavysnowSun { 46, WeatherForecast::LightRain }, // 46 Drizzle { 47, WeatherForecast::Hail }, // 47 LightSleet { 48, WeatherForecast::Hail }, // 48 HeavySleet { 49, WeatherForecast::LightSnow }, // 49 LightSnow { 50, WeatherForecast::Snow } // 50 HeavySnow }; WeatherForecast WeatherForecastManager::parseForecastElement(QXmlStreamReader &reader) const { WeatherForecast fc; while (!reader.atEnd()) { switch (reader.tokenType()) { case QXmlStreamReader::StartElement: if (reader.name() == QLatin1String("temperature")) { const auto t = reader.attributes().value(QLatin1String("value")).toFloat(); fc.setMinimumTemperature(t); fc.setMaximumTemperature(t); } else if (reader.name() == QLatin1String("minTemperature")) { fc.setMinimumTemperature(reader.attributes().value(QLatin1String("value")).toFloat()); } else if (reader.name() == QLatin1String("maxTemperature")) { fc.setMaximumTemperature(reader.attributes().value(QLatin1String("value")).toFloat()); } else if (reader.name() == QLatin1String("symbol")) { auto symId = reader.attributes().value(QLatin1String("number")).toInt(); if (symId > 100) { symId -= 100; // map polar night symbols } const auto it = std::lower_bound(std::begin(symbol_map), std::end(symbol_map), symId, [](symbol_map_t lhs, uint8_t rhs) { return lhs.id < rhs; }); if (it != std::end(symbol_map) && (*it).id == symId) { fc.setSymbolType((*it).type); } } else if (reader.name() == QLatin1String("precipitation")) { fc.setPrecipitation(reader.attributes().value(QLatin1String("value")).toFloat()); } break; case QXmlStreamReader::EndElement: if (reader.name() == QLatin1String("time")) { return fc; } break; default: break; } reader.readNext(); } return fc; } -QDateTime WeatherForecastManager::maximumForecastTime() const +QDateTime WeatherForecastManager::maximumForecastTime(const QDate &today) const { - return QDateTime(QDate::currentDate().addDays(9), QTime(0, 0)); + return QDateTime(today.addDays(9), QTime(0, 0)); } void WeatherForecastManager::setTestModeEnabled(bool testMode) { m_testMode = testMode; } void WeatherForecastManager::scheduleUpdate() { if (m_updateTimer.isActive()) { return; } // see §Updates on https://api.met.no/conditions_service.html m_updateTimer.setInterval(std::chrono::hours(2) + std::chrono::minutes(QTime::currentTime().msec() % 30)); qDebug() << "Next weather update:" << m_updateTimer.interval(); m_updateTimer.start(); } void WeatherForecastManager::updateAll() { for (const auto tile : m_monitoredTiles) { fetchTile(tile); } purgeCache(); scheduleUpdate(); } void WeatherForecastManager::purgeCache() { const auto basePath = QString(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + QLatin1String("/weather/")); const auto cutoffDate = QDateTime::currentDateTimeUtc().addDays(-9); QDirIterator it(basePath, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks | QDir::Writable, QDirIterator::Subdirectories); while (it.hasNext()) { it.next(); if (it.fileInfo().isFile() && it.fileInfo().lastModified() < cutoffDate) { qDebug() << "Purging old weather data:" << it.filePath(); QFile::remove(it.filePath()); } else if (it.fileInfo().isDir() && QDir(it.filePath()).isEmpty()) { qDebug() << "Purging old weather cache folder:" << it.filePath(); QDir().rmdir(it.filePath()); } } } #include "moc_weatherforecastmanager.cpp" diff --git a/src/weather/weatherforecastmanager.h b/src/weather/weatherforecastmanager.h index 8523c58..b844f99 100644 --- a/src/weather/weatherforecastmanager.h +++ b/src/weather/weatherforecastmanager.h @@ -1,96 +1,96 @@ /* Copyright (C) 2018 Volker Krause This program is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ #ifndef WEATHERFORECASTMANAGER_H #define WEATHERFORECASTMANAGER_H #include "weathertile.h" #include #include #include #include #include class WeatherForecast; class QNetworkAccessManager; class QNetworkReply; class QXmlStreamReader; /** Access to weather forecast data based on geo coorinates. */ class WeatherForecastManager : public QObject { Q_OBJECT public: explicit WeatherForecastManager(QObject *parent = nullptr); ~WeatherForecastManager(); /** Kill switch for network operations. */ void setAllowNetworkAccess(bool enabled); /** Monitor the specified location for weather forecasts. */ void monitorLocation(float latitude, float longitude); /** Get the forecast for the given time and location. */ WeatherForecast forecast(float latitude, float longitude, const QDateTime &dt) const; /** Get the forecast for the give time range and location. */ WeatherForecast forecast(float latitude, float longitude, const QDateTime &begin, const QDateTime &end) const; /** Time until when we have forecast data. */ - QDateTime maximumForecastTime() const; + QDateTime maximumForecastTime(const QDate &today) const; /** Enable unit test mode. * In this mode static forecast data is provided for all locations. */ void setTestModeEnabled(bool testMode); signals: /** Updated when new forecast data has been retrieved. */ void forecastUpdated(); private: friend class WeatherTest; void fetchTile(WeatherTile tile); void fetchNext(); void tileDownloaded(); QString cachePath(WeatherTile tile) const; void writeToCacheFile(QNetworkReply *reply) const; bool loadForecastData(WeatherTile tile) const; void mergeForecasts(std::vector &forecasts) const; std::vector parseForecast(QXmlStreamReader &reader, WeatherTile tile) const; WeatherForecast parseForecastElement(QXmlStreamReader &reader) const; void scheduleUpdate(); void updateAll(); void purgeCache(); std::vector m_monitoredTiles; std::deque m_pendingTiles; mutable std::unordered_map> m_forecastData; QNetworkAccessManager *m_nam = nullptr; QNetworkReply *m_pendingReply = nullptr; QTimer m_updateTimer; bool m_allowNetwork = false; bool m_testMode = false; }; #endif // WEATHERFORECASTMANAGER_H