Compare commits

...

13 Commits

Author SHA1 Message Date
957ab589cb Keba: fallback to direct IP in discovery address param
When thingParamValueAddress() returns empty (because MAC is known and
IP is tracked dynamically), fill the address param with the UDP source
IP so the user can see it in the setup UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 08:37:57 +02:00
8ddd388e80 Keba: bump version to 1.14.2+etm2 in changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 07:18:27 +02:00
b2b21a186a Keba: fix Shutter parser definitively + add step debug labels (v1.14.2+etm2)
- Fix: current parser used connectorTypeValue ('S') instead of connectorCurrentValue
  for Shutter connector type, causing m_isValid=false for KC-P30-ESS400U2-E00-PV
- Add: explicit qCWarning step labels on all m_isValid=false paths to ease debugging
- Bump version to 1.14.2+etm2 so reprepro replaces the broken 1.14.2+etm1 in repo
2026-04-01 07:13:48 +02:00
e7be9b51af Keba: fix Shutter/PV-Edition parser + add phase switching (x2/x2src UDP)
- Fix: product code parser fails for Shutter connector type (KC-P30-ESS400U2-E00-PV)
  Current rating was compared against connectorTypeValue ('S') instead of
  connectorCurrentValue ('4') -> m_isValid = false for all PV-Edition models
- Add: phase switching support via UDP x2src/x2 commands (doc V2.04 §3.2.14-15)
  New states: phaseSwitchSource, phaseSwitchActive, desiredPhaseCount
  New action: setPhaseCount (1 or 3 phases, 5 min cooldown per IEC 61851)
  Added KeContact::setPhaseSwitch() queuing both commands with built-in 200ms pause
2026-03-31 14:59:04 +02:00
jenkins
22656bdeec Jenkins release build 1.14.2 2026-02-19 12:13:26 +01:00
jenkins
f68148df08 Jenkins release build 1.14.1 2026-01-29 21:29:16 +01:00
jenkins
bf9c3d8267 Jenkins release build 1.14.0 2026-01-12 10:47:41 +01:00
jenkins
7560cdb144 Merge PR #784: Update EV charger interface and add full charging current resolution 2026-01-12 10:47:40 +01:00
Simon Stürz
178c16c967 Keba: Update EV charger interface and add full charging current resolution 2026-01-07 16:11:09 +01:00
Simon Stürz
9fd389002d go-e: Update EV charger interface to use double 2026-01-07 16:11:09 +01:00
Simon Stürz
616f660c9f Tado: Format source using clang-format 2025-12-19 15:24:13 +01:00
Simon Stürz
4e9c5501d6 Add clang-format 2025-12-19 15:23:27 +01:00
Simon Stürz
5bcdf131d3 Tado: Sync actions delayed in order to not exceed rate limits 2025-12-19 15:22:45 +01:00
12 changed files with 757 additions and 127 deletions

265
.clang-format Normal file
View File

@ -0,0 +1,265 @@
---
Language: Cpp
AccessModifierOffset: -4
AlignAfterOpenBracket: Align
AlignArrayOfStructures: None
AlignConsecutiveAssignments:
Enabled: false
AcrossEmptyLines: false
AcrossComments: false
AlignCompound: false
PadOperators: false
AlignConsecutiveBitFields:
Enabled: false
AcrossEmptyLines: false
AcrossComments: false
AlignCompound: false
PadOperators: false
AlignConsecutiveDeclarations:
Enabled: false
AcrossEmptyLines: false
AcrossComments: false
AlignCompound: false
PadOperators: false
AlignConsecutiveMacros:
Enabled: false
AcrossEmptyLines: false
AcrossComments: false
AlignCompound: false
PadOperators: false
AlignConsecutiveShortCaseStatements:
Enabled: false
AcrossEmptyLines: false
AcrossComments: false
AlignCaseColons: false
AlignEscapedNewlines: DontAlign
AlignOperands: Align
AlignTrailingComments:
Kind: Always
OverEmptyLines: 0
AllowAllArgumentsOnNextLine: true
AllowAllParametersOfDeclarationOnNextLine: true
AllowShortBlocksOnASingleLine: Never
AllowShortCaseLabelsOnASingleLine: false
AllowShortEnumsOnASingleLine: false
AllowShortFunctionsOnASingleLine: Inline
AllowShortIfStatementsOnASingleLine: Never
AllowShortLambdasOnASingleLine: All
AllowShortLoopsOnASingleLine: false
AlwaysBreakAfterDefinitionReturnType: None
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: false
AlwaysBreakTemplateDeclarations: Yes
AttributeMacros:
- __capability
BinPackArguments: false
BinPackParameters: false
BitFieldColonSpacing: Both
BraceWrapping:
AfterCaseLabel: false
AfterClass: true
AfterControlStatement: Never
AfterEnum: false
AfterExternBlock: false
AfterFunction: true
AfterNamespace: false
AfterObjCDeclaration: false
AfterStruct: true
AfterUnion: false
BeforeCatch: false
BeforeElse: false
BeforeLambdaBody: false
BeforeWhile: false
IndentBraces: false
SplitEmptyFunction: false
SplitEmptyRecord: false
SplitEmptyNamespace: false
BreakAfterAttributes: Never
BreakAfterJavaFieldAnnotations: false
BreakArrays: true
BreakBeforeBinaryOperators: All
BreakBeforeConceptDeclarations: Always
BreakBeforeBraces: Custom
BreakBeforeInlineASMColon: OnlyMultiline
BreakBeforeTernaryOperators: true
BreakConstructorInitializers: BeforeComma
BreakInheritanceList: BeforeColon
BreakStringLiterals: true
ColumnLimit: 180
CommentPragmas: '^ IWYU pragma:'
CompactNamespaces: false
ConstructorInitializerIndentWidth: 4
ContinuationIndentWidth: 4
Cpp11BracedListStyle: true
DerivePointerAlignment: true
DisableFormat: false
EmptyLineAfterAccessModifier: Never
EmptyLineBeforeAccessModifier: LogicalBlock
ExperimentalAutoDetectBinPacking: false
FixNamespaceComments: true
ForEachMacros:
- forever
- foreach
- Q_FOREACH
- BOOST_FOREACH
IfMacros:
- KJ_IF_MAYBE
IncludeBlocks: Preserve
IncludeCategories:
- Regex: '^<Q.*'
Priority: 200
SortPriority: 200
CaseSensitive: true
IncludeIsMainRegex: '(Test)?$'
IncludeIsMainSourceRegex: ''
IndentAccessModifiers: false
IndentCaseBlocks: false
IndentCaseLabels: false
IndentExternBlock: AfterExternBlock
IndentGotoLabels: true
IndentPPDirectives: None
IndentRequiresClause: true
IndentWidth: 4
IndentWrappedFunctionNames: false
InsertBraces: false
InsertNewlineAtEOF: false
InsertTrailingCommas: None
IntegerLiteralSeparator:
Binary: 0
BinaryMinDigits: 0
Decimal: 0
DecimalMinDigits: 0
Hex: 0
HexMinDigits: 0
JavaScriptQuotes: Leave
JavaScriptWrapImports: true
KeepEmptyLinesAtTheStartOfBlocks: false
KeepEmptyLinesAtEOF: false
LambdaBodyIndentation: Signature
LineEnding: DeriveLF
MacroBlockBegin: ''
MacroBlockEnd: ''
MaxEmptyLinesToKeep: 1
NamespaceIndentation: None
ObjCBinPackProtocolList: Auto
ObjCBlockIndentWidth: 4
ObjCBreakBeforeNestedBlockParam: true
ObjCSpaceAfterProperty: false
ObjCSpaceBeforeProtocolList: true
PackConstructorInitializers: BinPack
PenaltyBreakAssignment: 150
PenaltyBreakBeforeFirstCallParameter: 300
PenaltyBreakComment: 500
PenaltyBreakFirstLessLess: 400
PenaltyBreakOpenParenthesis: 0
PenaltyBreakString: 600
PenaltyBreakTemplateDeclaration: 10
PenaltyExcessCharacter: 50
PenaltyIndentedWhitespace: 0
PenaltyReturnTypeOnItsOwnLine: 300
PointerAlignment: Right
PPIndentWidth: -1
QualifierAlignment: Leave
ReferenceAlignment: Pointer
ReflowComments: false
RemoveBracesLLVM: false
RemoveParentheses: Leave
RemoveSemicolon: false
RequiresClausePosition: OwnLine
RequiresExpressionIndentation: OuterScope
SeparateDefinitionBlocks: Leave
ShortNamespaceLines: 1
SortIncludes: CaseSensitive
SortJavaStaticImport: Before
SortUsingDeclarations: Lexicographic
SpaceAfterCStyleCast: true
SpaceAfterLogicalNot: false
SpaceAfterTemplateKeyword: false
SpaceAroundPointerQualifiers: Default
SpaceBeforeAssignmentOperators: true
SpaceBeforeCaseColon: false
SpaceBeforeCpp11BracedList: false
SpaceBeforeCtorInitializerColon: true
SpaceBeforeInheritanceColon: true
SpaceBeforeJsonColon: false
SpaceBeforeParens: ControlStatements
SpaceBeforeParensOptions:
AfterControlStatements: true
AfterForeachMacros: true
AfterFunctionDefinitionName: false
AfterFunctionDeclarationName: false
AfterIfMacros: true
AfterOverloadedOperator: false
AfterRequiresInClause: false
AfterRequiresInExpression: false
BeforeNonEmptyParentheses: false
SpaceBeforeRangeBasedForLoopColon: true
SpaceBeforeSquareBrackets: false
SpaceInEmptyBlock: false
SpacesBeforeTrailingComments: 1
SpacesInAngles: Never
SpacesInContainerLiterals: false
SpacesInLineCommentPrefix:
Minimum: 1
Maximum: -1
SpacesInParens: Never
SpacesInParensOptions:
InCStyleCasts: false
InConditionalStatements: false
InEmptyParentheses: false
Other: false
SpacesInSquareBrackets: false
Standard: Auto
StatementAttributeLikeMacros:
- Q_EMIT
StatementMacros:
- Q_UNUSED
- QT_REQUIRE_VERSION
- Q_CLASSINFO
- Q_ENUM
- Q_ENUM_NS
- Q_FLAG
- Q_FLAG_NS
- Q_GADGET
- Q_GADGET_EXPORT
- Q_INTERFACES
- Q_MOC_INCLUDE
- Q_NAMESPACE
- Q_NAMESPACE_EXPORT
- Q_OBJECT
- Q_PROPERTY
- Q_REVISION
- Q_DISABLE_COPY
- Q_SET_OBJECT_NAME
- QT_BEGIN_NAMESPACE
- QT_END_NAMESPACE
- QML_ADDED_IN_MINOR_VERSION
- QML_ANONYMOUS
- QML_ATTACHED
- QML_DECLARE_TYPE
- QML_DECLARE_TYPEINFO
- QML_ELEMENT
- QML_EXTENDED
- QML_EXTENDED_NAMESPACE
- QML_EXTRA_VERSION
- QML_FOREIGN
- QML_FOREIGN_NAMESPACE
- QML_IMPLEMENTS_INTERFACES
- QML_INTERFACE
- QML_NAMED_ELEMENT
- QML_REMOVED_IN_MINOR_VERSION
- QML_SINGLETON
- QML_UNAVAILABLE
- QML_UNCREATABLE
- QML_VALUE_TYPE
TabWidth: 4
UseTab: Never
VerilogBreakBetweenInstancePorts: true
WhitespaceSensitiveMacros:
- BOOST_PP_STRINGIZE
- CF_SWIFT_NAME
- NS_SWIFT_NAME
- PP_STRINGIZE
- STRINGIZE
...

