Files
evcharger-app/evcharger-app/homeassistantclient.cpp
T
2026-08-20 07:40:28 +02:00

600 lines
21 KiB
C++

#include "homeassistantclient.h"
#include "appsettings.h"
#include <QDateTime>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrlQuery>
#include <algorithm>
#include <cmath>
namespace
{
constexpr auto urlSetting = "homeAssistant/url";
constexpr auto tokenSetting = "homeAssistant/accessToken";
constexpr auto chartsSetting = "homeAssistant/charts";
QString normalizedBaseUrl(const QString &input)
{
QString text = input.trimmed();
while (text.endsWith('/'))
text.chop(1);
return text;
}
QDateTime beginningOfPeriod(const QString &range, int offset)
{
const QDate today = QDate::currentDate();
QDate start;
if (range == QStringLiteral("week"))
start = today.addDays(1 - today.dayOfWeek()).addDays(offset * 7);
else if (range == QStringLiteral("month"))
start = QDate(today.year(), today.month(), 1).addMonths(offset);
else if (range == QStringLiteral("year"))
start = QDate(today.year(), 1, 1).addYears(offset);
else
start = today.addDays(offset);
return start.startOfDay();
}
QDateTime endOfPeriod(const QString &range, const QDateTime &start)
{
if (range == QStringLiteral("week"))
return start.addDays(7);
if (range == QStringLiteral("month"))
return QDateTime(start.date().addMonths(1), QTime(0, 0));
if (range == QStringLiteral("year"))
return QDateTime(start.date().addYears(1), QTime(0, 0));
return start.addDays(1);
}
QString defaultColor(int index)
{
static const QStringList colors{
QStringLiteral("#3274d9"), QStringLiteral("#f2c037"),
QStringLiteral("#2ca02c"), QStringLiteral("#d64f4f"),
QStringLiteral("#8b5cf6"), QStringLiteral("#00a6a6"),
QStringLiteral("#e8792e"), QStringLiteral("#64748b")};
return colors.at(index % colors.size());
}
}
HomeAssistantEntityModel::HomeAssistantEntityModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int HomeAssistantEntityModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : m_visibleRows.size();
}
QVariant HomeAssistantEntityModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_visibleRows.size())
return {};
const Entity &entity = m_entities.at(m_visibleRows.at(index.row()));
switch (role)
{
case EntityIdRole: return entity.entityId;
case NameRole: return entity.name;
case UnitRole: return entity.unit;
case DeviceClassRole: return entity.deviceClass;
case StateRole: return entity.state;
case SearchTextRole: return entity.searchText;
default: return {};
}
}
QHash<int, QByteArray> HomeAssistantEntityModel::roleNames() const
{
return {{EntityIdRole, "entityId"}, {NameRole, "name"}, {UnitRole, "unit"},
{DeviceClassRole, "deviceClass"}, {StateRole, "currentValue"},
{SearchTextRole, "searchText"}};
}
void HomeAssistantEntityModel::setEntities(QList<Entity> entities)
{
std::sort(entities.begin(), entities.end(), [](const Entity &left, const Entity &right) {
const int nameOrder = QString::localeAwareCompare(left.name, right.name);
return nameOrder == 0 ? left.entityId < right.entityId : nameOrder < 0;
});
beginResetModel();
m_entities = std::move(entities);
m_entityRows.clear();
for (int i = 0; i < m_entities.size(); ++i)
m_entityRows.insert(m_entities.at(i).entityId, i);
m_visibleRows.clear();
const QStringList words = m_filter.split(' ', Qt::SkipEmptyParts);
for (int i = 0; i < m_entities.size(); ++i)
{
bool matches = true;
for (const QString &word : words)
matches = matches && m_entities.at(i).searchText.contains(word, Qt::CaseInsensitive);
if (matches)
m_visibleRows.append(i);
}
endResetModel();
}
void HomeAssistantEntityModel::setFilter(const QString &filter)
{
const QString simplified = filter.simplified();
if (m_filter == simplified)
return;
m_filter = simplified;
rebuildVisibleRows();
}
const HomeAssistantEntityModel::Entity *HomeAssistantEntityModel::entity(
const QString &entityId) const
{
const auto row = m_entityRows.constFind(entityId);
return row == m_entityRows.cend() ? nullptr : &m_entities.at(*row);
}
void HomeAssistantEntityModel::rebuildVisibleRows()
{
beginResetModel();
m_visibleRows.clear();
const QStringList words = m_filter.split(' ', Qt::SkipEmptyParts);
for (int i = 0; i < m_entities.size(); ++i)
{
bool matches = true;
for (const QString &word : words)
matches = matches && m_entities.at(i).searchText.contains(word, Qt::CaseInsensitive);
if (matches)
m_visibleRows.append(i);
}
endResetModel();
}
HomeAssistantClient::HomeAssistantClient(QObject *parent)
: QObject(parent), m_settings(nullptr), m_entities(this)
{
}
void HomeAssistantClient::setSettings(AppSettings *settings)
{
if (m_settings == settings)
return;
if (m_settings)
disconnect(m_settings, &AppSettings::homeAssistantConfigurationChanged,
this, &HomeAssistantClient::loadConfiguration);
m_settings = settings;
if (m_settings)
connect(m_settings, &AppSettings::homeAssistantConfigurationChanged,
this, &HomeAssistantClient::loadConfiguration);
loadConfiguration();
emit settingsChanged();
}
void HomeAssistantClient::setEntitySearch(const QString &search)
{
if (m_entitySearch == search)
return;
m_entitySearch = search;
m_entities.setFilter(search);
emit entitySearchChanged();
}
QVariantMap HomeAssistantClient::entityDetails(const QString &entityId) const
{
const auto *entity = m_entities.entity(entityId);
if (!entity)
return {{QStringLiteral("entityId"), entityId},
{QStringLiteral("name"), entityId}};
return {{QStringLiteral("entityId"), entity->entityId},
{QStringLiteral("name"), entity->name},
{QStringLiteral("unit"), entity->unit},
{QStringLiteral("deviceClass"), entity->deviceClass},
{QStringLiteral("currentValue"), entity->state}};
}
QString HomeAssistantClient::defaultSeriesColor(int index) const
{
return defaultColor(index);
}
void HomeAssistantClient::testAndSaveConnection(const QString &baseUrl,
const QString &accessToken)
{
if (m_busy)
return;
m_pendingBaseUrl = normalizedBaseUrl(baseUrl);
m_pendingAccessToken = accessToken.trimmed();
const QUrl parsedUrl(m_pendingBaseUrl);
if (!parsedUrl.isValid()
|| (parsedUrl.scheme() != QStringLiteral("http")
&& parsedUrl.scheme() != QStringLiteral("https"))
|| parsedUrl.host().isEmpty())
{
setErrorText(tr("Enter a valid HTTP or HTTPS Home Assistant address."));
emit connectionTestFinished(false);
return;
}
if (m_pendingAccessToken.isEmpty())
{
setErrorText(tr("Enter a Home Assistant access token."));
emit connectionTestFinished(false);
return;
}
setErrorText({});
setStatusText(tr("Connecting to Home Assistant…"));
setBusy(true);
QUrl url = parsedUrl;
QString path = url.path();
if (path.endsWith('/'))
path.chop(1);
url.setPath(path + QStringLiteral("/api/"));
QNetworkReply *reply = m_network.get(authenticatedRequest(
url, m_pendingAccessToken.toUtf8()));
connect(reply, &QNetworkReply::finished, this, [this, reply] {
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray body = reply->readAll();
const bool success = reply->error() == QNetworkReply::NoError
&& status >= 200 && status < 300;
if (success)
{
m_baseUrl = m_pendingBaseUrl;
m_accessToken = m_pendingAccessToken;
persistConfiguration();
emit configurationChanged();
setErrorText({});
setStatusText(tr("Connected to Home Assistant."));
}
else
{
QString detail = reply->errorString();
const QJsonDocument errorDocument = QJsonDocument::fromJson(body);
if (errorDocument.isObject()
&& !errorDocument.object().value(QStringLiteral("message")).toString().isEmpty())
detail = errorDocument.object().value(QStringLiteral("message")).toString();
setErrorText(status == 401
? tr("Home Assistant rejected the access token.")
: tr("Could not connect to Home Assistant: %1").arg(detail));
setStatusText({});
}
setBusy(false);
reply->deleteLater();
emit connectionTestFinished(success);
if (success)
loadEntities();
});
}
void HomeAssistantClient::loadEntities()
{
if (!configured() || m_entitiesLoading)
return;
setErrorText({});
setEntitiesLoading(true);
setStatusText(tr("Loading Home Assistant entities…"));
QNetworkReply *reply = m_network.get(authenticatedRequest(apiUrl(QStringLiteral("api/states"))));
connect(reply, &QNetworkReply::finished, this, [this, reply] {
const QByteArray body = reply->readAll();
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (reply->error() != QNetworkReply::NoError || !document.isArray())
{
setErrorText(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 401
? tr("The Home Assistant credential is no longer valid.")
: tr("Could not load Home Assistant entities: %1")
.arg(reply->errorString()));
}
else
{
QList<HomeAssistantEntityModel::Entity> entities;
entities.reserve(document.array().size());
for (const QJsonValue &value : document.array())
{
const QJsonObject object = value.toObject();
const QString entityId = object.value(QStringLiteral("entity_id")).toString();
if (entityId.isEmpty())
continue;
const QJsonObject attributes = object.value(QStringLiteral("attributes")).toObject();
HomeAssistantEntityModel::Entity entity;
entity.entityId = entityId;
entity.name = attributes.value(QStringLiteral("friendly_name")).toString();
if (entity.name.isEmpty())
entity.name = entityId;
entity.unit = attributes.value(QStringLiteral("unit_of_measurement")).toString();
entity.deviceClass = attributes.value(QStringLiteral("device_class")).toString();
entity.state = object.value(QStringLiteral("state")).toString();
entity.searchText = entity.name + QLatin1Char(' ') + entity.entityId
+ QLatin1Char(' ') + entity.unit + QLatin1Char(' ')
+ entity.deviceClass;
entities.append(std::move(entity));
}
m_entities.setEntities(std::move(entities));
setErrorText({});
setStatusText(tr("Loaded %1 Home Assistant entities.")
.arg(m_entities.rowCount()));
}
setEntitiesLoading(false);
reply->deleteLater();
});
}
void HomeAssistantClient::saveCharts(const QVariantList &charts)
{
m_charts = charts;
persistConfiguration();
emit chartsChanged();
}
void HomeAssistantClient::clearConfiguration()
{
if (m_historyReply)
{
disconnect(m_historyReply, nullptr, this, nullptr);
m_historyReply->abort();
m_historyReply->deleteLater();
m_historyReply = nullptr;
setHistoryLoading(false);
}
m_baseUrl.clear();
m_accessToken.clear();
m_charts.clear();
m_historyPanels.clear();
m_entities.setEntities({});
if (m_settings)
{
m_settings->remove(QStringLiteral("homeAssistant"));
m_settings->sync();
emit m_settings->homeAssistantConfigurationChanged();
}
setStatusText({});
setErrorText({});
emit configurationChanged();
emit chartsChanged();
emit historyPanelsChanged();
}
void HomeAssistantClient::requestHistory(const QString &range, int offset)
{
if (!configured() || m_charts.isEmpty())
return;
if (m_historyReply)
{
disconnect(m_historyReply, nullptr, this, nullptr);
m_historyReply->abort();
m_historyReply->deleteLater();
m_historyReply = nullptr;
setHistoryLoading(false);
}
QStringList entityIds;
for (const QVariant &chartValue : m_charts)
{
const QVariantList series = chartValue.toMap().value(QStringLiteral("entities")).toList();
for (const QVariant &seriesValue : series)
{
const QString entityId = seriesValue.toMap().value(QStringLiteral("entityId")).toString();
if (!entityId.isEmpty() && !entityIds.contains(entityId))
entityIds.append(entityId);
}
}
if (entityIds.isEmpty())
return;
const QDateTime start = beginningOfPeriod(range, offset);
QDateTime end = endOfPeriod(range, start);
const QDateTime now = QDateTime::currentDateTime();
if (end > now)
end = now;
if (start > end)
return;
QUrl url = apiUrl(QStringLiteral("api/history/period/")
+ start.toUTC().toString(Qt::ISODate));
QUrlQuery query;
query.addQueryItem(QStringLiteral("end_time"), end.toUTC().toString(Qt::ISODate));
query.addQueryItem(QStringLiteral("filter_entity_id"), entityIds.join(','));
query.addQueryItem(QStringLiteral("minimal_response"), QString());
query.addQueryItem(QStringLiteral("no_attributes"), QString());
url.setQuery(query);
setErrorText({});
setHistoryLoading(true);
setStatusText(tr("Loading Home Assistant history…"));
QNetworkReply *reply = m_network.get(authenticatedRequest(url));
m_historyReply = reply;
connect(reply, &QNetworkReply::finished, this, [this, reply] {
if (m_historyReply != reply)
{
reply->deleteLater();
return;
}
m_historyReply = nullptr;
const QByteArray body = reply->readAll();
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(body, &parseError);
if (reply->error() != QNetworkReply::NoError || !document.isArray())
{
setErrorText(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 401
? tr("The Home Assistant credential is no longer valid.")
: tr("Could not load Home Assistant history: %1")
.arg(reply->errorString()));
}
else
{
rebuildHistoryPanels(document.array());
setErrorText({});
setStatusText(tr("Home Assistant history is up to date."));
}
setHistoryLoading(false);
reply->deleteLater();
});
}
QUrl HomeAssistantClient::apiUrl(const QString &relativePath) const
{
QUrl url(m_baseUrl);
QString path = url.path();
if (!path.endsWith('/'))
path += '/';
url.setPath(path + relativePath);
return url;
}
QNetworkRequest HomeAssistantClient::authenticatedRequest(const QUrl &url,
const QByteArray &token) const
{
QNetworkRequest request(url);
request.setRawHeader("Authorization", "Bearer " + (token.isEmpty()
? m_accessToken.toUtf8()
: token));
request.setRawHeader("Accept", "application/json");
request.setHeader(QNetworkRequest::UserAgentHeader,
QStringLiteral("EVChargerApp HomeAssistantClient"));
request.setTransferTimeout(20000);
return request;
}
void HomeAssistantClient::setBusy(bool busy)
{
if (m_busy == busy)
return;
m_busy = busy;
emit busyChanged();
}
void HomeAssistantClient::setEntitiesLoading(bool loading)
{
if (m_entitiesLoading == loading)
return;
m_entitiesLoading = loading;
emit entitiesLoadingChanged();
}
void HomeAssistantClient::setHistoryLoading(bool loading)
{
if (m_historyLoading == loading)
return;
m_historyLoading = loading;
emit historyLoadingChanged();
}
void HomeAssistantClient::setStatusText(const QString &text)
{
if (m_statusText == text)
return;
m_statusText = text;
emit statusTextChanged();
}
void HomeAssistantClient::setErrorText(const QString &text)
{
if (m_errorText == text)
return;
m_errorText = text;
emit errorTextChanged();
}
void HomeAssistantClient::persistConfiguration()
{
if (!m_settings)
return;
m_settings->setValue(urlSetting, m_baseUrl);
m_settings->setValue(tokenSetting, m_accessToken);
m_settings->setValue(chartsSetting,
QJsonDocument::fromVariant(m_charts).toJson(QJsonDocument::Compact));
m_settings->sync();
emit m_settings->homeAssistantConfigurationChanged();
}
void HomeAssistantClient::loadConfiguration()
{
const QString previousBaseUrl = m_baseUrl;
const QString previousAccessToken = m_accessToken;
const QVariantList previousCharts = m_charts;
m_baseUrl.clear();
m_accessToken.clear();
m_charts.clear();
if (m_settings)
{
m_baseUrl = normalizedBaseUrl(m_settings->value(urlSetting).toString());
m_accessToken = m_settings->value(tokenSetting).toString();
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(
m_settings->value(chartsSetting).toByteArray(), &error);
if (error.error == QJsonParseError::NoError && document.isArray())
m_charts = document.array().toVariantList();
}
if (previousBaseUrl != m_baseUrl || previousAccessToken != m_accessToken)
emit configurationChanged();
if (previousCharts != m_charts)
emit chartsChanged();
}
void HomeAssistantClient::rebuildHistoryPanels(const QJsonArray &history)
{
QHash<QString, QVariantList> pointsByEntity;
for (const QJsonValue &seriesValue : history)
{
const QJsonArray states = seriesValue.toArray();
QString entityId;
QVariantList points;
points.reserve(states.size());
for (const QJsonValue &stateValue : states)
{
const QJsonObject state = stateValue.toObject();
const QString receivedId = state.value(QStringLiteral("entity_id")).toString();
if (!receivedId.isEmpty())
entityId = receivedId;
bool numeric = false;
const double value = state.value(QStringLiteral("state")).toString().toDouble(&numeric);
const QString timestampText = state.value(QStringLiteral("last_changed")).toString(
state.value(QStringLiteral("last_updated")).toString());
const QDateTime timestamp = QDateTime::fromString(timestampText, Qt::ISODate);
if (numeric && timestamp.isValid())
points.append(QVariantMap{{QStringLiteral("t"), timestamp.toMSecsSinceEpoch() / 1000.0},
{QStringLiteral("value"), value}});
}
if (!entityId.isEmpty())
pointsByEntity.insert(entityId, points);
}
QVariantList panels;
for (const QVariant &chartValue : m_charts)
{
const QVariantMap chart = chartValue.toMap();
QVariantList dataSeries;
const QVariantList configuredSeries = chart.value(QStringLiteral("entities")).toList();
for (int i = 0; i < configuredSeries.size(); ++i)
{
const QVariantMap configuration = configuredSeries.at(i).toMap();
const QString entityId = configuration.value(QStringLiteral("entityId")).toString();
const auto *entity = m_entities.entity(entityId);
const QString label = configuration.value(QStringLiteral("label")).toString();
dataSeries.append(QVariantMap{
{QStringLiteral("name"), entityId},
{QStringLiteral("friendlyName"), !label.isEmpty() ? label
: entity ? entity->name : entityId},
{QStringLiteral("unit"), entity ? entity->unit : QString()},
{QStringLiteral("color"), configuration.value(QStringLiteral("color"),
defaultColor(i))},
{QStringLiteral("showLegend"), true},
{QStringLiteral("dataSet"), pointsByEntity.value(entityId)}});
}
panels.append(QVariantMap{{QStringLiteral("title"), chart.value(QStringLiteral("title"))},
{QStringLiteral("dataseries"), dataSeries}});
}
m_historyPanels = panels;
m_historyCurrentTime = QDateTime::currentSecsSinceEpoch();
emit historyPanelsChanged();
}