diff --git a/debian/control b/debian/control
index de77f4b2..3fdbb21d 100644
--- a/debian/control
+++ b/debian/control
@@ -210,6 +210,15 @@ Description: nymea integration plugin for elgato
This package contains the nymea integration plugin for devices from Elgato
+Package: nymea-plugin-espuino
+Architecture: any
+Depends: ${shlibs:Depends},
+ ${misc:Depends},
+Conflicts: nymea-plugins-translations (< 1.0.1)
+Description: nymea integration plugin for ESPuino
+ This package contains the nymea integration plugin for ESPuino devices.
+
+
Package: nymea-plugin-fastcom
Architecture: any
Depends: ${misc:Depends},
diff --git a/debian/nymea-plugin-espuino.install.in b/debian/nymea-plugin-espuino.install.in
new file mode 100644
index 00000000..6f7d89f9
--- /dev/null
+++ b/debian/nymea-plugin-espuino.install.in
@@ -0,0 +1,2 @@
+usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationpluginespuino.so
+espuino/translations/*qm usr/share/nymea/translations/
diff --git a/espuino/README.md b/espuino/README.md
new file mode 100644
index 00000000..81beda8b
--- /dev/null
+++ b/espuino/README.md
@@ -0,0 +1,29 @@
+# ESPuino
+
+This plugin allows to integrate nymea with [ESPunio](https://github.com/biologist79/ESPuino),
+a software for Rfid-controlled music players running on ESP32 hardware.
+
+ESPuino boxes can't be bought off the shelf, but there's a (mostly
+German speaking) community in the [ESPuino forum](https://forum.espuino.de/)
+that provides a lot of documentation and ideas for building your own custom
+ESPuino.
+
+## Usage
+
+As a prerequisite, ESPuino has to be compiled with WiFi and MQTT
+enabled and the ESPuino must have been set up to connect to your home
+WiFi. For the mDNS based auto discovery to work, the hostname must start with
+`espuino`.
+
+Then the ESPuino can be added to nymea.
+
+## Supported features
+
+- Display current title and cover art
+- Control volume
+- Play/pause
+- Lock hardware controls
+- Browse SD-Card and start playback
+- Control LED brightness
+- Configure sleep or repeat modes
+- Show battery status
diff --git a/espuino/espuino.pro b/espuino/espuino.pro
new file mode 100644
index 00000000..efefd13f
--- /dev/null
+++ b/espuino/espuino.pro
@@ -0,0 +1,12 @@
+include(../plugins.pri)
+
+QT += network \
+ websockets
+
+PKGCONFIG += nymea-mqtt
+
+SOURCES += \
+ integrationpluginespuino.cpp
+
+HEADERS += \
+ integrationpluginespuino.h
diff --git a/espuino/integrationpluginespuino.cpp b/espuino/integrationpluginespuino.cpp
new file mode 100644
index 00000000..42d3054b
--- /dev/null
+++ b/espuino/integrationpluginespuino.cpp
@@ -0,0 +1,556 @@
+/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+*
+* Copyright 2013 - 2022, nymea GmbH
+* Contact: contact@nymea.io
+*
+* This file is part of nymea.
+* This project including source code and documentation is protected by
+* copyright law, and remains the property of nymea GmbH. All rights, including
+* reproduction, publication, editing and translation, are reserved. The use of
+* this project is subject to the terms of a license agreement to be concluded
+* with nymea GmbH in accordance with the terms of use of nymea GmbH, available
+* under https://nymea.io/license
+*
+* GNU Lesser General Public License Usage
+* Alternatively, this project may be redistributed and/or modified under the
+* terms of the GNU Lesser General Public License as published by the Free
+* Software Foundation; version 3. This project 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
+* Lesser General Public License for more details.
+*
+* You should have received a copy of the GNU Lesser General Public License
+* along with this project. If not, see .
+*
+* For any further details and any questions please contact us under
+* contact@nymea.io or see our FAQ/Licensing Information on
+* https://nymea.io/license/faq
+*
+* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+
+#include "integrationpluginespuino.h"
+
+#include "integrations/integrationplugin.h"
+
+#include "plugininfo.h"
+
+#include "network/networkaccessmanager.h"
+#include "network/mqtt/mqttprovider.h"
+#include "network/mqtt/mqttchannel.h"
+#include "network/zeroconf/zeroconfservicebrowser.h"
+#include "platform/platformzeroconfcontroller.h"
+
+#include
+
+#include
+#include
+#include
+#include
+
+void IntegrationPluginEspuino::init()
+{
+ m_zeroConfBrowser = hardwareManager()->zeroConfController()->createServiceBrowser("_http._tcp");
+}
+
+void IntegrationPluginEspuino::discoverThings(ThingDiscoveryInfo *info)
+{
+ foreach (const ZeroConfServiceEntry &entry, m_zeroConfBrowser->serviceEntries()) {
+ QRegExp match("espuino.*");
+ if (!match.exactMatch(entry.name())) {
+ continue;
+ }
+
+ qCDebug(dcESPuino()) << "Found device:" << entry;
+ ThingDescriptor descriptor(info->thingClassId(), entry.hostName(), entry.hostAddress().toString());
+ ParamList params;
+ params << Param(espuinoThingHostnameParamTypeId, entry.hostName());
+ descriptor.setParams(params);
+
+ Things existingThings = myThings().filterByParam(espuinoThingHostnameParamTypeId, entry.hostName());
+ if (existingThings.count() == 1) {
+ qCDebug(dcESPuino()) << "This device already exists in the system!";
+ descriptor.setThingId(existingThings.first()->id());
+ }
+
+ info->addThingDescriptor(descriptor);
+ }
+
+ info->finish(Thing::ThingErrorNoError);
+}
+
+void IntegrationPluginEspuino::setupThing(ThingSetupInfo *info)
+{
+ Thing *thing = info->thing();
+
+ MqttChannel *channel;
+ if (myThings().findByParams(ParamList() << Param(espuinoThingHostnameParamTypeId, thing->paramValue(espuinoThingHostnameParamTypeId).toString())) == nullptr){
+ qCDebug(dcESPuino) << "Creating MQTT channel for new device.";
+
+ channel = hardwareManager()->mqttProvider()->createChannel(QHostAddress(getHost(thing)), {"Cmnd/ESPuino", "State/ESPuino"});
+ if (!channel) {
+ qCWarning(dcESPuino) << "Failed to create MQTT channel.";
+ info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Error creating MQTT channel. Please check MQTT server settings."));
+ return;
+ }
+
+ qCInfo(dcESPuino) << "Reconfiguring MQTT settings via Websocket.";
+ QWebSocket *ws = new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, info);
+ connect(ws, &QWebSocket::connected, info, [channel, ws](){
+ QJsonDocument jsonRequest{QJsonObject
+ {
+ {"mqtt", QJsonObject{{"mqttEnable", "1"},
+ {"mqttClientId", channel->clientId()},
+ {"mqttServer", channel->serverAddress().toString()},
+ {"mqttUser", channel->username()},
+ {"mqttPwd", channel->password()},
+ {"mqttPort", QString::number(channel->serverPort())}}}
+ }};
+ ws->sendTextMessage(jsonRequest.toJson(QJsonDocument::Compact));
+ });
+ connect(ws, &QWebSocket::textMessageReceived, info, [this, info, thing, channel, ws](const QString &message){
+ ws->close();
+
+ QJsonParseError parseError;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(message.toUtf8(), &parseError);
+ if (parseError.error != QJsonParseError::NoError) {
+ qCWarning(dcESPuino()) << "Json parse error:" << parseError.error << parseError.errorString() << "Received:" << message;
+ info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Failed to configure MQTT via Websocket."));
+ hardwareManager()->mqttProvider()->releaseChannel(channel);
+ return;
+ }
+ if (jsonDoc.object().value("status").toString() != "ok") {
+ qCWarning(dcESPuino()) << "Failed to configure MQTT via websocket. Received:" << message;
+ info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("Failed to configure MQTT via Websocket."));
+ hardwareManager()->mqttProvider()->releaseChannel(channel);
+ return;
+ }
+
+ pluginStorage()->beginGroup(thing->id().toString());
+ pluginStorage()->setValue("clientId", channel->clientId());
+ pluginStorage()->setValue("username", channel->username());
+ pluginStorage()->setValue("password", channel->password());
+ pluginStorage()->endGroup();
+
+ qCInfo(dcESPuino) << "Restarting box to apply new MQTT config.";
+ QUrl url(QString("http://%1/restart").arg(getHost(thing)));
+ QNetworkRequest request(url);
+ QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+
+ info->finish(Thing::ThingErrorNoError);
+ });
+ QUrl url(QString("ws://%1/ws").arg(getHost(thing)));
+ ws->open(url);
+ } else {
+ qCDebug(dcESPuino) << "Creating MQTT channel for existing device.";
+ pluginStorage()->beginGroup(thing->id().toString());
+ QString clientId = pluginStorage()->value("clientId").toString();
+ QString username = pluginStorage()->value("username").toString();
+ QString password = pluginStorage()->value("password").toString();
+ pluginStorage()->endGroup();
+ channel = hardwareManager()->mqttProvider()->createChannel(clientId, username, password, QHostAddress(getHost(thing)), {"Cmnd/ESPuino", "State/ESPuino"});
+ if (!channel) {
+ qCWarning(dcESPuino) << "Failed to create MQTT channel.";
+ info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Error creating MQTT channel. Please check MQTT server settings."));
+ return;
+ }
+ info->finish(Thing::ThingErrorNoError);
+ }
+
+ m_mqttChannels.insert(thing, channel);
+ connect(channel, &MqttChannel::clientConnected, this, &IntegrationPluginEspuino::onClientConnected);
+ connect(channel, &MqttChannel::clientDisconnected, this, &IntegrationPluginEspuino::onClientDisconnected);
+ connect(channel, &MqttChannel::publishReceived, this, &IntegrationPluginEspuino::onPublishReceived);
+}
+
+void IntegrationPluginEspuino::thingRemoved(Thing *thing)
+{
+ qCDebug(dcESPuino) << "Device removed" << thing->name();
+ if (m_mqttChannels.contains(thing)) {
+ qCDebug(dcESPuino) << "Releasing MQTT channel";
+ MqttChannel* channel = m_mqttChannels.take(thing);
+ hardwareManager()->mqttProvider()->releaseChannel(channel);
+ }
+}
+
+void IntegrationPluginEspuino::onClientConnected(MqttChannel *channel)
+{
+ Thing *thing = m_mqttChannels.key(channel);
+ qCDebug(dcESPuino) << "Thing connected" << thing->name();
+ if (!thing) {
+ qCWarning(dcESPuino) << "Received a MQTT connected message from a client but don't have a matching thing";
+ return;
+ }
+ thing->setStateValue(espuinoConnectedStateTypeId, true);
+}
+
+void IntegrationPluginEspuino::onClientDisconnected(MqttChannel *channel)
+{
+ Thing *thing = m_mqttChannels.key(channel);
+ qCDebug(dcESPuino) << "Thing disconnected" << thing->name();
+ if (!thing) {
+ qCWarning(dcESPuino) << "Received a MQTT disconnected message from a client but don't have a matching thing";
+ return;
+ }
+ thing->setStateValue(espuinoConnectedStateTypeId, false);
+}
+
+void IntegrationPluginEspuino::onPublishReceived(MqttChannel *channel, const QString &topic, const QByteArray &payload)
+{
+ qCDebug(dcESPuino) << "Publish received" << topic << payload;
+
+ Thing *thing = m_mqttChannels.key(channel);
+ if (!thing) {
+ qCWarning(dcESPuino) << "Received a publish message from a client but don't have a matching thing";
+ return;
+ }
+
+ if (topic == "State/ESPuino/State") {
+ thing->setStateValue(espuinoConnectedStateTypeId, payload == "Online");
+ } else if (topic == "State/ESPuino/Playmode") {
+ thing->setStateValue(espuinoPlaybackStatusStateTypeId, payload == "0" ? "Stopped" : "Playing");
+
+ QString playmode = "None";
+ if (payload == "0") {
+ playmode = "None";
+ } else if (payload == "1") {
+ playmode = "Single track";
+ } else if (payload == "2") {
+ playmode = "Single track (loop)";
+ } else if (payload == "12") {
+ playmode = "Single track of a directory (random). Followed by sleep.";
+ } else if (payload == "3") {
+ playmode = "Audiobook";
+ } else if (payload == "4") {
+ playmode = "Audiobook (loop)";
+ } else if (payload == "5") {
+ playmode = "All tracks of a directory (sorted alph.)";
+ } else if (payload == "6") {
+ playmode = "All tracks of a directory (random)";
+ } else if (payload == "7") {
+ playmode = "All tracks of a directory (sorted alph., loop)";
+ } else if (payload == "9") {
+ playmode = "All tracks of a directory (random, loop)";
+ } else if (payload == "8") {
+ playmode = "Webradio";
+ } else if (payload == "11") {
+ playmode = "List (files from SD and/or webstreams) from local .m3u-File";
+ } else if (payload == "10") {
+ playmode = "Busy";
+ } else {
+ qCWarning(dcESPuino) << "Unknown playmode received" << payload;
+ }
+ thing->setStateValue(espuinoPlaymodeStateTypeId, playmode);
+ } else if (topic == "State/ESPuino/Loudness") {
+ bool ok;
+ int volume = payload.toInt(&ok);
+ if (ok) {
+ thing->setStateValue(espuinoVolumeStateTypeId, volume);
+ } else {
+ qCWarning(dcESPuino) << "Failed to read numeric volume value" << payload;
+ thing->setStateValue(espuinoVolumeStateTypeId, 0);
+ }
+ } else if (topic == "State/ESPuino/Track") {
+ thing->setStateValue(espuinoTitleStateTypeId, payload);
+ } else if (topic == "State/ESPuino/CoverChanged") {
+ thing->setStateValue(espuinoArtworkStateTypeId, QString("http://%1/cover?%2").arg(getHost(thing)).arg(QDateTime::currentMSecsSinceEpoch()));
+ } else if (topic == "State/ESPuino/LedBrightness") {
+ bool ok;
+ int brightness = payload.toInt(&ok);
+ if (ok) {
+ thing->setStateValue(espuinoBrightnessStateTypeId, brightness);
+ } else {
+ qCWarning(dcESPuino) << "Failed to read numeric brightness value" << payload;
+ thing->setStateValue(espuinoBrightnessStateTypeId, 0);
+ }
+ } else if (topic == "State/ESPuino/RepeatMode") {
+ if (payload == "3") {
+ thing->setStateValue(espuinoRepeatStateTypeId, "All");
+ } if (payload == "1") {
+ thing->setStateValue(espuinoRepeatStateTypeId, "One");
+ } else {
+ thing->setStateValue(espuinoRepeatStateTypeId, "None");
+ }
+ } else if (topic == "State/ESPuino/WifiRssi") {
+ bool ok;
+ int rssi = payload.toInt(&ok);
+ if (ok) {
+ thing->setStateValue(espuinoSignalStrengthStateTypeId, qMin(100, qMax(0, (rssi + 100) * 2)));
+ } else {
+ thing->setStateValue(espuinoSignalStrengthStateTypeId, 0);
+ }
+ } else if (topic == "State/ESPuino/LockControl") {
+ thing->setStateValue(espuinoChildLockStateTypeId, payload == "ON");
+ } else if (topic == "State/ESPuino/SleepTimer") {
+ if (payload == "EOP") {
+ thing->setStateValue(espuinoSleepmodeStateTypeId, "End of playlist");
+ } else if (payload == "EOT") {
+ thing->setStateValue(espuinoSleepmodeStateTypeId, "End of track");
+ } else if (payload == "EO5T") {
+ thing->setStateValue(espuinoSleepmodeStateTypeId, "End of five tracks");
+ } else if (payload == "0") {
+ thing->setStateValue(espuinoSleepmodeStateTypeId, "None");
+ } else {
+ bool ok;
+ int timer = payload.toInt(&ok);
+ if (ok) {
+ thing->setStateValue(espuinoSleepmodeStateTypeId, "Timer");
+ thing->setStateValue(espuinoSleeptimerStateTypeId, timer);
+ } else {
+ qCWarning(dcESPuino) << "Failed to read numeric sleep timer value" << payload;
+ thing->setStateValue(espuinoSleepmodeStateTypeId, "None");
+ }
+ }
+ } else if (topic == "State/ESPuino/Battery") {
+ bool ok;
+ float battery = payload.toFloat(&ok);
+ if (ok) {
+ thing->setStateValue(espuinoBatteryLevelStateTypeId, battery);
+ thing->setStateValue(espuinoBatteryCriticalStateTypeId, battery < 5.0f);
+ } else {
+ qCWarning(dcESPuino) << "Failed to read numeric battery level value" << payload;
+ thing->setStateValue(espuinoBatteryLevelStateTypeId, 0);
+ thing->setStateValue(espuinoBatteryCriticalStateTypeId, false);
+ }
+ }
+
+ // Finish pending action.
+ QPointer thingActionInfo = m_pendingActions.take(topic);
+ if (!thingActionInfo.isNull()) {
+ thingActionInfo->finish(Thing::ThingErrorNoError);
+ }
+}
+
+void IntegrationPluginEspuino::executeAction(ThingActionInfo *info)
+{
+ Thing *thing = info->thing();
+ Action action = info->action();
+
+ MqttChannel *channel = m_mqttChannels.value(thing);
+ if (!channel) {
+ qCWarning(dcESPuino) << "No valid MQTT channel for thing" << thing->name();
+ return info->finish(Thing::ThingErrorThingNotFound);
+ }
+
+ // See: https://github.com/biologist79/ESPuino#mqtt-topics-and-their-ranges
+ QString topic;
+ QByteArray payload;
+ if (action.actionTypeId() == espuinoVolumeActionTypeId) {
+ topic = "Cmnd/ESPuino/Loudness";
+ payload = QByteArray::number(action.param(espuinoVolumeActionVolumeParamTypeId).value().toInt());
+ m_pendingActions.insert("State/ESPuino/Loudness", info);
+ } else if (action.actionTypeId() == espuinoIncreaseVolumeActionTypeId) {
+ topic = "Cmnd/ESPuino/Loudness";
+ payload = QByteArray::number(thing->stateValue(espuinoVolumeStateTypeId).toInt() + 1);
+ m_pendingActions.insert("State/ESPuino/Loudness", info);
+ } else if (action.actionTypeId() == espuinoDecreaseVolumeActionTypeId) {
+ topic = "Cmnd/ESPuino/Loudness";
+ payload = QByteArray::number(thing->stateValue(espuinoVolumeStateTypeId).toInt() - 1);
+ m_pendingActions.insert("State/ESPuino/Loudness", info);
+ } else if (action.actionTypeId() == espuinoStopActionTypeId) {
+ topic = "Cmnd/ESPuino/TrackControl";
+ payload = "1";
+ m_pendingActions.insert("State/ESPuino/TrackControl", info);
+ } else if (action.actionTypeId() == espuinoSkipNextActionTypeId) {
+ topic = "Cmnd/ESPuino/TrackControl";
+ payload = "4";
+ m_pendingActions.insert("State/ESPuino/TrackControl", info);
+ } else if (action.actionTypeId() == espuinoSkipBackActionTypeId) {
+ topic = "Cmnd/ESPuino/TrackControl";
+ payload = "5";
+ m_pendingActions.insert("State/ESPuino/TrackControl", info);
+ } else if (action.actionTypeId() == espuinoPlayActionTypeId) {
+ topic = "Cmnd/ESPuino/TrackControl";
+ payload = "3";
+ m_pendingActions.insert("State/ESPuino/TrackControl", info);
+ } else if (action.actionTypeId() == espuinoPauseActionTypeId) {
+ topic = "Cmnd/ESPuino/TrackControl";
+ payload = "3";
+ m_pendingActions.insert("State/ESPuino/TrackControl", info);
+ } else if (action.actionTypeId() == espuinoBrightnessActionTypeId) {
+ topic = "Cmnd/ESPuino/LedBrightness";
+ payload = QByteArray::number(action.param(espuinoBrightnessActionBrightnessParamTypeId).value().toInt());
+ m_pendingActions.insert("State/ESPuino/LedBrightness", info);
+ } else if (action.actionTypeId() == espuinoRepeatActionTypeId) {
+ topic = "Cmnd/ESPuino/RepeatMode";
+ QString repeat = action.param(espuinoRepeatActionRepeatParamTypeId).value().toString();
+ if (repeat == "One") {
+ payload = "1";
+ } else if (repeat == "All") {
+ payload = "3";
+ } else {
+ payload = "0";
+ }
+ m_pendingActions.insert("State/ESPuino/RepeatMode", info);
+ } else if (action.actionTypeId() == espuinoChildLockActionTypeId) {
+ topic = "Cmnd/ESPuino/LockControls";
+ payload = action.param(espuinoChildLockActionChildLockParamTypeId).value().toBool() ? "ON" : "OFF";
+ m_pendingActions.insert("State/ESPuino/LockControls", info);
+ } else if (action.actionTypeId() == espuinoSleepmodeActionTypeId) {
+ topic = "Cmnd/ESPuino/SleepTimer";
+ QString sleepmode = action.param(espuinoSleepmodeActionSleepmodeParamTypeId).value().toString();
+ if (sleepmode == "None") {
+ payload = "0";
+ } else if (sleepmode == "End of playlist") {
+ payload = "EOP";
+ } else if (sleepmode == "End of track") {
+ payload = "EOT";
+ } else if (sleepmode == "End of five tracks") {
+ payload = "EO5T";
+ } else {
+ payload = QByteArray::number(thing->stateValue(espuinoSleeptimerStateTypeId).toInt());
+ }
+ m_pendingActions.insert("State/ESPuino/SleepTimer", info);
+ } else if (action.actionTypeId() == espuinoSleeptimerActionTypeId) {
+ thing->setStateValue(espuinoSleeptimerStateTypeId, action.param(espuinoSleeptimerActionSleeptimerParamTypeId).value().toUInt());
+ info->finish(Thing::ThingErrorNoError);
+ }
+
+ if (!topic.isEmpty()) {
+ qCDebug(dcESPuino) << "Publishing:" << topic << payload;
+ channel->publish(topic, payload);
+ }
+ return;
+}
+
+void IntegrationPluginEspuino::browseThing(BrowseResult *result)
+{
+ QUrlQuery id(result->itemId());
+ browseThing(result, id.queryItemValue("path"));
+}
+
+void IntegrationPluginEspuino::browseThing(BrowseResult *result, const QString &path)
+{
+ QUrl url(QString("http://%1/explorer?path=%2").arg(getHost(result->thing())).arg(path.isEmpty() ? "/" : path));
+ QNetworkRequest request(url);
+ QNetworkReply *reply = hardwareManager()->networkManager()->get(request);
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, result, [result, reply, path, this]() {
+ if (reply->error() != QNetworkReply::NoError) {
+ qCWarning(dcESPuino()) << "Error fetching paths";
+ result->finish(Thing::ThingErrorHardwareNotAvailable);
+ return;
+ }
+ QByteArray data = reply->readAll();
+ QJsonParseError error;
+ QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
+ if (error.error != QJsonParseError::NoError) {
+ qCWarning(dcESPuino()) << "Error parsing json" << data;
+ result->finish(Thing::ThingErrorHardwareFailure);
+ return;
+ }
+
+ //qCDebug(dcESPuino()) << "Reply:" << qUtf8Printable(jsonDoc.toJson());
+ QVariantList variantList = jsonDoc.toVariant().toList();
+ foreach (const QVariant &element, variantList) {
+ QVariantMap variantMap = element.toMap();
+ QUrlQuery id;
+ id.addQueryItem("name", variantMap.value("name").toString());
+ id.addQueryItem("path", path + "/" + variantMap.value("name").toString());
+ if (variantMap.value("dir").toBool()) {
+ id.addQueryItem("playmode", QString::number(5));
+ id.addQueryItem("type", "dir");
+ } else if (variantMap.value("name").toString().contains(QRegExp("\\.(:?mp3|ogg|wav|wma|acc|m4a|flac)$", Qt::CaseInsensitive))) {
+ id.addQueryItem("playmode", QString::number(1));
+ id.addQueryItem("type", "audiofile");
+ } else if (variantMap.value("name").toString().endsWith(".m3u", Qt::CaseInsensitive)) {
+ id.addQueryItem("playmode", QString::number(11));
+ id.addQueryItem("type", "playlist");
+ }
+ result->addItem(browserItemFromQuery(id));
+ }
+ result->finish(Thing::ThingErrorNoError);
+ });
+}
+
+void IntegrationPluginEspuino::browserItem(BrowserItemResult *result)
+{
+ QUrlQuery id(result->itemId());
+ result->finish(browserItemFromQuery(id));
+}
+
+BrowserItem IntegrationPluginEspuino::browserItemFromQuery(const QUrlQuery &id)
+{
+ BrowserItem item;
+ item.setDisplayName(id.queryItemValue("name"));
+ if (id.queryItemValue("type") == "dir") {
+ item.setId(id.toString());
+ item.setIcon(BrowserItem::BrowserIconFolder);
+ item.setBrowsable(true);
+ item.setActionTypeIds({espuinoPlayAllBrowserItemActionTypeId});
+ } else if (id.queryItemValue("type") == "audiofile") {
+ item.setId(id.toString());
+ item.setIcon(BrowserItem::BrowserIconMusic);
+ item.setExecutable(true);
+ } else if (id.queryItemValue("type") == "playlist") {
+ item.setId(id.toString());
+ item.setIcon(BrowserItem::BrowserIconDocument);
+ item.setExecutable(true);
+ } else {
+ item.setId(id.toString());
+ item.setIcon(BrowserItem::BrowserIconFile);
+ }
+ return item;
+}
+
+void IntegrationPluginEspuino::IntegrationPluginEspuino::executeBrowserItem(BrowserActionInfo *info)
+{
+ Thing *thing = info->thing();
+ BrowserAction action = info->browserAction();
+
+ QUrl url(QString("http://%1/exploreraudio?%2").arg(getHost(thing)).arg(action.itemId()));
+ qCInfo(dcESPuino) << "Starting playback" << url.toString();
+ QNetworkRequest request(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QByteArray());
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [reply]() {
+ if (reply->error() != QNetworkReply::NoError) {
+ qCWarning(dcESPuino()) << "Fail to execute play action";
+ }
+ });
+}
+
+void IntegrationPluginEspuino::executeBrowserItemAction(BrowserItemActionInfo *info)
+{
+ Thing *thing = info->thing();
+ BrowserItemAction action = info->browserItemAction();
+
+ if (action.actionTypeId() == espuinoPlayAllBrowserItemActionTypeId) {
+ QUrl url(QString("http://%1/exploreraudio?%2").arg(getHost(thing)).arg(action.itemId()));
+ qCInfo(dcESPuino) << "Starting playback" << url.toString();
+ QNetworkRequest request(url);
+ request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
+ QNetworkReply *reply = hardwareManager()->networkManager()->post(request, QByteArray());
+ connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
+ connect(reply, &QNetworkReply::finished, this, [reply]() {
+ if (reply->error() != QNetworkReply::NoError) {
+ qCWarning(dcESPuino()) << "Fail to execute play action";
+ }
+ });
+ }
+}
+
+QString IntegrationPluginEspuino::getHost(Thing *thing) const
+{
+ QString hostName = thing->paramValue(espuinoThingHostnameParamTypeId).toString();
+ ZeroConfServiceEntry zeroConfEntry;
+ foreach (const ZeroConfServiceEntry &entry, m_zeroConfBrowser->serviceEntries()) {
+ if (hostName == entry.hostName()) {
+ zeroConfEntry = entry;
+ }
+ }
+ QString host;
+ pluginStorage()->beginGroup(thing->id().toString());
+ if (zeroConfEntry.isValid()) {
+ host = zeroConfEntry.hostAddress().toString();
+ pluginStorage()->setValue("cachedAddress", host);
+ } else if (pluginStorage()->contains("cachedAddress")){
+ host = pluginStorage()->value("cachedAddress").toString();
+ } else {
+ qCWarning(dcESPuino()) << "Unable to determine IP address for:" << hostName;
+ }
+ pluginStorage()->endGroup();
+
+ return host;
+}
diff --git a/espuino/integrationpluginespuino.h b/espuino/integrationpluginespuino.h
new file mode 100644
index 00000000..3e4d224c
--- /dev/null
+++ b/espuino/integrationpluginespuino.h
@@ -0,0 +1,78 @@
+/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+*
+* Copyright 2013 - 2022, nymea GmbH
+* Contact: contact@nymea.io
+*
+* This file is part of nymea.
+* This project including source code and documentation is protected by
+* copyright law, and remains the property of nymea GmbH. All rights, including
+* reproduction, publication, editing and translation, are reserved. The use of
+* this project is subject to the terms of a license agreement to be concluded
+* with nymea GmbH in accordance with the terms of use of nymea GmbH, available
+* under https://nymea.io/license
+*
+* GNU Lesser General Public License Usage
+* Alternatively, this project may be redistributed and/or modified under the
+* terms of the GNU Lesser General Public License as published by the Free
+* Software Foundation; version 3. This project 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
+* Lesser General Public License for more details.
+*
+* You should have received a copy of the GNU Lesser General Public License
+* along with this project. If not, see .
+*
+* For any further details and any questions please contact us under
+* contact@nymea.io or see our FAQ/Licensing Information on
+* https://nymea.io/license/faq
+*
+* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+
+#ifndef INTEGRATIONPLUGINESPUINO_H
+#define INTEGRATIONPLUGINESPUINO_H
+
+#include "integrations/integrationplugin.h"
+#include "plugintimer.h"
+
+#include
+
+#include "extern-plugininfo.h"
+
+class MqttChannel;
+class ZeroConfServiceBrowser;
+
+class IntegrationPluginEspuino : public IntegrationPlugin
+{
+ Q_OBJECT
+
+ Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginespuino.json")
+ Q_INTERFACES(IntegrationPlugin)
+
+public:
+ void init() override;
+ void discoverThings(ThingDiscoveryInfo *info) override;
+ void setupThing(ThingSetupInfo *info) override;
+ void thingRemoved(Thing *thing) override;
+ void executeAction(ThingActionInfo *info) override;
+ void browseThing(BrowseResult *result) override;
+ void browseThing(BrowseResult *result, const QString &path);
+ void browserItem(BrowserItemResult *result) override;
+ BrowserItem browserItemFromQuery(const QUrlQuery &query);
+ void executeBrowserItem(BrowserActionInfo *info) override;
+ void executeBrowserItemAction(BrowserItemActionInfo *info) override;
+
+private slots:
+ void onClientConnected(MqttChannel *channel);
+ void onClientDisconnected(MqttChannel *channel);
+ void onPublishReceived(MqttChannel* channel, const QString &topic, const QByteArray &payload);
+
+private:
+ QString getHost(Thing *thing) const;
+
+ ZeroConfServiceBrowser *m_zeroConfBrowser;
+ QHash m_mqttChannels;
+ QHash m_ipAddressParamTypeMap;
+ QMap> m_pendingActions;
+};
+
+#endif // INTEGRATIONPLUGINESPUINO_H
diff --git a/espuino/integrationpluginespuino.json b/espuino/integrationpluginespuino.json
new file mode 100644
index 00000000..ee116a36
--- /dev/null
+++ b/espuino/integrationpluginespuino.json
@@ -0,0 +1,261 @@
+{
+ "name": "ESPuino",
+ "displayName": "ESPuino",
+ "id": "5f8ba72b-d3fb-4efe-952d-a927bed20cfe",
+ "vendors": [
+ {
+ "name": "ESPuino",
+ "displayName": "ESPuino",
+ "id": "58c8eb30-98a4-44fd-aaac-cb2a7aae7e8a",
+ "thingClasses": [
+ {
+ "id": "ee24ce2b-d34a-4c2c-85f3-9d895d17f414",
+ "name": "espuino",
+ "displayName": "ESPuino",
+ "createMethods": ["discovery"],
+ "interfaces": ["mediaplayer", "mediametadataprovider", "volumecontroller", "wirelessconnectable", "battery", "childlock"],
+ "browsable": true,
+ "paramTypes": [
+ {
+ "id": "2a9c9427-3e4e-4473-805e-c25242cfc621",
+ "name": "hostname",
+ "displayName": "Hostname",
+ "type": "QString",
+ "readOnly": true
+ }
+ ],
+ "stateTypes": [
+ {
+ "id": "edbff474-0cdc-488c-a9e9-970b25ce7548",
+ "name": "connected",
+ "displayName": "Connected",
+ "type": "bool",
+ "defaultValue": false,
+ "cached": false
+ },
+ {
+ "id": "57bd1bab-d872-4315-b53e-1157fe3889d4",
+ "name": "signalStrength",
+ "displayName": "Signal strength",
+ "type": "uint",
+ "unit": "Percentage",
+ "minValue": 0,
+ "maxValue": 100,
+ "defaultValue": 0,
+ "cached": false
+ },
+ {
+ "id": "bee497e6-a320-458a-9006-ddfe4c7c37c2",
+ "name": "batteryCritical",
+ "displayName": "Battery critical",
+ "type": "bool",
+ "defaultValue": false,
+ "cached": false
+ },
+ {
+ "id": "9fd8f882-8240-492f-8c6b-b5477e26623e",
+ "name": "batteryLevel",
+ "displayName": "Battery level",
+ "type": "int",
+ "unit": "Percentage",
+ "minValue": 0,
+ "maxValue": 100,
+ "defaultValue": 0,
+ "cached": false
+ },
+ {
+ "id": "dd1cfb1f-fec4-4035-9c02-562a6fba683d",
+ "name": "playbackStatus",
+ "displayName": "Playback status",
+ "type": "QString",
+ "possibleValues": ["Playing", "Paused", "Stopped"],
+ "defaultValue": "Stopped",
+ "writable": false,
+ "cached": false
+ },
+ {
+ "id": "a274e048-9820-444a-b5de-a3a421c855a2",
+ "name": "title",
+ "displayName": "Title",
+ "type": "QString",
+ "defaultValue": "",
+ "cached": false
+ },
+ {
+ "id": "5acce950-cdac-44ea-963d-0635afcabdca",
+ "name": "playmode",
+ "displayName": "Playmode",
+ "type": "QString",
+ "possibleValues": [
+ "None",
+ "Single track",
+ "Single track (loop)",
+ "Single track of a directory (random). Followed by sleep.",
+ "Audiobook",
+ "Audiobook (loop)",
+ "All tracks of a directory (sorted alph.)",
+ "All tracks of a directory (random)",
+ "All tracks of a directory (sorted alph., loop)",
+ "All tracks of a directory (random, loop)",
+ "Webradio",
+ "List (files from SD and/or webstreams) from local .m3u-File",
+ "Busy"],
+ "defaultValue": "None",
+ "writable": false,
+ "cached": false
+ },
+ {
+ "id": "27b5ff3b-bd60-411f-b8e3-b1c8f6897bec",
+ "name": "repeat",
+ "displayName": "Repeat mode",
+ "type": "QString",
+ "possibleValues": [
+ "None",
+ "One",
+ "All"
+ ],
+ "displayNameAction": "Set repeat",
+ "defaultValue": "None",
+ "writable": true,
+ "cached": false
+ },
+ {
+ "id": "93a5098a-a41a-46ee-8613-266d4f9ed69a",
+ "displayName": "Volume",
+ "name": "volume",
+ "type": "int",
+ "minValue": "0",
+ "maxValue": "21",
+ "displayNameAction": "Set volume",
+ "defaultValue": 0,
+ "writable": true,
+ "cached": false
+ },
+ {
+ "id": "595908c1-57b1-4303-a0ca-4c64f3cb1907",
+ "name": "brightness",
+ "displayName": "LED brightness",
+ "type": "int",
+ "minValue": 0,
+ "maxValue": 255,
+ "displayNameAction": "Set LED brightness",
+ "writable": true,
+ "defaultValue": 0,
+ "ioType": "analogOutput",
+ "cached": false
+ },
+ {
+ "id": "03e7a5e2-9434-47e8-91ad-03610601b925",
+ "name": "childLock",
+ "displayName": "Locl controls",
+ "type": "bool",
+ "displayNameAction": "Enable/disable control lock",
+ "writable": true,
+ "defaultValue": false,
+ "cached": false
+ },
+ {
+ "id": "19bd1456-2e4f-444a-a586-75bf6cc9fb73",
+ "name": "sleepmode",
+ "displayName": "Sleepmode",
+ "type": "QString",
+ "possibleValues": ["None", "End of playlist", "End of track", "End of five tracks", "Timer"],
+ "defaultValue": "None",
+ "displayNameAction": "Set Sleepmode",
+ "writable": true,
+ "cached": false
+ },
+ {
+ "id": "4c7594e4-70e7-4f0c-aae4-02e3993ffa1d",
+ "name": "sleeptimer",
+ "displayName": "Sleeptimer",
+ "type": "uint",
+ "unit": "Minutes",
+ "defaultValue": 10,
+ "displayNameAction": "Set Sleeptimer",
+ "writable": true,
+ "cached": false
+ },
+ {
+ "id": "f84ccfc3-0698-40ff-b413-53f0064ce663",
+ "name": "artwork",
+ "displayName": "Artwork",
+ "type": "QString",
+ "defaultValue": "",
+ "cached": false
+ },
+ {
+ "id": "67a5b71e-ec88-4272-8d68-9562b7f786cf",
+ "name": "artist",
+ "displayName": "Artist",
+ "type": "QString",
+ "defaultValue": "",
+ "cached": false
+ },
+ {
+ "id": "c1af97a6-f061-4082-8bf5-595728b03ab1",
+ "name": "collection",
+ "displayName": "Collection",
+ "type": "QString",
+ "defaultValue": "",
+ "cached": false
+ },
+ {
+ "id": "c7814ee8-52b1-4cc9-b8f4-f3f91ad8f33e",
+ "displayName": "Player Type",
+ "name": "playerType",
+ "type": "QString",
+ "possibleValues": ["audio", "video"],
+ "defaultValue": "audio",
+ "cached": false
+ }
+ ],
+ "actionTypes": [
+ {
+ "id": "d045e491-c83b-4155-85ef-abc28a391402",
+ "name": "increaseVolume",
+ "displayName": "Increase volume"
+ },
+ {
+ "id": "16ae2d6a-68cc-497f-9e5d-2fa1f5f7107a",
+ "name": "decreaseVolume",
+ "displayName": "Decrease volume"
+ },
+ {
+ "id": "e04b74cc-cf74-482c-908d-8df294bd5ec8",
+ "name": "skipBack",
+ "displayName": "Prev"
+ },
+ {
+ "id": "d46f0b61-d406-4302-adc3-6bbc00fc2a8f",
+ "name": "stop",
+ "displayName": "Stop"
+ },
+ {
+ "id": "4e3b2f50-82dc-4f51-a9e5-69012985b491",
+ "name": "play",
+ "displayName": "Play"
+ },
+ {
+ "id": "b7128827-b429-4583-bc34-1ef4e7987809",
+ "name": "pause",
+ "displayName": "Pause"
+ },
+ {
+ "id": "25301c30-727c-43fd-bf3b-f7b3916947c7",
+ "name": "skipNext",
+ "displayName": "Next"
+ }
+ ],
+ "browserItemActionTypes": [
+ {
+ "id": "ccb210ac-5819-4614-897b-e5a0b130a38a",
+ "name": "playAll",
+ "displayName": "Play All"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/espuino/meta.json b/espuino/meta.json
new file mode 100644
index 00000000..3bba194c
--- /dev/null
+++ b/espuino/meta.json
@@ -0,0 +1,14 @@
+{
+ "title": "ESPunio",
+ "tagline": "Remote control ESPunio (Rfid-controller musicplayer) via MQTT.",
+ "stability": "community",
+ "offline": true,
+ "technologies": [
+ "mqtt",
+ "network"
+ ],
+ "categories": [
+ "dyi",
+ "multimedia",
+ ]
+}
diff --git a/espuino/translations/5f8ba72b-d3fb-4efe-952d-a927bed20cfe-en_US.ts b/espuino/translations/5f8ba72b-d3fb-4efe-952d-a927bed20cfe-en_US.ts
new file mode 100644
index 00000000..d237f7b7
--- /dev/null
+++ b/espuino/translations/5f8ba72b-d3fb-4efe-952d-a927bed20cfe-en_US.ts
@@ -0,0 +1,244 @@
+
+
+
+
+ ESPuino
+
+
+ Artist
+ The name of the StateType ({67a5b71e-ec88-4272-8d68-9562b7f786cf}) of ThingClass espuino
+
+
+
+
+ Artwork
+ The name of the StateType ({f84ccfc3-0698-40ff-b413-53f0064ce663}) of ThingClass espuino
+
+
+
+
+ Battery critical
+ The name of the StateType ({bee497e6-a320-458a-9006-ddfe4c7c37c2}) of ThingClass espuino
+
+
+
+
+ Battery level
+ The name of the StateType ({9fd8f882-8240-492f-8c6b-b5477e26623e}) of ThingClass espuino
+
+
+
+
+ Collection
+ The name of the StateType ({c1af97a6-f061-4082-8bf5-595728b03ab1}) of ThingClass espuino
+
+
+
+
+ Connected
+ The name of the StateType ({edbff474-0cdc-488c-a9e9-970b25ce7548}) of ThingClass espuino
+
+
+
+
+ Decrease volume
+ The name of the ActionType ({16ae2d6a-68cc-497f-9e5d-2fa1f5f7107a}) of ThingClass espuino
+
+
+
+
+
+
+ ESPuino
+ The name of the ThingClass ({ee24ce2b-d34a-4c2c-85f3-9d895d17f414})
+----------
+The name of the vendor ({58c8eb30-98a4-44fd-aaac-cb2a7aae7e8a})
+----------
+The name of the plugin ESPuino ({5f8ba72b-d3fb-4efe-952d-a927bed20cfe})
+
+
+
+
+ Enable/disable control lock
+ The name of the ActionType ({03e7a5e2-9434-47e8-91ad-03610601b925}) of ThingClass espuino
+
+
+
+
+ Hostname
+ The name of the ParamType (ThingClass: espuino, Type: thing, ID: {2a9c9427-3e4e-4473-805e-c25242cfc621})
+
+
+
+
+ Increase volume
+ The name of the ActionType ({d045e491-c83b-4155-85ef-abc28a391402}) of ThingClass espuino
+
+
+
+
+
+ LED brightness
+ The name of the ParamType (ThingClass: espuino, ActionType: brightness, ID: {595908c1-57b1-4303-a0ca-4c64f3cb1907})
+----------
+The name of the StateType ({595908c1-57b1-4303-a0ca-4c64f3cb1907}) of ThingClass espuino
+
+
+
+
+
+ Locl controls
+ The name of the ParamType (ThingClass: espuino, ActionType: childLock, ID: {03e7a5e2-9434-47e8-91ad-03610601b925})
+----------
+The name of the StateType ({03e7a5e2-9434-47e8-91ad-03610601b925}) of ThingClass espuino
+
+
+
+
+ Next
+ The name of the ActionType ({25301c30-727c-43fd-bf3b-f7b3916947c7}) of ThingClass espuino
+
+
+
+
+ Pause
+ The name of the ActionType ({b7128827-b429-4583-bc34-1ef4e7987809}) of ThingClass espuino
+
+
+
+
+ Play
+ The name of the ActionType ({4e3b2f50-82dc-4f51-a9e5-69012985b491}) of ThingClass espuino
+
+
+
+
+ Play All
+ The name of the Browser Item ActionType ({ccb210ac-5819-4614-897b-e5a0b130a38a}) of ThingClass espuino
+
+
+
+
+ Playback status
+ The name of the StateType ({dd1cfb1f-fec4-4035-9c02-562a6fba683d}) of ThingClass espuino
+
+
+
+
+ Player Type
+ The name of the StateType ({c7814ee8-52b1-4cc9-b8f4-f3f91ad8f33e}) of ThingClass espuino
+
+
+
+
+ Playmode
+ The name of the StateType ({5acce950-cdac-44ea-963d-0635afcabdca}) of ThingClass espuino
+
+
+
+
+ Prev
+ The name of the ActionType ({e04b74cc-cf74-482c-908d-8df294bd5ec8}) of ThingClass espuino
+
+
+
+
+
+ Repeat mode
+ The name of the ParamType (ThingClass: espuino, ActionType: repeat, ID: {27b5ff3b-bd60-411f-b8e3-b1c8f6897bec})
+----------
+The name of the StateType ({27b5ff3b-bd60-411f-b8e3-b1c8f6897bec}) of ThingClass espuino
+
+
+
+
+ Set LED brightness
+ The name of the ActionType ({595908c1-57b1-4303-a0ca-4c64f3cb1907}) of ThingClass espuino
+
+
+
+
+ Set Sleepmode
+ The name of the ActionType ({19bd1456-2e4f-444a-a586-75bf6cc9fb73}) of ThingClass espuino
+
+
+
+
+ Set Sleeptimer
+ The name of the ActionType ({4c7594e4-70e7-4f0c-aae4-02e3993ffa1d}) of ThingClass espuino
+
+
+
+
+ Set repeat
+ The name of the ActionType ({27b5ff3b-bd60-411f-b8e3-b1c8f6897bec}) of ThingClass espuino
+
+
+
+
+ Set volume
+ The name of the ActionType ({93a5098a-a41a-46ee-8613-266d4f9ed69a}) of ThingClass espuino
+
+
+
+
+ Signal strength
+ The name of the StateType ({57bd1bab-d872-4315-b53e-1157fe3889d4}) of ThingClass espuino
+
+
+
+
+
+ Sleepmode
+ The name of the ParamType (ThingClass: espuino, ActionType: sleepmode, ID: {19bd1456-2e4f-444a-a586-75bf6cc9fb73})
+----------
+The name of the StateType ({19bd1456-2e4f-444a-a586-75bf6cc9fb73}) of ThingClass espuino
+
+
+
+
+
+ Sleeptimer
+ The name of the ParamType (ThingClass: espuino, ActionType: sleeptimer, ID: {4c7594e4-70e7-4f0c-aae4-02e3993ffa1d})
+----------
+The name of the StateType ({4c7594e4-70e7-4f0c-aae4-02e3993ffa1d}) of ThingClass espuino
+
+
+
+
+ Stop
+ The name of the ActionType ({d46f0b61-d406-4302-adc3-6bbc00fc2a8f}) of ThingClass espuino
+
+
+
+
+ Title
+ The name of the StateType ({a274e048-9820-444a-b5de-a3a421c855a2}) of ThingClass espuino
+
+
+
+
+
+ Volume
+ The name of the ParamType (ThingClass: espuino, ActionType: volume, ID: {93a5098a-a41a-46ee-8613-266d4f9ed69a})
+----------
+The name of the StateType ({93a5098a-a41a-46ee-8613-266d4f9ed69a}) of ThingClass espuino
+
+
+
+
+ IntegrationPluginEspuino
+
+
+
+ Error creating MQTT channel. Please check MQTT server settings.
+
+
+
+
+
+ Failed to configure MQTT via Websocket.
+
+
+
+
diff --git a/nymea-plugins.pro b/nymea-plugins.pro
index d0802476..f6d3babf 100644
--- a/nymea-plugins.pro
+++ b/nymea-plugins.pro
@@ -21,6 +21,7 @@ PLUGIN_DIRS = \
dynatrace \
elgato \
eq-3 \
+ espuino \
fastcom \
flowercare \
fronius \