View File

@ -1,3 +1,46 @@
nymea-plugins (1.14.2+etm3) powersync-stable; urgency=low
* Keba: show IP address in setup params when thingParamValueAddress is empty (fallback to direct IP)
-- ETM Schurig <dev@etm-schurig.eu> Tue, 01 Apr 2026 09:00:00 +0200
nymea-plugins (1.14.2+etm2) powersync-stable; urgency=high
* Keba: fix product code parser for PV-Edition Shutter connector (KC-P30-ESS400U2-E00-PV)
- Fix: current rating compared against connectorTypeValue ('S') instead of
connectorCurrentValue ('4') causing m_isValid=false for all Shutter models
- Add: explicit step labels on all parser failure points for easier debugging
- Add: phase switching support via UDP x2src/x2 (IEC 61851, 5 min cooldown)
- Add: states phaseSwitchSource, phaseSwitchActive, desiredPhaseCount
- Add: action setPhaseCount (1 or 3 phases)
-- ETM Schurig <dev@etm-schurig.eu> Tue, 01 Apr 2026 08:00:00 +0200
nymea-plugins (1.14.2+etm1) powersync-stable; urgency=low
* Renommage : nymea-plugin-* -> powersync-plugin-*
* Ajout Provides/Conflicts/Replaces pour compatibilité nymea
-- ETM Schurig <dev@etm-schurig.eu> Mon, 30 Mar 2026 20:00:00 +0200
nymea-plugins (1.14.2) noble; urgency=medium
-- jenkins <developer@nymea.io> Thu, 19 Feb 2026 12:13:26 +0100
nymea-plugins (1.14.1) noble; urgency=medium
-- jenkins <developer@nymea.io> Thu, 29 Jan 2026 21:29:16 +0100
nymea-plugins (1.14.0) noble; urgency=medium
[ Simon Stürz ]
* Tado: Synch cached states delayed
* Update EV charger interface and add full charging current resolution
-- jenkins <developer@nymea.io> Mon, 12 Jan 2026 10:47:41 +0100
nymea-plugins (1.13.0) jammy; urgency=medium
[ Simon Stürz ]

View File

@ -143,10 +143,11 @@
"displayName": "Charging current",
"displayNameEvent": "Charging current changed",
"displayNameAction": "Set charging current",
"type": "uint",
"type": "double",
"unit": "Ampere",
"minValue": 6,
"maxValue": 32,
"stepSize": 1.0,
"defaultValue": 16,
"writable": true,
"suggestLogging": true

View File

@ -113,7 +113,11 @@ void IntegrationPluginKeba::discoverThings(ThingDiscoveryInfo *info)
ParamList params;
params << Param(m_macAddressParamTypeIds.value(discoveredThingClassId), result.networkDeviceInfo.thingParamValueMacAddress());
params << Param(m_hostNameParamTypeIds.value(discoveredThingClassId), result.networkDeviceInfo.thingParamValueHostName());
params << Param(m_addressParamTypeIds.value(discoveredThingClassId), result.networkDeviceInfo.thingParamValueAddress());
// Fallback to direct IP when thingParamValueAddress() is empty (occurs when MAC is known and IP is tracked dynamically)
QString addressValue = result.networkDeviceInfo.thingParamValueAddress();
if (addressValue.isEmpty())
addressValue = result.address.toString();
params << Param(m_addressParamTypeIds.value(discoveredThingClassId), addressValue);
params << Param(m_modelParamTypeIds.value(discoveredThingClassId), result.product);
params << Param(m_serialNumberParamTypeIds.value(discoveredThingClassId), result.serialNumber);
descriptor.setParams(params);
@ -281,7 +285,7 @@ void IntegrationPluginKeba::executeAction(ThingActionInfo *info)
QUuid requestId;
if (thing->thingClassId() == kebaThingClassId) {
if (action.actionTypeId() == kebaMaxChargingCurrentActionTypeId) {
int milliAmpere = action.paramValue(kebaMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toUInt() * 1000;
int milliAmpere = qRound(action.paramValue(kebaMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toDouble() * 1000);
requestId = keba->setMaxAmpereGeneral(milliAmpere);
} else if (action.actionTypeId() == kebaPowerActionTypeId) {
requestId = keba->enableOutput(action.param(kebaPowerActionTypeId).value().toBool());
@ -289,6 +293,9 @@ void IntegrationPluginKeba::executeAction(ThingActionInfo *info)
requestId = keba->displayMessage(action.param(kebaDisplayActionMessageParamTypeId).value().toByteArray());
} else if (action.actionTypeId() == kebaOutputX2ActionTypeId) {
requestId = keba->setOutputX2(action.param(kebaOutputX2ActionOutputX2ParamTypeId).value().toBool());
} else if (action.actionTypeId() == kebaSetPhaseCountActionTypeId) {
int phaseCount = action.paramValue(kebaSetPhaseCountActionPhaseCountParamTypeId).toInt();
requestId = keba->setPhaseSwitch(phaseCount);
} else if (action.actionTypeId() == kebaFailsafeModeActionTypeId) {
int timeout = 0;
if (action.param(kebaFailsafeModeActionFailsafeModeParamTypeId).value().toBool()) {
@ -302,7 +309,7 @@ void IntegrationPluginKeba::executeAction(ThingActionInfo *info)
} else if (thing->thingClassId() == kebaSimpleThingClassId) {
if (action.actionTypeId() == kebaSimpleMaxChargingCurrentActionTypeId) {
int milliAmpere = action.paramValue(kebaSimpleMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toUInt() * 1000;
int milliAmpere = qRound(action.paramValue(kebaSimpleMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toDouble() * 1000);
requestId = keba->setMaxAmpereGeneral(milliAmpere);
} else if (action.actionTypeId() == kebaSimplePowerActionTypeId) {
requestId = keba->enableOutput(action.param(kebaSimplePowerActionTypeId).value().toBool());
@ -426,6 +433,7 @@ void IntegrationPluginKeba::setupKeba(ThingSetupInfo *info, const QHostAddress &
case KebaProductInfo::SeriesX4G:
case KebaProductInfo::SeriesSpecial:
qCDebug(dcKeba()) << "The keba" << productInformation.series() << "is capable of communicating using UDP";
qCDebug(dcKeba()) << "Series Special detected:" << productInformation.productString().right(2) << "(PV-Edition ou autre variante spéciale)";
supported = true;
break;
default:
@ -481,7 +489,7 @@ void IntegrationPluginKeba::onCommandExecuted(QUuid requestId, bool success)
if (thing->thingClassId() == kebaThingClassId) {
// Set the value to the state so we don't have to wait for the report 2 response
if (info->action().actionTypeId() == kebaMaxChargingCurrentActionTypeId) {
uint value = info->action().paramValue(kebaMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toUInt();
double value = info->action().paramValue(kebaMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toDouble();
info->thing()->setStateValue("maxChargingCurrent", value);
} else if (info->action().actionTypeId() == kebaPowerActionTypeId) {
info->thing()->setStateValue("power", info->action().paramValue(kebaPowerActionTypeId).toBool());
@ -489,7 +497,7 @@ void IntegrationPluginKeba::onCommandExecuted(QUuid requestId, bool success)
} else if (thing->thingClassId() == kebaSimpleThingClassId) {
// Set the value to the state so we don't have to wait for the report 2 response
if (info->action().actionTypeId() == kebaSimpleMaxChargingCurrentActionTypeId) {
uint value = info->action().paramValue(kebaSimpleMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toUInt();
double value = info->action().paramValue(kebaSimpleMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toDouble();
info->thing()->setStateValue("maxChargingCurrent", value);
} else if (info->action().actionTypeId() == kebaPowerActionTypeId) {
info->thing()->setStateValue("power", info->action().paramValue(kebaSimplePowerActionTypeId).toBool());
@ -598,7 +606,7 @@ void IntegrationPluginKeba::onReportTwoReceived(const KeContact::ReportTwo &repo
qCDebug(dcKeba()) << " - Output:" << reportTwo.output;
qCDebug(dcKeba()) << " - Input:" << reportTwo.input;
qCDebug(dcKeba()) << " - Serial number:" << reportTwo.serialNumber;
qCDebug(dcKeba()) << " - Uptime:" << reportTwo.seconds/60 << "[min]";
qCDebug(dcKeba()) << " - Uptime:" << reportTwo.seconds / 60 << "[min]";
if (reportTwo.serialNumber == thing->paramValue(m_serialNumberParamTypeIds.value(thing->thingClassId())).toString()) {
setDeviceState(thing, reportTwo.state);
@ -609,7 +617,7 @@ void IntegrationPluginKeba::onReportTwoReceived(const KeContact::ReportTwo &repo
thing->setStateValue("error2", reportTwo.error2);
thing->setStateValue("systemEnabled", reportTwo.enableSys);
thing->setStateValue("maxChargingCurrent", qRound(reportTwo.currentUser));
thing->setStateValue("maxChargingCurrent", reportTwo.currentUser);
thing->setStateValue("maxChargingCurrentPercent", reportTwo.maxCurrentPercentage);
thing->setStateValue("maxChargingCurrentHardware", reportTwo.currentHardwareLimitation);
@ -623,6 +631,12 @@ void IntegrationPluginKeba::onReportTwoReceived(const KeContact::ReportTwo &repo
thing->setStateValue("outputX2", reportTwo.output);
thing->setStateValue("input", reportTwo.input);
// Phase switch state (PV-Edition / SeriesSpecial)
thing->setStateValue("phaseSwitchSource", reportTwo.x2PhaseSwitchSource);
thing->setStateValue("phaseSwitchActive", reportTwo.x2PhaseSwitchSource == 4);
// x2PhaseSwitch: 0 = 1 phase, 1 = 3 phases
thing->setStateValue("desiredPhaseCount", reportTwo.x2PhaseSwitch == 1 ? 3 : 1);
thing->setStateValue("uptime", reportTwo.seconds / 60);
} else {
qCWarning(dcKeba()) << "Received report but the serial number didn't match";

View File

@ -136,11 +136,12 @@
"displayName": "Maximal charging current",
"displayNameEvent": "Maximal charging current changed",
"displayNameAction": "Set maximal charging current",
"type": "uint",
"type": "double",
"unit": "Ampere",
"defaultValue": 6,
"minValue": 6,
"maxValue": 32,
"stepSize": 0.001,
"writable": true,
"suggestLogging": true
},
@ -298,6 +299,35 @@
"writable": true,
"defaultValue": false
},
{
"id": "f8e39ab6-53a7-45a4-96df-8644cbd57036",
"name": "phaseSwitchSource",
"displayName": "Phase switch source",
"displayNameEvent": "Phase switch source changed",
"type": "int",
"defaultValue": 0,
"cached": true
},
{
"id": "bea5e94f-95e0-44ad-a37b-5111415fc093",
"name": "phaseSwitchActive",
"displayName": "Phase switch active",
"displayNameEvent": "Phase switch active changed",
"type": "bool",
"defaultValue": false,
"cached": true
},
{
"id": "c8830dc8-4f12-4aeb-be07-e5024bbe34b4",
"name": "desiredPhaseCount",
"displayName": "Desired phase count",
"displayNameEvent": "Desired phase count changed",
"type": "uint",
"defaultValue": 3,
"minValue": 1,
"maxValue": 3,
"cached": true
},
{
"id": "ba600276-8b36-4404-b8ec-415245e5bc15",
"name": "input",
@ -375,6 +405,23 @@
"defaultValue": ""
}
]
},
{
"id": "f6bedafe-ade4-47ec-894a-b9114da9ad38",
"name": "setPhaseCount",
"displayName": "Set phase count",
"paramTypes": [
{
"id": "39ff68f6-1cd9-4395-8d87-ccb2c69436b3",
"name": "phaseCount",
"displayName": "Phase count",
"type": "int",
"defaultValue": 3,
"minValue": 1,
"maxValue": 3,
"allowedValues": [1, 3]
}
]
}
],
"eventTypes": [
@ -549,11 +596,12 @@
"displayName": "Maximal charging current",
"displayNameEvent": "Maximal charging current changed",
"displayNameAction": "Set maximal charging current",
"type": "uint",
"type": "double",
"unit": "Ampere",
"defaultValue": 6,
"minValue": 6,
"maxValue": 32,
"stepSize": 0.001,
"writable": true,
"suggestLogging": true
},

View File

@ -35,6 +35,7 @@ KebaProductInfo::KebaProductInfo(const QString &productString) :
// KC-P30-EC220112-000-DE
// KC-P30-EC2404B2-M0A-GE
// KC-P30-EC2204U2-E00-PV
// KC-P30-ESS400U2-E00-PV (PV-Edition, Shutter T2, 32A, triphasé, marché FR)
qCDebug(dcKeba()) << "Parsing product information from" << productString.count() << productString;
if (m_productString.count() < 19) {
@ -76,6 +77,7 @@ KebaProductInfo::KebaProductInfo(const QString &productString) :
m_connector = ConnectorCable;
qCDebug(dcKeba()) << "Connector: Cable";
} else {
qCWarning(dcKeba()) << "Invalid at step: connector" << connectorValue;
m_isValid = false;
return;
}
@ -88,6 +90,7 @@ KebaProductInfo::KebaProductInfo(const QString &productString) :
} else if (connectorTypeValue.toLower() == QChar('s')) {
m_connectorType = Shutter;
} else {
qCWarning(dcKeba()) << "Invalid at step: connectorType" << connectorTypeValue;
m_isValid = false;
return;
}
@ -95,7 +98,27 @@ KebaProductInfo::KebaProductInfo(const QString &productString) :
qCDebug(dcKeba()) << "Connector type:" << m_connectorType;
QChar connectorCurrentValue = descriptor.at(3);
if (connectorCurrentValue.isDigit() && connectorTypeValue == QChar('1')) {
if (m_connectorType == Shutter) {
// Shutter: le courant est encodé directement en position 3
// (indépendant de connectorTypeValue qui vaut 'S')
if (connectorCurrentValue == QChar('4')) {
m_current = Current32A;
qCDebug(dcKeba()) << "Current (Shutter): 32A";
} else if (connectorCurrentValue == QChar('3')) {
m_current = Current20A;
qCDebug(dcKeba()) << "Current (Shutter): 20A";
} else if (connectorCurrentValue == QChar('2')) {
m_current = Current16A;
qCDebug(dcKeba()) << "Current (Shutter): 16A";
} else if (connectorCurrentValue == QChar('1')) {
m_current = Current13A;
qCDebug(dcKeba()) << "Current (Shutter): 13A";
} else {
qCWarning(dcKeba()) << "Invalid at step: current (Shutter)" << connectorCurrentValue;
m_isValid = false;
return;
}
} else if (connectorCurrentValue.isDigit() && connectorTypeValue == QChar('1')) {
m_current = Current13A;
} else if (connectorCurrentValue.isDigit() && connectorTypeValue == QChar('2')) {
m_current = Current16A;
@ -104,6 +127,7 @@ KebaProductInfo::KebaProductInfo(const QString &productString) :
} else if (connectorCurrentValue.isDigit() && connectorTypeValue == QChar('4')) {
m_current = Current32A;
} else {
qCWarning(dcKeba()) << "Invalid at step: current" << connectorCurrentValue << "connectorType" << connectorTypeValue;
m_isValid = false;
return;
}
@ -128,6 +152,7 @@ KebaProductInfo::KebaProductInfo(const QString &productString) :
m_cable = Cable5p5m;
qCDebug(dcKeba()) << "Cable: 5.5 meter";
} else {
qCWarning(dcKeba()) << "Invalid at step: cable" << cableValue;
m_isValid = false;
return;
}
@ -165,7 +190,7 @@ KebaProductInfo::KebaProductInfo(const QString &productString) :
m_series = SeriesSpecial;
qCDebug(dcKeba()) << "Series: Special" + m_productString.right(2);
} else {
qCWarning(dcKeba()) << "Series: Unknown" << productString << "value:" << seriesValue;
qCWarning(dcKeba()) << "Invalid at step: series" << seriesValue;
m_isValid = false;
return;
}
@ -177,6 +202,7 @@ KebaProductInfo::KebaProductInfo(const QString &productString) :
} else if (phaseCountValue == QChar('2')) {
m_phaseCount = 3;
} else {
qCWarning(dcKeba()) << "Invalid at step: phaseCount" << phaseCountValue;
m_isValid = false;
return;
}
@ -200,6 +226,7 @@ KebaProductInfo::KebaProductInfo(const QString &productString) :
m_meter = MeterCalibratedNationalCertified;
qCDebug(dcKeba()) << "Meter: Calibrated meter (national certified)";
} else {
qCWarning(dcKeba()) << "Invalid at step: meter" << meterValue;
m_isValid = false;
return;
}

View File

@ -327,6 +327,29 @@ void KeContact::getReport1XX(int reportNumber)
getReport(reportNumber);
}
QUuid KeContact::setPhaseSwitch(int phaseCount)
{
if (!m_dataLayer) {
qCWarning(dcKeba()) << "UDP socket not initialized";
setReachable(false);
return QUuid();
}
// 1. Activate UDP phase control source
KeContactRequest activateRequest(QUuid::createUuid(), "x2src 4");
m_requestQueue.enqueue(activateRequest);
// 2. Set phase count: x2 0 = 1 phase, x2 1 = 3 phases
// Note: IEC 61851 requires a 5 min cooldown between phase switches
QByteArray cmd = QString("x2 %1").arg(phaseCount == 3 ? 1 : 0).toLatin1();
KeContactRequest phaseRequest(QUuid::createUuid(), cmd);
qCDebug(dcKeba()) << "Phase switch:" << phaseCount << "phases. Datagram:" << cmd;
m_requestQueue.enqueue(phaseRequest);
sendNextCommand();
return phaseRequest.requestId();
}
QUuid KeContact::setOutputX2(bool state)
{
if (!m_dataLayer) {
@ -500,6 +523,8 @@ void KeContact::onReceivedDatagram(const QHostAddress &address, const QByteArray
reportTwo.setEnergy = data.value("Setenergy").toInt() / 10000.00;
reportTwo.output = data.value("Output").toInt();
reportTwo.input= data.value("Input").toInt();
reportTwo.x2PhaseSwitchSource = data.value("X2 phaseSwitch source").toInt();
reportTwo.x2PhaseSwitch = data.value("X2 phaseSwitch").toInt();
reportTwo.serialNumber = data.value("Serial").toString();
reportTwo.seconds = data.value("Sec").toInt();
// Not documented:

View File

@ -130,7 +130,7 @@ public:
double maxCurrent; //Current preset value via Control pilot in ampere.
double maxCurrentPercentage; //Current preset value via Control pilot in 0,1% of the PWM value
double currentHardwareLimitation; //Highest possible charging current of the charging connection. Contains device maximum, DIP-switch setting, cable coding and temperature reduction.
double currentUser; //Current preset value of the user via UDP; Default = 63000mA.
double currentUser; //Current preset value of the user via UDP; A.
double currentFailsafe; //Current preset value for the Failsafe function.
int timeoutFailsafe; //Communication timeout before triggering the Failsafe function.
int currTimer; //Shows the current preset value of currtime.
@ -138,6 +138,8 @@ public:
double setEnergy; //Shows the set energy limit
bool output; //State of the output X2.
bool input; //State of the potential free Enable input X1. When using the input, please pay attention to the information in the installation manual.
int x2PhaseSwitchSource = 0; //Source of phase switching control (4 = UDP)
int x2PhaseSwitch = 0; //Current phase state (0 = 1 phase, 1 = 3 phases)
QString serialNumber; //Serial number
int seconds; //Current system clock since restart of the charging station.
};
@ -198,6 +200,7 @@ public:
// Command “currtime”
QUuid setOutputX2(bool state); // Command “output”
QUuid setPhaseSwitch(int phaseCount); // Commands “x2src 4” + “x2 0/1” (IEC 61851: 5 min cooldown between switches)
private:
KeContactDataLayer *m_dataLayer = nullptr;

View File

@ -29,20 +29,87 @@
#include <network/networkaccessmanager.h>
#include <QDebug>
#include <QUrlQuery>
#include <QJsonDocument>
#include <QTimer>
#include <QUrlQuery>
#include <QtMath>
namespace {
void finishPendingActions(const QList<ThingActionInfo *> &actions, Thing::ThingError error)
{
for (ThingActionInfo *info : actions) {
if (info) {
info->finish(error);
}
}
}
} // namespace
IntegrationPluginTado::IntegrationPluginTado()
{
m_stateSyncTimer.setInterval(5000);
connect(&m_stateSyncTimer, &QTimer::timeout, this, &IntegrationPluginTado::syncPendingOverlays);
}
void IntegrationPluginTado::init()
QString IntegrationPluginTado::buildZoneKey(const ThingId &accountThingId, const QString &homeId, const QString &zoneId)
{
return accountThingId.toString() + ":" + homeId + ":" + zoneId;
}
bool IntegrationPluginTado::overlayStatesEqual(const OverlayState &first, const OverlayState &second)
{
if (first.deleteOverlay != second.deleteOverlay) {
return false;
}
if (first.deleteOverlay) {
return true;
}
if (first.power != second.power) {
return false;
}
return qAbs(first.temperature - second.temperature) < 0.01;
}
void IntegrationPluginTado::queueOverlayChange(ThingActionInfo *info, const QString &homeId, const QString &zoneId, const OverlayState &desired)
{
if (!info) {
return;
}
Thing *thing = info->thing();
if (!thing) {
return;
}
ThingId accountThingId = thing->parentId();
QString zoneKey = buildZoneKey(accountThingId, homeId, zoneId);
PendingOverlayChange &pending = m_pendingOverlayChanges[zoneKey];
pending.accountThingId = accountThingId;
pending.homeId = homeId;
pending.zoneId = zoneId;
pending.desired = desired;
pending.dirty = true;
pending.pendingActions.append(info);
connect(info, &ThingActionInfo::aborted, this, [this, info]() { removePendingAction(info); });
if (!m_stateSyncTimer.isActive()) {
m_stateSyncTimer.start();
}
}
void IntegrationPluginTado::removePendingAction(ThingActionInfo *info)
{
for (auto it = m_pendingOverlayChanges.begin(); it != m_pendingOverlayChanges.end(); ++it) {
it->pendingActions.removeAll(info);
}
for (auto it = m_pendingRequests.begin(); it != m_pendingRequests.end(); ++it) {
it->actions.removeAll(info);
}
}
void IntegrationPluginTado::init() {}
void IntegrationPluginTado::startPairing(ThingPairingInfo *info)
{
qCDebug(dcTado()) << "Start pairing process ...";
@ -56,7 +123,7 @@ void IntegrationPluginTado::startPairing(ThingPairingInfo *info)
tado->deleteLater();
});
connect(tado, &Tado::getLoginUrlFinished, info, [info, tado, this] (bool success) {
connect(tado, &Tado::getLoginUrlFinished, info, [info, tado, this](bool success) {
if (!success) {
info->finish(Thing::ThingErrorAuthenticationFailure);
return;
@ -67,8 +134,6 @@ void IntegrationPluginTado::startPairing(ThingPairingInfo *info)
m_unfinishedTadoAccounts.take(info->thingId())->deleteLater();
});
qCDebug(dcTado()) << "Tado server is reachable. Starting the OAuth pairing process using" << tado->loginUrl();
info->setOAuthUrl(QUrl(tado->loginUrl()));
info->finish(Thing::ThingErrorNoError);
@ -84,8 +149,8 @@ void IntegrationPluginTado::confirmPairing(ThingPairingInfo *info, const QString
qCDebug(dcTado()) << "Confirm pairing" << password;
Tado *tado = m_unfinishedTadoAccounts.value(info->thingId());
connect(tado, &Tado::connectionError, info, [info] (QNetworkReply::NetworkError error){
if (error != QNetworkReply::NetworkError::NoError){
connect(tado, &Tado::connectionError, info, [info](QNetworkReply::NetworkError error) {
if (error != QNetworkReply::NetworkError::NoError) {
qCWarning(dcTado()) << "Confirm pairing failed" << error;
info->finish(Thing::ThingErrorSetupFailed, QT_TR_NOOP("A connection error occurred."));
}
@ -113,7 +178,6 @@ void IntegrationPluginTado::setupThing(ThingSetupInfo *info)
Thing *thing = info->thing();
if (thing->thingClassId() == tadoAccountThingClassId) {
qCDebug(dcTado) << "Setting up Tado account" << thing->name() << thing->params();
Tado *tado = nullptr;
@ -133,7 +197,6 @@ void IntegrationPluginTado::setupThing(ThingSetupInfo *info)
pluginStorage()->setValue("refreshToken", tado->refreshToken());
pluginStorage()->endGroup();
} else {
// Load refresh token
pluginStorage()->beginGroup(thing->id().toString());
QString refreshToken = pluginStorage()->value("refreshToken").toString();
@ -163,7 +226,7 @@ void IntegrationPluginTado::setupThing(ThingSetupInfo *info)
}
});
connect(tado, &Tado::refreshTokenReceived, this, [thing, this](const QString &refreshToken){
connect(tado, &Tado::refreshTokenReceived, this, [thing, this](const QString &refreshToken) {
pluginStorage()->beginGroup(thing->id().toString());
pluginStorage()->setValue("refreshToken", refreshToken);
pluginStorage()->endGroup();
@ -174,8 +237,8 @@ void IntegrationPluginTado::setupThing(ThingSetupInfo *info)
info->finish(Thing::ThingErrorNoError);
});
connect(tado, &Tado::connectionError, info, [this, info] (QNetworkReply::NetworkError error) {
if (error != QNetworkReply::NetworkError::NoError){
connect(tado, &Tado::connectionError, info, [this, info](QNetworkReply::NetworkError error) {
if (error != QNetworkReply::NetworkError::NoError) {
if (m_tadoAccounts.contains(info->thing()->id())) {
Tado *tado = m_tadoAccounts.take(info->thing()->id());
tado->deleteLater();
@ -202,10 +265,10 @@ void IntegrationPluginTado::setupThing(ThingSetupInfo *info)
} else if (thing->thingClassId() == zoneThingClassId) {
qCDebug(dcTado) << "Setup Tado zone" << thing->params();
Thing *parentThing = myThings().findById(thing->parentId());
if(parentThing->setupComplete()) {
if (parentThing->setupComplete()) {
return info->finish(Thing::ThingErrorNoError);
} else {
connect(parentThing, &Thing::setupStatusChanged, info, [parentThing, info]{
connect(parentThing, &Thing::setupStatusChanged, info, [parentThing, info] {
if (parentThing->setupComplete()) {
info->finish(Thing::ThingErrorNoError);
}
@ -222,15 +285,57 @@ void IntegrationPluginTado::thingRemoved(Thing *thing)
{
if (thing->thingClassId() == tadoAccountThingClassId) {
Tado *tado = m_tadoAccounts.take(thing->id());
tado->deleteLater();
if (tado) {
tado->deleteLater();
}
for (auto it = m_pendingOverlayChanges.begin(); it != m_pendingOverlayChanges.end();) {
if (it->accountThingId == thing->id()) {
finishPendingActions(it->pendingActions, Thing::ThingErrorThingNotFound);
it = m_pendingOverlayChanges.erase(it);
continue;
}
++it;
}
QString accountPrefix = thing->id().toString() + ":";
for (auto it = m_pendingRequests.begin(); it != m_pendingRequests.end();) {
if (it->zoneKey.startsWith(accountPrefix)) {
finishPendingActions(it->actions, Thing::ThingErrorThingNotFound);
it = m_pendingRequests.erase(it);
continue;
}
++it;
}
} else if (thing->thingClassId() == zoneThingClassId) {
QString homeId = thing->paramValue(zoneThingHomeIdParamTypeId).toString();
QString zoneId = thing->paramValue(zoneThingZoneIdParamTypeId).toString();
QString zoneKey = buildZoneKey(thing->parentId(), homeId, zoneId);
if (m_pendingOverlayChanges.contains(zoneKey)) {
PendingOverlayChange pending = m_pendingOverlayChanges.take(zoneKey);
finishPendingActions(pending.pendingActions, Thing::ThingErrorThingNotFound);
}
for (auto it = m_pendingRequests.begin(); it != m_pendingRequests.end();) {
if (it->zoneKey == zoneKey) {
finishPendingActions(it->actions, Thing::ThingErrorThingNotFound);
it = m_pendingRequests.erase(it);
continue;
}
++it;
}
}
// Clean up storage
pluginStorage()->remove(thing->id().toString());
if (myThings().isEmpty() && m_pluginTimer) {
m_pluginTimer->deleteLater();
m_pluginTimer = nullptr;
if (myThings().isEmpty()) {
if (m_pluginTimer) {
m_pluginTimer->deleteLater();
m_pluginTimer = nullptr;
}
if (m_stateSyncTimer.isActive()) {
m_stateSyncTimer.stop();
}
}
}
@ -240,6 +345,9 @@ void IntegrationPluginTado::postSetupThing(Thing *thing)
m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(10);
connect(m_pluginTimer, &PluginTimer::timeout, this, &IntegrationPluginTado::onPluginTimer);
}
if (!m_stateSyncTimer.isActive()) {
m_stateSyncTimer.start();
}
if (thing->thingClassId() == tadoAccountThingClassId) {
Tado *tado = m_tadoAccounts.value(thing->id());
@ -262,51 +370,45 @@ void IntegrationPluginTado::executeAction(ThingActionInfo *info)
Action action = info->action();
if (thing->thingClassId() == zoneThingClassId) {
Tado *tado = m_tadoAccounts.value(thing->parentId());
if (!tado) {
if (!m_tadoAccounts.contains(thing->parentId())) {
info->finish(Thing::ThingErrorThingNotFound);
return;
}
QString homeId = thing->paramValue(zoneThingHomeIdParamTypeId).toString();
QString zoneId = thing->paramValue(zoneThingZoneIdParamTypeId).toString();
if (action.actionTypeId() == zoneModeActionTypeId) {
QUuid requestId;
if (action.param(zoneModeActionModeParamTypeId).value().toString() == "Tado") {
requestId = tado->deleteOverlay(homeId, zoneId);
} else if (action.param(zoneModeActionModeParamTypeId).value().toString() == "Off") {
requestId = tado->setOverlay(homeId, zoneId, false, thing->stateValue(zoneTargetTemperatureStateTypeId).toDouble());
OverlayState desired;
QString mode = action.param(zoneModeActionModeParamTypeId).value().toString();
if (mode == "Tado") {
desired.deleteOverlay = true;
} else if (mode == "Off") {
desired.power = false;
desired.temperature = thing->stateValue(zoneTargetTemperatureStateTypeId).toDouble();
} else {
if(thing->stateValue(zoneTargetTemperatureStateTypeId).toDouble() <= 5.0) {
requestId = tado->setOverlay(homeId, zoneId, true, 5);
} else {
requestId = tado->setOverlay(homeId, zoneId, true, thing->stateValue(zoneTargetTemperatureStateTypeId).toDouble());
}
desired.power = true;
double targetTemperature = thing->stateValue(zoneTargetTemperatureStateTypeId).toDouble();
desired.temperature = targetTemperature <= 5.0 ? 5.0 : targetTemperature;
}
m_asyncActions.insert(requestId, info);
connect(info, &ThingActionInfo::aborted, thing, [requestId, this] {m_asyncActions.remove(requestId);});
queueOverlayChange(info, homeId, zoneId, desired);
} else if (action.actionTypeId() == zoneTargetTemperatureActionTypeId) {
double temperature = action.param(zoneTargetTemperatureActionTargetTemperatureParamTypeId).value().toDouble();
QUuid requestId;
OverlayState desired;
if (temperature <= 0) {
requestId = tado->setOverlay(homeId, zoneId, false, 0);
desired.power = false;
desired.temperature = 0;
} else {
requestId = tado->setOverlay(homeId, zoneId, true, temperature);
desired.power = true;
desired.temperature = temperature;
}
m_asyncActions.insert(requestId, info);
connect(info, &ThingActionInfo::aborted, thing, [requestId, this] {m_asyncActions.remove(requestId);});
queueOverlayChange(info, homeId, zoneId, desired);
} else if (action.actionTypeId() == zonePowerActionTypeId) {
bool power = action.param(zonePowerActionPowerParamTypeId).value().toBool();
thing->setStateValue(zonePowerStateTypeId, power); // the actual power set response might be slow
QUuid requestId;
OverlayState desired;
double temperature = thing->stateValue(zoneTargetTemperatureStateTypeId).toDouble();
if (!power) {
requestId = tado->setOverlay(homeId, zoneId, false, 0);
} else {
requestId = tado->setOverlay(homeId, zoneId, true, temperature);
}
m_asyncActions.insert(requestId, info);
connect(info, &ThingActionInfo::aborted, thing, [requestId, this] {m_asyncActions.remove(requestId);});
desired.power = power;
desired.temperature = power ? temperature : 0;
queueOverlayChange(info, homeId, zoneId, desired);
} else {
qCWarning(dcTado()) << "Execute action, unhandled actionTypeId" << action.actionTypeId();
info->finish(Thing::ThingErrorActionTypeNotFound);
@ -317,9 +419,69 @@ void IntegrationPluginTado::executeAction(ThingActionInfo *info)
}
}
void IntegrationPluginTado::syncPendingOverlays()
{
if (m_pendingOverlayChanges.isEmpty()) {
return;
}
for (auto it = m_pendingOverlayChanges.begin(); it != m_pendingOverlayChanges.end(); ++it) {
PendingOverlayChange &pending = it.value();
if (!pending.dirty) {
if (!pending.pendingActions.isEmpty()) {
finishPendingActions(pending.pendingActions, Thing::ThingErrorNoError);
pending.pendingActions.clear();
}
continue;
}
if (pending.inFlightValid) {
continue;
}
if (pending.lastSyncedValid && overlayStatesEqual(pending.desired, pending.lastSynced)) {
pending.dirty = false;
if (!pending.pendingActions.isEmpty()) {
finishPendingActions(pending.pendingActions, Thing::ThingErrorNoError);
pending.pendingActions.clear();
}
continue;
}
Tado *tado = m_tadoAccounts.value(pending.accountThingId);
if (!tado) {
if (!pending.pendingActions.isEmpty()) {
finishPendingActions(pending.pendingActions, Thing::ThingErrorThingNotFound);
pending.pendingActions.clear();
}
pending.dirty = false;
continue;
}
QUuid requestId;
if (pending.desired.deleteOverlay) {
requestId = tado->deleteOverlay(pending.homeId, pending.zoneId);
} else {
requestId = tado->setOverlay(pending.homeId, pending.zoneId, pending.desired.power, pending.desired.temperature);
}
if (requestId.isNull()) {
continue;
}
PendingRequest request;
request.zoneKey = it.key();
request.actions = pending.pendingActions;
request.sentState = pending.desired;
pending.pendingActions.clear();
pending.inFlightValid = true;
m_pendingRequests.insert(requestId, request);
}
}
void IntegrationPluginTado::onPluginTimer()
{
Q_FOREACH(Tado *tado, m_tadoAccounts){
Q_FOREACH (Tado *tado, m_tadoAccounts) {
ThingId accountThingId = m_tadoAccounts.key(tado);
if (!tado->authenticated()) {
tado->getAccessToken();
@ -337,9 +499,9 @@ void IntegrationPluginTado::onPluginTimer()
void IntegrationPluginTado::onConnectionChanged(bool connected)
{
Tado *tado = static_cast<Tado*>(sender());
Tado *tado = static_cast<Tado *>(sender());
if (m_tadoAccounts.values().contains(tado)){
if (m_tadoAccounts.values().contains(tado)) {
Thing *thing = myThings().findById(m_tadoAccounts.key(tado));
if (!thing)
return;
@ -357,11 +519,11 @@ void IntegrationPluginTado::onConnectionChanged(bool connected)
void IntegrationPluginTado::onAuthenticationStatusChanged(bool authenticated)
{
Tado *tado = static_cast<Tado*>(sender());
Tado *tado = static_cast<Tado *>(sender());
if (m_tadoAccounts.values().contains(tado)){
if (m_tadoAccounts.values().contains(tado)) {
Thing *thing = myThings().findById(m_tadoAccounts.key(tado));
if (!thing){
if (!thing) {
qCWarning(dcTado()) << "OnAuthenticationChanged no thing found by ID" << m_tadoAccounts.key(tado);
return;
}
@ -378,9 +540,9 @@ void IntegrationPluginTado::onAuthenticationStatusChanged(bool authenticated)
void IntegrationPluginTado::onUsernameChanged(const QString &username)
{
Tado *tado = static_cast<Tado*>(sender());
Tado *tado = static_cast<Tado *>(sender());
if (m_tadoAccounts.values().contains(tado)){
if (m_tadoAccounts.values().contains(tado)) {
Thing *thing = myThings().findById(m_tadoAccounts.key(tado));
thing->setStateValue(tadoAccountUserDisplayNameStateTypeId, username);
}
@ -388,20 +550,39 @@ void IntegrationPluginTado::onUsernameChanged(const QString &username)
void IntegrationPluginTado::onRequestExecuted(QUuid requestId, bool success)
{
if (m_asyncActions.contains(requestId)) {
ThingActionInfo *info = m_asyncActions.take(requestId);
if (success) {
info->finish(Thing::ThingErrorNoError);
} else {
info->finish(Thing::ThingErrorHardwareNotAvailable);
if (!m_pendingRequests.contains(requestId)) {
return;
}
PendingRequest request = m_pendingRequests.take(requestId);
finishPendingActions(request.actions, success ? Thing::ThingErrorNoError : Thing::ThingErrorHardwareNotAvailable);
if (!m_pendingOverlayChanges.contains(request.zoneKey)) {
return;
}
PendingOverlayChange &pending = m_pendingOverlayChanges[request.zoneKey];
pending.inFlightValid = false;
if (success) {
pending.lastSynced = request.sentState;
pending.lastSyncedValid = true;
}
if (pending.lastSyncedValid && overlayStatesEqual(pending.desired, pending.lastSynced)) {
pending.dirty = false;
if (!pending.pendingActions.isEmpty()) {
finishPendingActions(pending.pendingActions, Thing::ThingErrorNoError);
pending.pendingActions.clear();
}
} else {
pending.dirty = true;
}
}
void IntegrationPluginTado::onHomesReceived(QList<Tado::Home> homes)
{
qCDebug(dcTado()) << "Homes received";
Tado *tado = static_cast<Tado*>(sender());
Tado *tado = static_cast<Tado *>(sender());
foreach (Tado::Home home, homes) {
tado->getZones(home.id);
}
@ -409,16 +590,14 @@ void IntegrationPluginTado::onHomesReceived(QList<Tado::Home> homes)
void IntegrationPluginTado::onZonesReceived(const QString &homeId, QList<Tado::Zone> zones)
{
Tado *tado = static_cast<Tado*>(sender());
Tado *tado = static_cast<Tado *>(sender());
if (m_tadoAccounts.values().contains(tado)) {
Thing *parentDevice = myThings().findById(m_tadoAccounts.key(tado));
qCDebug(dcTado()) << "Zones received:" << zones.count() << parentDevice->name();
ThingDescriptors descriptors;
foreach (Tado::Zone zone, zones) {
ThingDescriptor descriptor(zoneThingClassId, zone.name, "Type:" + zone.type, parentDevice->id());
ParamList params;
params.append(Param(zoneThingHomeIdParamTypeId, homeId));
@ -438,7 +617,7 @@ void IntegrationPluginTado::onZonesReceived(const QString &homeId, QList<Tado::Z
void IntegrationPluginTado::onZoneStateReceived(const QString &homeId, const QString &zoneId, Tado::ZoneState state)
{
Tado *tado = static_cast<Tado*>(sender());
Tado *tado = static_cast<Tado *>(sender());
ThingId parentId = m_tadoAccounts.key(tado);
ParamList params;
params.append(Param(zoneThingHomeIdParamTypeId, homeId));
@ -447,7 +626,7 @@ void IntegrationPluginTado::onZoneStateReceived(const QString &homeId, const QSt
if (!thing)
return;
if (state.overlayIsSet) {
if (state.overlayIsSet) {
if (state.overlaySettingPower) {
thing->setStateValue(zoneModeStateTypeId, "Manual");
} else {
@ -468,7 +647,7 @@ void IntegrationPluginTado::onZoneStateReceived(const QString &homeId, const QSt
void IntegrationPluginTado::onOverlayReceived(const QString &homeId, const QString &zoneId, const Tado::Overlay &overlay)
{
Tado *tado = static_cast<Tado*>(sender());
Tado *tado = static_cast<Tado *>(sender());
ThingId parentId = m_tadoAccounts.key(tado);
ParamList params;
params.append(Param(zoneThingHomeIdParamTypeId, homeId));
@ -478,7 +657,7 @@ void IntegrationPluginTado::onOverlayReceived(const QString &homeId, const QStri
return;
thing->setStateValue(zoneTargetTemperatureStateTypeId, overlay.temperature);
if (overlay.tadoMode == "MANUAL") {
if (overlay.tadoMode == "MANUAL") {
if (overlay.power) {
thing->setStateValue(zoneModeStateTypeId, "Manual");
} else {

View File

@ -26,10 +26,11 @@
#define INTEGRATIONPLUGINTADO_H
#include <integrations/integrationplugin.h>
#include <plugintimer.h>
#include <network/oauth2.h>
#include <plugintimer.h>
#include <QHash>
#include <QList>
#include <QTimer>
#include "extern-plugininfo.h"
@ -55,14 +56,49 @@ public:
void executeAction(ThingActionInfo *info) override;
private:
struct OverlayState
{
bool deleteOverlay = false;
bool power = false;
double temperature = 0.0;
};
struct PendingOverlayChange
{
ThingId accountThingId;
QString homeId;
QString zoneId;
OverlayState desired;
OverlayState lastSynced;
bool dirty = false;
bool lastSyncedValid = false;
bool inFlightValid = false;
QList<ThingActionInfo *> pendingActions;
};
struct PendingRequest
{
QString zoneKey;
QList<ThingActionInfo *> actions;
OverlayState sentState;
};
PluginTimer *m_pluginTimer = nullptr;
QHash<ThingId, Tado *> m_unfinishedTadoAccounts;
QHash<ThingId, Tado *> m_tadoAccounts;
QHash<QUuid, ThingActionInfo *> m_asyncActions;
QTimer m_stateSyncTimer;
QHash<QString, PendingOverlayChange> m_pendingOverlayChanges;
QHash<QUuid, PendingRequest> m_pendingRequests;
static QString buildZoneKey(const ThingId &accountThingId, const QString &homeId, const QString &zoneId);
static bool overlayStatesEqual(const OverlayState &first, const OverlayState &second);
void queueOverlayChange(ThingActionInfo *info, const QString &homeId, const QString &zoneId, const OverlayState &desired);
void removePendingAction(ThingActionInfo *info);
private slots:
void onPluginTimer();
void syncPendingOverlays();
void onConnectionChanged(bool connected);
void onAuthenticationStatusChanged(bool authenticated);
@ -70,7 +106,7 @@ private slots:
void onRequestExecuted(QUuid requestId, bool success);
void onHomesReceived(QList<Tado::Home> homes);
void onZonesReceived(const QString &homeId, QList<Tado::Zone> zones);
void onZoneStateReceived(const QString &homeId,const QString &zoneId, Tado::ZoneState sate);
void onZoneStateReceived(const QString &homeId, const QString &zoneId, Tado::ZoneState sate);
void onOverlayReceived(const QString &homeId, const QString &zoneId, const Tado::Overlay &overlay);
};

View File

@ -25,14 +25,14 @@
#include "tado.h"
#include "extern-plugininfo.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QUrlQuery>
Tado::Tado(NetworkAccessManager *networkManager, QObject *parent) :
QObject(parent),
m_networkManager(networkManager)
Tado::Tado(NetworkAccessManager *networkManager, QObject *parent)
: QObject(parent)
, m_networkManager(networkManager)
{
m_baseControlUrl = "https://my.tado.com/api/v2";
m_baseAuthorizationUrl = "https://login.tado.com/oauth2";
@ -40,14 +40,14 @@ Tado::Tado(NetworkAccessManager *networkManager, QObject *parent) :
m_clientId = "1bb50063-6b0c-4d11-bd99-387f4a91cc46";
m_refreshTimer.setSingleShot(true);
connect(&m_refreshTimer, &QTimer::timeout, this, [this](){
connect(&m_refreshTimer, &QTimer::timeout, this, [this]() {
qCDebug(dcTado()) << "Refresh token...";
getAccessToken();
});
m_pollAuthenticationTimer.setSingleShot(true);
m_pollAuthenticationTimer.setInterval(2000);
connect(&m_pollAuthenticationTimer, &QTimer::timeout, this, [this](){
connect(&m_pollAuthenticationTimer, &QTimer::timeout, this, [this]() {
qCDebug(dcTado()) << "Checking authentication status...";
requestAuthenticationToken();
});
@ -68,7 +68,6 @@ bool Tado::connected()
return m_connectionStatus;
}
QString Tado::loginUrl() const
{
return m_loginUrl;
@ -112,7 +111,6 @@ void Tado::getLoginUrl()
QNetworkReply *reply = m_networkManager->post(request, payload);
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [reply, this] {
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
// Check HTTP status code
if (status != 200 || reply->error() != QNetworkReply::NoError) {
@ -157,7 +155,6 @@ void Tado::getLoginUrl()
});
}
void Tado::getAccessToken()
{
QNetworkRequest request = QNetworkRequest(QUrl(m_baseAuthorizationUrl + "/token"));
@ -175,12 +172,10 @@ void Tado::getAccessToken()
QNetworkReply *reply = m_networkManager->post(request, payload);
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [reply, this] {
QByteArray data = reply->readAll();
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
// Check HTTP status code
if (status != 200 || reply->error() != QNetworkReply::NoError) {
emit connectionError(reply->error());
if (reply->error() == QNetworkReply::HostNotFoundError)
@ -216,7 +211,6 @@ void Tado::getAccessToken()
emit refreshTokenReceived(m_refreshToken);
}
setAuthenticationStatus(true);
// Refresh 10 sekonds before expiration
@ -232,7 +226,7 @@ void Tado::getHomes()
return;
}
if(m_accessToken.isEmpty()) {
if (m_accessToken.isEmpty()) {
qCWarning(dcTado()) << "Not sending request, get the access token first";
return;
}
@ -244,7 +238,6 @@ void Tado::getHomes()
//qCDebug(dcTado()) << "Sending request" << request.url() << request.rawHeaderList();
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [reply, this] {
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
// Check HTTP status code
@ -299,7 +292,7 @@ void Tado::getZones(const QString &homeId)
return;
}
if(m_accessToken.isEmpty()) {
if (m_accessToken.isEmpty()) {
qCWarning(dcTado()) << "Not sending request, get the access token first";
return;
}
@ -312,7 +305,6 @@ void Tado::getZones(const QString &homeId)
//qCDebug(dcTado()) << "Sending request" << request.url() << request.rawHeaderList();
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [reply, homeId, this] {
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
// Check HTTP status code
@ -356,7 +348,7 @@ void Tado::getZoneState(const QString &homeId, const QString &zoneId)
return;
}
if(m_accessToken.isEmpty()) {
if (m_accessToken.isEmpty()) {
qCWarning(dcTado()) << "Not sending request, get the access token first";
return;
}
@ -369,7 +361,6 @@ void Tado::getZoneState(const QString &homeId, const QString &zoneId)
//qCDebug(dcTado()) << "Sending request" << request.url() << request.rawHeaderList();
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [reply, homeId, zoneId, this] {
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
// Check HTTP status code
@ -386,7 +377,6 @@ void Tado::getZoneState(const QString &homeId, const QString &zoneId)
return;
}
setConnectionStatus(true);
setAuthenticationStatus(true);
@ -416,7 +406,7 @@ void Tado::getZoneState(const QString &homeId, const QString &zoneId)
state.temperature = dataMap["insideTemperature"].toMap().value("celsius").toDouble();
state.humidity = dataMap["humidity"].toMap().value("percentage").toDouble();
if (!map["overlay"].toMap().isEmpty()){
if (!map["overlay"].toMap().isEmpty()) {
state.overlayIsSet = true;
QVariantMap overlayMap = map["overlay"].toMap();
state.overlayType = map["overlayType"].toString();
@ -436,7 +426,7 @@ QUuid Tado::setOverlay(const QString &homeId, const QString &zoneId, bool power,
return QUuid();
}
if(m_accessToken.isEmpty()) {
if (m_accessToken.isEmpty()) {
qCWarning(dcTado()) << "Not sending request, get the access token first";
return QUuid();
}
@ -454,13 +444,13 @@ QUuid Tado::setOverlay(const QString &homeId, const QString &zoneId, bool power,
else
powerString = "OFF";
body.append("{\"setting\":{\"type\":\"HEATING\",\"power\":\"" + powerString + "\",\"temperature\":{\"celsius\":" + QVariant(targetTemperature).toByteArray() + "}},\"termination\":{\"type\":\"MANUAL\"}}");
body.append("{\"setting\":{\"type\":\"HEATING\",\"power\":\"" + powerString + "\",\"temperature\":{\"celsius\":" + QVariant(targetTemperature).toByteArray()
+ "}},\"termination\":{\"type\":\"MANUAL\"}}");
//qCDebug(dcTado()) << "Sending request" << body;
QNetworkReply *reply = m_networkManager->put(request, body);
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [homeId, zoneId, requestId, reply, this] {
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status != 200 || reply->error() != QNetworkReply::NoError) {
emit requestExecuted(requestId, false);
@ -513,7 +503,7 @@ QUuid Tado::deleteOverlay(const QString &homeId, const QString &zoneId)
return QUuid();
}
if(m_accessToken.isEmpty()) {
if (m_accessToken.isEmpty()) {
qCWarning(dcTado()) << "Not sending request, get the access token first";
return QUuid();
}
@ -525,11 +515,9 @@ QUuid Tado::deleteOverlay(const QString &homeId, const QString &zoneId)
QNetworkReply *reply = m_networkManager->deleteResource(request);
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [homeId, zoneId, requestId, reply, this] {
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status < 200 || status > 210 || reply->error() != QNetworkReply::NoError) {
emit requestExecuted(requestId ,false);
emit requestExecuted(requestId, false);
emit connectionError(reply->error());
if (reply->error() == QNetworkReply::HostNotFoundError) {
@ -590,7 +578,6 @@ void Tado::requestAuthenticationToken()
QNetworkReply *reply = m_networkManager->post(request, payload);
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, [reply, this] {
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status != 200 || reply->error() != QNetworkReply::NoError) {
qCDebug(dcTado()) << "Request error:" << status << "Retrying:" << m_pollAuthenticationCount << "/" << m_pollAuthenticationLimit;
@ -643,7 +630,6 @@ void Tado::setAuthenticationStatus(bool status)
if (!status)
m_refreshTimer.stop();
}
void Tado::setConnectionStatus(bool status)

View File

@ -25,8 +25,8 @@
#ifndef TADO_H
#define TADO_H
#include <network/networkaccessmanager.h>
#include <integrations/thing.h>
#include <network/networkaccessmanager.h>
#include <QObject>
#include <QTimer>
@ -36,21 +36,24 @@ class Tado : public QObject
{
Q_OBJECT
public:
struct Zone {
struct Zone
{
QString id;
QString name;
QString type;
};
struct Overlay {
bool power;
double temperature;
QString zoneType;
QString terminationType;
QString tadoMode;
struct Overlay
{
bool power;
double temperature;
QString zoneType;
QString terminationType;
QString tadoMode;
};
struct ZoneState {
struct ZoneState
{
bool connected = false;
bool power = false;
QString tadoMode;
@ -68,7 +71,8 @@ public:
QString overlayType;
};
struct Home {
struct Home
{
QString id;
QString name;
};
@ -138,10 +142,9 @@ signals:
void homesReceived(QList<Tado::Home> homes);
void zonesReceived(const QString &homeId, QList<Tado::Zone> zones);
void zoneStateReceived(const QString &homeId,const QString &zoneId, Tado::ZoneState sate);
void zoneStateReceived(const QString &homeId, const QString &zoneId, Tado::ZoneState sate);
void overlayReceived(const QString &homeId, const QString &zoneId, const Tado::Overlay &overlay);
void connectionError(QNetworkReply::NetworkError error);
};
#endif // TADO_H