Added support for 15 minute price resolution (#1031)

* 15min prices WIP

* WIP more changes for 15min prices

* More work on 15min pricing

* Fixed some errors

* Some changes after testing

* Graphical changes for 15min pricing

* Adjustments on MQTT handlers after switching to 15min prices

* Reverted some MQTT changes

* Adapted HA integration for 15min pricing

* Adapted JSON payload for 15min

* Adjustments during testing

* Set default price interval

* Fixed refresh of price graph when data changes

* Bugfixes

* Fixed some issues with raw payload

* Adjustments for meter timestamp from Kamstrup

* Updated readme

* Added detailed breakdown of payloads coming from Norwegian meters

* Minor changes relating to price

* Fixed byte alignment on price config

* Changes to support RC upgraders
This commit is contained in:
Gunnar Skjold 2025-11-13 15:10:54 +01:00 committed by GitHub
parent ffd8d46f2e
commit c648546b61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1073 additions and 637 deletions

View File

@ -82,3 +82,4 @@ jobs:
version: ${{ needs.prepare.outputs.version }}
upload_url: ${{ needs.prepare.outputs.upload_url }}
subfolder: /rc
is_esp32: false

View File

@ -20,6 +20,11 @@ on:
required: false
type: string
default: ''
is_esp32:
description: 'Whether the build is for ESP32 based firmware'
required: false
type: boolean
default: true
jobs:
build-and-deploy:
@ -79,11 +84,11 @@ jobs:
CI: false
- name: PlatformIO lib install
env:
GITHUB_TAG: v${{ inputs.version }}
run: pio lib install
- name: Build firmware
env:
GITHUB_TAG: v${{ inputs.version }}
run: pio run -e ${{ inputs.env }}
- name: Create zip file
@ -119,10 +124,13 @@ jobs:
run: aws s3 cp firmware.md5 s3://${{ secrets.AWS_S3_BUCKET }}/firmware${{ inputs.subfolder }}/ams2mqtt-${{ inputs.env }}-${{ inputs.version }}.md5
- name: Upload bootloader to S3
if: ${{ inputs.is_esp32 }}
run: aws s3 cp .pio/build/${{ inputs.env }}/bootloader.bin s3://${{ secrets.AWS_S3_BUCKET }}/firmware${{ inputs.subfolder }}/ams2mqtt-${{ inputs.env }}-${{ inputs.version }}-bootloader.bin
- name: Upload partition table to S3
if: ${{ inputs.is_esp32 }}
run: aws s3 cp .pio/build/${{ inputs.env }}/partitions.bin s3://${{ secrets.AWS_S3_BUCKET }}/firmware${{ inputs.subfolder }}/ams2mqtt-${{ inputs.env }}-${{ inputs.version }}-partitions.bin
- name: Upload app0 to S3
if: ${{ inputs.is_esp32 }}
run: aws s3 cp ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin s3://${{ secrets.AWS_S3_BUCKET }}/firmware${{ inputs.subfolder }}/ams2mqtt-${{ inputs.env }}-${{ inputs.version }}-app0.bin

View File

@ -76,3 +76,4 @@ jobs:
env: esp8266
version: ${{ needs.prepare.outputs.version }}
upload_url: ${{ needs.prepare.outputs.upload_url }}
is_esp32: false

View File

@ -1,12 +1,15 @@
# AMS Reader
This code is designed to decode data from electric smart meters installed in many countries in Europe these days. The data is presented in a graphical web interface and can also send the data to a MQTT broker which makes it suitable for home automation project. Originally it was only designed to work with Norwegian meters, but has since been adapter to read any IEC-62056-7-5 or IEC-62056-21 compliant meters.
Later development have added Energy usage graph for both day and month, as well as future energy price. The code can run on any ESP8266 or ESP32 hardware which you can read more about in the [WiKi](https://github.com/UtilitechAS/amsreader-firmware/wiki). If you don't have the knowledge to set up a ESP device yourself, or you would like to support our work, please have a look at our shop at [amsleser.no](https://amsleser.no/).
Later development have added Energy usage graph for both day and month, as well as future energy price. The code can run on any ESP8266 or ESP32 hardware which you can read more about in the [WiKi](https://github.com/UtilitechAS/amsreader-firmware/wiki). If you don't have the knowledge to set up a ESP device yourself, or you would like to support our work, please have a look at our shop at [amsleser.no](https://www.amsleser.no/).
<img src="images/dashboard.png">
Go to the [WiKi](https://github.com/UtilitechAS/amsreader-firmware/wiki) for information on how to get your own device! And find the latest prebuilt firmware file at the [release section](https://github.com/UtilitechAS/amsreader-firmware/releases).
## Installing pre-built firmware
If you have a device already running this firmware and you for some reason need to upgrade via USB port, you can use a [this web-based tool](https://www.amsleser.cloud/flasher)
If you are using a development board and want to flash a pre-built firmware manually, get the necessary files from the [release](https://github.com/UtilitechAS/amsreader-firmware/releases) section and visit the [WiKi](https://github.com/UtilitechAS/amsreader-firmware/wiki) and have a look at the [Flashing](https://github.com/UtilitechAS/amsreader-firmware/wiki/flashinghttps://github.com/UtilitechAS/amsreader-firmware/wiki/flashing) section
## Building this project with PlatformIO
To build this project, you need [PlatformIO](https://platformio.org/) installed.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View File

@ -207,10 +207,12 @@ struct PriceServiceConfig {
char entsoeToken[37];
char area[17];
char currency[4];
uint32_t unused1;
bool enabled;
uint8_t resolutionInMinutes;
uint16_t unused2;
}; // 64
uint16_t unused3;
bool enabled;
uint16_t unused6;
};
struct EnergyAccountingConfig {
uint16_t thresholds[10];
@ -237,14 +239,14 @@ struct UiConfig {
}; // 15
struct UpgradeInformation {
char fromVersion[8];
char toVersion[8];
char fromVersion[16];
char toVersion[16];
uint32_t size;
uint16_t block_position;
uint8_t retry_count;
uint8_t reboot_count;
int8_t errorCode;
}; // 25
}; // 41+3
struct CloudConfig {
bool enabled;

View File

@ -655,6 +655,9 @@ bool AmsConfiguration::getPriceServiceConfig(PriceServiceConfig& config) {
clearPriceServiceConfig(config);
return false;
}
if(config.resolutionInMinutes != 15 && config.resolutionInMinutes != 60) {
config.resolutionInMinutes = 60;
}
return true;
} else {
clearPriceServiceConfig(config);
@ -669,6 +672,7 @@ bool AmsConfiguration::setPriceServiceConfig(PriceServiceConfig& config) {
priceChanged |= strcmp(config.area, existing.area) != 0;
priceChanged |= strcmp(config.currency, existing.currency) != 0;
priceChanged |= config.enabled != existing.enabled;
priceChanged |= config.resolutionInMinutes != existing.resolutionInMinutes;
} else {
priceChanged = true;
}
@ -688,9 +692,8 @@ void AmsConfiguration::clearPriceServiceConfig(PriceServiceConfig& config) {
memset(config.entsoeToken, 0, 37);
memset(config.area, 0, 17);
memset(config.currency, 0, 4);
config.unused1 = 1000;
config.enabled = false;
config.unused2 = 0;
config.resolutionInMinutes = 60;
}
bool AmsConfiguration::isPriceServiceChanged() {

View File

@ -97,7 +97,7 @@ private:
uint32_t lastVersionCheck = 0;
uint8_t firmwareVariant;
bool autoUpgrade;
char nextVersion[10];
char nextVersion[17];
bool fetchNextVersion();

View File

@ -114,6 +114,7 @@ bool EthernetConnectionHandler::connect(NetworkConfig config, SystemConfig sys)
debugger->printf_P(PSTR("Static IP configuration is invalid, not using\n"));
}
}
this->config = config;
} else {
#if defined(AMS_REMOTE_DEBUG)
if (debugger->isActive(RemoteDebug::ERROR))
@ -147,6 +148,9 @@ void EthernetConnectionHandler::eventHandler(WiFiEvent_t event, WiFiEventInfo_t
{
debugger->printf_P(PSTR("Successfully connected to Ethernet!\n"));
}
if(config.ipv6 && !ETH.enableIpV6()) {
debugger->printf_P(PSTR("Unable to enable IPv6\n"));
}
break;
case ARDUINO_EVENT_ETH_GOT_IP:
#if defined(AMS_REMOTE_DEBUG)

View File

@ -26,9 +26,11 @@ struct EnergyAccountingPeak6 {
struct EnergyAccountingData {
uint8_t version;
uint8_t month;
int32_t costToday;
int32_t costYesterday;
int32_t costThisMonth;
int32_t costLastMonth;
int32_t incomeToday;
int32_t incomeYesterday;
int32_t incomeThisMonth;
int32_t incomeLastMonth;
@ -36,6 +38,7 @@ struct EnergyAccountingData {
uint32_t lastMonthExport;
uint8_t lastMonthAccuracy;
EnergyAccountingPeak peaks[5];
time_t lastUpdated;
};
struct EnergyAccountingData6 {
@ -115,7 +118,6 @@ public:
void setData(EnergyAccountingData&);
void setCurrency(String currency);
float getPriceForHour(uint8_t d, uint8_t h);
private:
#if defined(AMS_REMOTE_DEBUG)
@ -128,7 +130,7 @@ private:
PriceService *ps = NULL;
EnergyAccountingConfig *config = NULL;
Timezone *tz = NULL;
EnergyAccountingData data = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
EnergyAccountingData data = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };
EnergyAccountingRealtimeData* realtimeData = NULL;
String currency = "";

View File

@ -30,7 +30,7 @@ EnergyAccounting::EnergyAccounting(Stream* Stream, EnergyAccountingRealtimeData*
rtd->lastImportUpdateMillis = 0;
rtd->lastExportUpdateMillis = 0;
}
this->realtimeData = rtd;
realtimeData = rtd;
}
void EnergyAccounting::setup(AmsDataStorage *ds, EnergyAccountingConfig *config) {
@ -67,14 +67,14 @@ bool EnergyAccounting::update(AmsData* amsData) {
breakTime(tz->toLocal(now), local);
if(!init) {
this->realtimeData->lastImportUpdateMillis = 0;
this->realtimeData->lastExportUpdateMillis = 0;
this->realtimeData->currentHour = local.Hour;
this->realtimeData->currentDay = local.Day;
realtimeData->lastImportUpdateMillis = 0;
realtimeData->lastExportUpdateMillis = 0;
realtimeData->currentHour = local.Hour;
realtimeData->currentDay = local.Day;
if(!load()) {
data = { 7, local.Month,
0, 0, 0, // Cost
0, 0, 0, // Income
0, 0, 0, 0, // Cost
0, 0, 0, 0, // Income
0, 0, 0, // Last month import, export and accuracy
0, 0, 0, // Peak 1
0, 0, 0, // Peak 2
@ -86,12 +86,11 @@ bool EnergyAccounting::update(AmsData* amsData) {
init = true;
}
float importPrice = getPriceForHour(PRICE_DIRECTION_IMPORT, 0);
if(!initPrice && importPrice != PRICE_NO_VALUE) {
if(!initPrice && ps != NULL && ps->hasPrice()) {
calcDayCost();
}
if(local.Hour != this->realtimeData->currentHour && (amsData->getListType() >= 3 || local.Minute == 1)) {
if(local.Hour != realtimeData->currentHour && (amsData->getListType() >= 3 || local.Minute == 1)) {
tmElements_t oneHrAgo, oneHrAgoLocal;
breakTime(now-3600, oneHrAgo);
uint16_t val = round(ds->getHourImport(oneHrAgo.Hour) / 10.0);
@ -99,27 +98,24 @@ bool EnergyAccounting::update(AmsData* amsData) {
breakTime(tz->toLocal(now-3600), oneHrAgoLocal);
ret |= updateMax(val, oneHrAgoLocal.Day, oneHrAgoLocal.Hour);
this->realtimeData->currentHour = local.Hour; // Need to be defined here so that day cost is correctly calculated
if(local.Hour > 0) {
calcDayCost();
}
realtimeData->currentHour = local.Hour; // Need to be defined here so that day cost is correctly calculated
this->realtimeData->use = 0;
this->realtimeData->produce = 0;
this->realtimeData->costHour = 0;
this->realtimeData->incomeHour = 0;
realtimeData->use = 0;
realtimeData->produce = 0;
realtimeData->costHour = 0;
realtimeData->incomeHour = 0;
uint8_t prevDay = this->realtimeData->currentDay;
if(local.Day != this->realtimeData->currentDay) {
data.costYesterday = this->realtimeData->costDay * 100;
data.costThisMonth += this->realtimeData->costDay * 100;
this->realtimeData->costDay = 0;
uint8_t prevDay = realtimeData->currentDay;
if(local.Day != realtimeData->currentDay) {
data.costYesterday = realtimeData->costDay * 100;
data.costThisMonth += realtimeData->costDay * 100;
realtimeData->costDay = 0;
data.incomeYesterday = this->realtimeData->incomeDay * 100;
data.incomeThisMonth += this->realtimeData->incomeDay * 100;
this->realtimeData->incomeDay = 0;
data.incomeYesterday = realtimeData->incomeDay * 100;
data.incomeThisMonth += realtimeData->incomeDay * 100;
realtimeData->incomeDay = 0;
this->realtimeData->currentDay = local.Day;
realtimeData->currentDay = local.Day;
ret = true;
}
@ -149,42 +145,49 @@ bool EnergyAccounting::update(AmsData* amsData) {
data.lastMonthAccuracy = accuracy;
data.month = local.Month;
this->realtimeData->currentThresholdIdx = 0;
realtimeData->currentThresholdIdx = 0;
ret = true;
}
if(ret) {
data.costToday = realtimeData->costDay * 100;
data.incomeToday = realtimeData->incomeDay * 100;
data.lastUpdated = now;
}
}
if(this->realtimeData->lastImportUpdateMillis < amsData->getLastUpdateMillis()) {
unsigned long ms = amsData->getLastUpdateMillis() - this->realtimeData->lastImportUpdateMillis;
if(realtimeData->lastImportUpdateMillis < amsData->getLastUpdateMillis()) {
unsigned long ms = amsData->getLastUpdateMillis() - realtimeData->lastImportUpdateMillis;
float kwhi = (amsData->getActiveImportPower() * (((float) ms) / 3600000.0)) / 1000.0;
if(kwhi > 0) {
this->realtimeData->use += kwhi;
realtimeData->use += kwhi;
float importPrice = ps == NULL ? PRICE_NO_VALUE : ps->getCurrentPrice(PRICE_DIRECTION_IMPORT);
if(importPrice != PRICE_NO_VALUE) {
float cost = importPrice * kwhi;
this->realtimeData->costHour += cost;
this->realtimeData->costDay += cost;
realtimeData->costHour += cost;
realtimeData->costDay += cost;
}
}
this->realtimeData->lastImportUpdateMillis = amsData->getLastUpdateMillis();
realtimeData->lastImportUpdateMillis = amsData->getLastUpdateMillis();
}
if(amsData->getListType() > 1 && this->realtimeData->lastExportUpdateMillis < amsData->getLastUpdateMillis()) {
unsigned long ms = amsData->getLastUpdateMillis() - this->realtimeData->lastExportUpdateMillis;
if(amsData->getListType() > 1 && realtimeData->lastExportUpdateMillis < amsData->getLastUpdateMillis()) {
unsigned long ms = amsData->getLastUpdateMillis() - realtimeData->lastExportUpdateMillis;
float kwhe = (amsData->getActiveExportPower() * (((float) ms) / 3600000.0)) / 1000.0;
if(kwhe > 0) {
this->realtimeData->produce += kwhe;
float exportPrice = getPriceForHour(PRICE_DIRECTION_EXPORT, 0);
realtimeData->produce += kwhe;
float exportPrice = ps == NULL ? PRICE_NO_VALUE : ps->getCurrentPrice(PRICE_DIRECTION_EXPORT);
if(exportPrice != PRICE_NO_VALUE) {
float income = exportPrice * kwhe;
this->realtimeData->incomeHour += income;
this->realtimeData->incomeDay += income;
realtimeData->incomeHour += income;
realtimeData->incomeDay += income;
}
}
this->realtimeData->lastExportUpdateMillis = amsData->getLastUpdateMillis();
realtimeData->lastExportUpdateMillis = amsData->getLastUpdateMillis();
}
if(config != NULL) {
while(getMonthMax() > config->thresholds[this->realtimeData->currentThresholdIdx] && this->realtimeData->currentThresholdIdx < 10) this->realtimeData->currentThresholdIdx++;
while(getMonthMax() > config->thresholds[realtimeData->currentThresholdIdx] && realtimeData->currentThresholdIdx < 10) realtimeData->currentThresholdIdx++;
}
return ret;
@ -192,28 +195,36 @@ bool EnergyAccounting::update(AmsData* amsData) {
void EnergyAccounting::calcDayCost() {
time_t now = time(nullptr);
tmElements_t local, utc;
tmElements_t local, utc, lastUpdateUtc;
if(tz == NULL) return;
breakTime(tz->toLocal(now), local);
if(ps == NULL) return;
if(getPriceForHour(PRICE_DIRECTION_IMPORT, 0) != PRICE_NO_VALUE) {
if(initPrice) {
this->realtimeData->costDay = 0;
this->realtimeData->incomeDay = 0;
if(ps->hasPrice()) {
breakTime(data.lastUpdated, lastUpdateUtc);
uint8_t calcFromHour = 0;
if(lastUpdateUtc.Day != local.Day || lastUpdateUtc.Month != local.Month || lastUpdateUtc.Year != local.Year) {
realtimeData->costDay = 0;
realtimeData->incomeDay = 0;
calcFromHour = 0;
} else {
realtimeData->costDay = data.costToday / 100.0;
realtimeData->incomeDay = data.incomeToday / 100.0;
calcFromHour = lastUpdateUtc.Hour;
}
for(uint8_t i = 0; i < this->realtimeData->currentHour; i++) {
for(uint8_t i = calcFromHour; i < realtimeData->currentHour; i++) {
breakTime(now - ((local.Hour - i) * 3600), utc);
float priceIn = getPriceForHour(PRICE_DIRECTION_IMPORT, i - local.Hour);
float priceIn = ps->getPriceForRelativeHour(PRICE_DIRECTION_IMPORT, i - local.Hour);
if(priceIn != PRICE_NO_VALUE) {
int16_t wh = ds->getHourImport(utc.Hour);
this->realtimeData->costDay += priceIn * (wh / 1000.0);
realtimeData->costDay += priceIn * (wh / 1000.0);
}
float priceOut = getPriceForHour(PRICE_DIRECTION_EXPORT, i - local.Hour);
float priceOut = ps->getPriceForRelativeHour(PRICE_DIRECTION_EXPORT, i - local.Hour);
if(priceOut != PRICE_NO_VALUE) {
int16_t wh = ds->getHourExport(utc.Hour);
this->realtimeData->incomeDay += priceOut * (wh / 1000.0);
realtimeData->incomeDay += priceOut * (wh / 1000.0);
}
}
initPrice = true;
@ -221,7 +232,7 @@ void EnergyAccounting::calcDayCost() {
}
float EnergyAccounting::getUseThisHour() {
return this->realtimeData->use;
return realtimeData->use;
}
float EnergyAccounting::getUseToday() {
@ -231,7 +242,7 @@ float EnergyAccounting::getUseToday() {
if(now < FirmwareVersion::BuildEpoch) return 0.0;
tmElements_t utc, local;
breakTime(tz->toLocal(now), local);
for(uint8_t i = 0; i < this->realtimeData->currentHour; i++) {
for(uint8_t i = 0; i < realtimeData->currentHour; i++) {
breakTime(now - ((local.Hour - i) * 3600), utc);
ret += ds->getHourImport(utc.Hour) / 1000.0;
}
@ -242,7 +253,7 @@ float EnergyAccounting::getUseThisMonth() {
time_t now = time(nullptr);
if(now < FirmwareVersion::BuildEpoch) return 0.0;
float ret = 0;
for(uint8_t i = 1; i < this->realtimeData->currentDay; i++) {
for(uint8_t i = 1; i < realtimeData->currentDay; i++) {
ret += ds->getDayImport(i) / 1000.0;
}
return ret + getUseToday();
@ -253,7 +264,7 @@ float EnergyAccounting::getUseLastMonth() {
}
float EnergyAccounting::getProducedThisHour() {
return this->realtimeData->produce;
return realtimeData->produce;
}
float EnergyAccounting::getProducedToday() {
@ -263,7 +274,7 @@ float EnergyAccounting::getProducedToday() {
if(now < FirmwareVersion::BuildEpoch) return 0.0;
tmElements_t utc, local;
breakTime(tz->toLocal(now), local);
for(uint8_t i = 0; i < this->realtimeData->currentHour; i++) {
for(uint8_t i = 0; i < realtimeData->currentHour; i++) {
breakTime(now - ((local.Hour - i) * 3600), utc);
ret += ds->getHourExport(utc.Hour) / 1000.0;
}
@ -274,7 +285,7 @@ float EnergyAccounting::getProducedThisMonth() {
time_t now = time(nullptr);
if(now < FirmwareVersion::BuildEpoch) return 0.0;
float ret = 0;
for(uint8_t i = 1; i < this->realtimeData->currentDay; i++) {
for(uint8_t i = 1; i < realtimeData->currentDay; i++) {
ret += ds->getDayExport(i) / 1000.0;
}
return ret + getProducedToday();
@ -285,11 +296,11 @@ float EnergyAccounting::getProducedLastMonth() {
}
float EnergyAccounting::getCostThisHour() {
return this->realtimeData->costHour;
return realtimeData->costHour;
}
float EnergyAccounting::getCostToday() {
return this->realtimeData->costDay;
return realtimeData->costDay;
}
float EnergyAccounting::getCostYesterday() {
@ -305,11 +316,11 @@ float EnergyAccounting::getCostLastMonth() {
}
float EnergyAccounting::getIncomeThisHour() {
return this->realtimeData->incomeHour;
return realtimeData->incomeHour;
}
float EnergyAccounting::getIncomeToday() {
return this->realtimeData->incomeDay;
return realtimeData->incomeDay;
}
float EnergyAccounting::getIncomeYesterday() {
@ -327,7 +338,7 @@ float EnergyAccounting::getIncomeLastMonth() {
uint8_t EnergyAccounting::getCurrentThreshold() {
if(config == NULL)
return 0;
return config->thresholds[this->realtimeData->currentThresholdIdx];
return config->thresholds[realtimeData->currentThresholdIdx];
}
float EnergyAccounting::getMonthMax() {
@ -414,9 +425,11 @@ bool EnergyAccounting::load() {
} else if(buf[0] == 6) {
EnergyAccountingData6* data = (EnergyAccountingData6*) buf;
this->data = { 7, data->month,
0, // Cost today
data->costYesterday,
data->costThisMonth,
data->costLastMonth,
0, // Income today
data->incomeYesterday,
data->incomeThisMonth,
data->incomeLastMonth,
@ -495,8 +508,3 @@ bool EnergyAccounting::updateMax(uint16_t val, uint8_t day, uint8_t hour) {
void EnergyAccounting::setCurrency(String currency) {
this->currency = currency;
}
float EnergyAccounting::getPriceForHour(uint8_t d, uint8_t h) {
if(ps == NULL) return PRICE_NO_VALUE;
return ps->getValueForHour(d, h);
}

View File

@ -53,7 +53,7 @@ private:
bool l1Init, l2Init, l2eInit, l3Init, l3eInit, l4Init, l4eInit, rtInit, rteInit, pInit, sInit, rInit, fInit;
bool tInit[32] = {false};
bool prInit[38] = {false};
uint8_t priceImportInit = 0, priceExportInit = 0;
uint32_t lastThresholdPublish = 0;
HwTools* hw;

View File

@ -17,113 +17,112 @@ struct HomeAssistantSensor {
const char* uom;
const char* devcl;
const char* stacl;
const char* uid;
};
const uint8_t List1SensorCount PROGMEM = 2;
const HomeAssistantSensor List1Sensors[List1SensorCount] PROGMEM = {
{"Active import", "/power", "P", 30, "W", "power", "measurement"},
{"Data timestamp", "/power", "t", 30, "", "timestamp", ""}
{"Active import", "/power", "P", 30, "W", "power", "measurement", ""},
{"Data timestamp", "/power", "t", 30, "", "timestamp", "", ""}
};
const uint8_t List2SensorCount PROGMEM = 8;
const HomeAssistantSensor List2Sensors[List2SensorCount] PROGMEM = {
{"Reactive import", "/power", "Q", 30, "var", "reactive_power", "measurement"},
{"Reactive export", "/power", "QO", 30, "var", "reactive_power", "measurement"},
{"L1 current", "/power", "I1", 30, "A", "current", "measurement"},
{"L2 current", "/power", "I2", 30, "A", "current", "measurement"},
{"L3 current", "/power", "I3", 30, "A", "current", "measurement"},
{"L1 voltage", "/power", "U1", 30, "V", "voltage", "measurement"},
{"L2 voltage", "/power", "U2", 30, "V", "voltage", "measurement"},
{"L3 voltage", "/power", "U3", 30, "V", "voltage", "measurement"}
{"Reactive import", "/power", "Q", 30, "var", "reactive_power", "measurement", ""},
{"Reactive export", "/power", "QO", 30, "var", "reactive_power", "measurement", ""},
{"L1 current", "/power", "I1", 30, "A", "current", "measurement", ""},
{"L2 current", "/power", "I2", 30, "A", "current", "measurement", ""},
{"L3 current", "/power", "I3", 30, "A", "current", "measurement", ""},
{"L1 voltage", "/power", "U1", 30, "V", "voltage", "measurement", ""},
{"L2 voltage", "/power", "U2", 30, "V", "voltage", "measurement", ""},
{"L3 voltage", "/power", "U3", 30, "V", "voltage", "measurement", ""}
};
const uint8_t List2ExportSensorCount PROGMEM = 1;
const HomeAssistantSensor List2ExportSensors[List2ExportSensorCount] PROGMEM = {
{"Active export", "/power", "PO", 30, "W", "power", "measurement"}
{"Active export", "/power", "PO", 30, "W", "power", "measurement", ""}
};
const uint8_t List3SensorCount PROGMEM = 4;
const HomeAssistantSensor List3Sensors[List3SensorCount] PROGMEM = {
{"Accumulated active import", "/energy", "tPI", 4000, "kWh", "energy", "total_increasing"},
{"Accumulated reactive import","/energy", "tQI", 4000, "kvarh","", "total_increasing"},
{"Accumulated reactive export","/energy", "tQO", 4000, "kvarh","", "total_increasing"},
{"Meter timestamp", "/energy", "rtc", 4000, "", "timestamp", ""}
{"Accumulated active import", "/energy", "tPI", 4000, "kWh", "energy", "total_increasing", ""},
{"Accumulated reactive import","/energy", "tQI", 4000, "kvarh","", "total_increasing", ""},
{"Accumulated reactive export","/energy", "tQO", 4000, "kvarh","", "total_increasing", ""},
{"Meter timestamp", "/energy", "rtc", 4000, "", "timestamp", "", ""}
};
const uint8_t List3ExportSensorCount PROGMEM = 1;
const HomeAssistantSensor List3ExportSensors[List3ExportSensorCount] PROGMEM = {
{"Accumulated active export", "/energy", "tPO", 4000, "kWh", "energy", "total_increasing"}
{"Accumulated active export", "/energy", "tPO", 4000, "kWh", "energy", "total_increasing", ""}
};
const uint8_t List4SensorCount PROGMEM = 10;
const HomeAssistantSensor List4Sensors[List4SensorCount] PROGMEM = {
{"Power factor", "/power", "PF", 30, "%", "power_factor", "measurement"},
{"L1 power factor", "/power", "PF1", 30, "%", "power_factor", "measurement"},
{"L2 power factor", "/power", "PF2", 30, "%", "power_factor", "measurement"},
{"L3 power factor", "/power", "PF3", 30, "%", "power_factor", "measurement"},
{"L1 active import", "/power", "P1", 30, "W", "power", "measurement"},
{"L2 active import", "/power", "P2", 30, "W", "power", "measurement"},
{"L3 active import", "/power", "P3", 30, "W", "power", "measurement"},
{"L1 accumulated active import","/power", "tPI1", 30, "kWh", "energy", "total_increasing"},
{"L2 accumulated active import","/power", "tPI2", 30, "kWh", "energy", "total_increasing"},
{"L3 accumulated active import","/power", "tPI3", 30, "kWh", "energy", "total_increasing"}
{"Power factor", "/power", "PF", 30, "%", "power_factor", "measurement", ""},
{"L1 power factor", "/power", "PF1", 30, "%", "power_factor", "measurement", ""},
{"L2 power factor", "/power", "PF2", 30, "%", "power_factor", "measurement", ""},
{"L3 power factor", "/power", "PF3", 30, "%", "power_factor", "measurement", ""},
{"L1 active import", "/power", "P1", 30, "W", "power", "measurement", ""},
{"L2 active import", "/power", "P2", 30, "W", "power", "measurement", ""},
{"L3 active import", "/power", "P3", 30, "W", "power", "measurement", ""},
{"L1 accumulated active import","/power", "tPI1", 30, "kWh", "energy", "total_increasing", ""},
{"L2 accumulated active import","/power", "tPI2", 30, "kWh", "energy", "total_increasing", ""},
{"L3 accumulated active import","/power", "tPI3", 30, "kWh", "energy", "total_increasing", ""}
};
const uint8_t List4ExportSensorCount PROGMEM = 6;
const HomeAssistantSensor List4ExportSensors[List4ExportSensorCount] PROGMEM = {
{"L1 active export", "/power", "PO1", 30, "W", "power", "measurement"},
{"L2 active export", "/power", "PO2", 30, "W", "power", "measurement"},
{"L3 active export", "/power", "PO3", 30, "W", "power", "measurement"},
{"L1 accumulated active export","/power", "tPO1", 30, "kWh", "energy", "total_increasing"},
{"L2 accumulated active export","/power", "tPO2", 30, "kWh", "energy", "total_increasing"},
{"L3 accumulated active export","/power", "tPO3", 30, "kWh", "energy", "total_increasing"}
{"L1 active export", "/power", "PO1", 30, "W", "power", "measurement", ""},
{"L2 active export", "/power", "PO2", 30, "W", "power", "measurement", ""},
{"L3 active export", "/power", "PO3", 30, "W", "power", "measurement", ""},
{"L1 accumulated active export","/power", "tPO1", 30, "kWh", "energy", "total_increasing", ""},
{"L2 accumulated active export","/power", "tPO2", 30, "kWh", "energy", "total_increasing", ""},
{"L3 accumulated active export","/power", "tPO3", 30, "kWh", "energy", "total_increasing", ""}
};
const uint8_t RealtimeSensorCount PROGMEM = 8;
const HomeAssistantSensor RealtimeSensors[RealtimeSensorCount] PROGMEM = {
{"Month max", "/realtime","max", 120, "kWh", "energy", ""},
{"Tariff threshold", "/realtime","threshold", 120, "kWh", "energy", ""},
{"Current hour used", "/realtime","hour.use", 120, "kWh", "energy", "total_increasing"},
{"Current hour cost", "/realtime","hour.cost", 120, "", "monetary", ""},
{"Current day used", "/realtime","day.use", 120, "kWh", "energy", "total_increasing"},
{"Current day cost", "/realtime","day.cost", 120, "", "monetary", ""},
{"Current month used", "/realtime","month.use", 120, "kWh", "energy", "total_increasing"},
{"Current month cost", "/realtime","month.cost", 120, "", "monetary", ""}
{"Month max", "/realtime","max", 120, "kWh", "energy", "", ""},
{"Tariff threshold", "/realtime","threshold", 120, "kWh", "energy", "", ""},
{"Current hour used", "/realtime","hour.use", 120, "kWh", "energy", "total_increasing", ""},
{"Current hour cost", "/realtime","hour.cost", 120, "", "monetary", "", ""},
{"Current day used", "/realtime","day.use", 120, "kWh", "energy", "total_increasing", ""},
{"Current day cost", "/realtime","day.cost", 120, "", "monetary", "", ""},
{"Current month used", "/realtime","month.use", 120, "kWh", "energy", "total_increasing", ""},
{"Current month cost", "/realtime","month.cost", 120, "", "monetary", "", ""}
};
const uint8_t RealtimeExportSensorCount PROGMEM = 6;
const HomeAssistantSensor RealtimeExportSensors[RealtimeExportSensorCount] PROGMEM = {
{"Current hour produced", "/realtime","hour.produced", 120, "kWh", "energy", "total_increasing"},
{"Current hour income", "/realtime","hour.income", 120, "", "monetary", ""},
{"Current day produced", "/realtime","day.produced", 120, "kWh", "energy", "total_increasing"},
{"Current day income", "/realtime","day.income", 120, "", "monetary", ""},
{"Current month produced", "/realtime","month.produced", 120, "kWh", "energy", "total_increasing"},
{"Current month income", "/realtime","month.income", 120, "", "monetary", ""}
{"Current hour produced", "/realtime","hour.produced", 120, "kWh", "energy", "total_increasing", ""},
{"Current hour income", "/realtime","hour.income", 120, "", "monetary", "", ""},
{"Current day produced", "/realtime","day.produced", 120, "kWh", "energy", "total_increasing", ""},
{"Current day income", "/realtime","day.income", 120, "", "monetary", "", ""},
{"Current month produced", "/realtime","month.produced", 120, "kWh", "energy", "total_increasing", ""},
{"Current month income", "/realtime","month.income", 120, "", "monetary", "", ""}
};
const HomeAssistantSensor RealtimePeakSensor PROGMEM = {"Current month peak %d", "/realtime", "peaks[%d]", 4000, "kWh", "energy", ""};
const HomeAssistantSensor RealtimeThresholdSensor PROGMEM = {"Tariff threshold %d", "/realtime", "thresholds[%d]", 4000, "kWh", "energy", ""};
const HomeAssistantSensor RealtimePeakSensor PROGMEM = {"Current month peak %d", "/realtime", "peaks[%d]", 4000, "kWh", "energy", "", ""};
const HomeAssistantSensor RealtimeThresholdSensor PROGMEM = {"Tariff threshold %d", "/realtime", "thresholds[%d]", 4000, "kWh", "energy", "", ""};
const uint8_t PriceSensorCount PROGMEM = 5;
const HomeAssistantSensor PriceSensors[PriceSensorCount] PROGMEM = {
{"Minimum price ahead", "/prices", "prices.min", 4000, "", "monetary", ""},
{"Maximum price ahead", "/prices", "prices.max", 4000, "", "monetary", ""},
{"Cheapest 1hr period ahead", "/prices", "prices.cheapest1hr",4000, "", "timestamp", ""},
{"Cheapest 3hr period ahead", "/prices", "prices.cheapest3hr",4000, "", "timestamp", ""},
{"Cheapest 6hr period ahead", "/prices", "prices.cheapest6hr",4000, "", "timestamp", ""}
{"Minimum price ahead", "/prices", "prices.min", 4000, "", "monetary", "", ""},
{"Maximum price ahead", "/prices", "prices.max", 4000, "", "monetary", "", ""},
{"Cheapest 1hr period ahead", "/prices", "prices.cheapest1hr", 4000, "", "timestamp", "", ""},
{"Cheapest 3hr period ahead", "/prices", "prices.cheapest3hr", 4000, "", "timestamp", "", ""},
{"Cheapest 6hr period ahead", "/prices", "prices.cheapest6hr", 4000, "", "timestamp", "", ""}
};
const HomeAssistantSensor PriceSensor PROGMEM = {"Price in %02d %s", "/prices", "prices['%d']", 4000, "", "monetary", ""};
const uint8_t SystemSensorCount PROGMEM = 3;
const HomeAssistantSensor SystemSensors[SystemSensorCount] PROGMEM = {
{"Status", "/state", "rssi", 180, "dBm", "signal_strength", "measurement"},
{"Supply volt", "/state", "vcc", 180, "V", "voltage", "measurement"},
{"Uptime", "/state", "up", 180, "s", "duration", "measurement"}
{"Status", "/state", "rssi", 180, "dBm", "signal_strength", "measurement", ""},
{"Supply volt", "/state", "vcc", 180, "V", "voltage", "measurement", ""},
{"Uptime", "/state", "up", 180, "s", "duration", "measurement", ""}
};
const HomeAssistantSensor TemperatureSensor PROGMEM = {"Temperature sensor %s", "/temperatures", "temperatures['%s']", 900, "°C", "temperature", "measurement"};
const HomeAssistantSensor TemperatureSensor PROGMEM = {"Temperature sensor %s", "/temperatures", "temperatures['%s']", 900, "°C", "temperature", "measurement", ""};
#endif

View File

@ -35,6 +35,10 @@ void HomeAssistantMqttHandler::setHomeAssistantConfig(HomeAssistantConfig config
manufacturer = boardManufacturerToString(boardType);
deviceUid = String(hostname); // Maybe configurable in the future?
#if defined(AMS_REMOTE_DEBUG)
if (debugger->isActive(RemoteDebug::INFO))
#endif
debugger->printf_P(PSTR(" Hostname is [%s]\n"), hostname);
if(strlen(config.discoveryHostname) > 0) {
if(strncmp_P(config.discoveryHostname, PSTR("http"), 4) == 0) {
@ -348,7 +352,7 @@ bool HomeAssistantMqttHandler::publishTemperatures(AmsConfiguration* config, HwT
bool HomeAssistantMqttHandler::publishPrices(PriceService* ps) {
if(pubTopic.isEmpty() || !mqtt.connected())
return false;
if(ps->getValueForHour(PRICE_DIRECTION_IMPORT, 0) == PRICE_NO_VALUE)
if(!ps->hasPrice())
return false;
publishPriceSensors(ps);
@ -361,7 +365,7 @@ bool HomeAssistantMqttHandler::publishPrices(PriceService* ps) {
float values[38];
for(int i = 0;i < 38; i++) values[i] = PRICE_NO_VALUE;
for(uint8_t i = 0; i < 38; i++) {
float val = ps->getValueForHour(PRICE_DIRECTION_IMPORT, now, i);
float val = ps->getPriceForRelativeHour(PRICE_DIRECTION_IMPORT, i);
values[i] = val;
if(val == PRICE_NO_VALUE) break;
@ -430,31 +434,41 @@ bool HomeAssistantMqttHandler::publishPrices(PriceService* ps) {
sprintf_P(ts6hr, PSTR("%04d-%02d-%02dT%02d:00:00Z"), tm.Year+1970, tm.Month, tm.Day, tm.Hour);
}
uint16_t pos = snprintf_P(json, BufferSize, PSTR("{\"id\":\"%s\",\"prices\":{"), WiFi.macAddress().c_str());
for(uint8_t i = 0;i < 38; i++) {
if(values[i] == PRICE_NO_VALUE) {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"%d\":null,"), i);
uint16_t pos = snprintf_P(json, BufferSize, PSTR("{\"id\":\"%s\",\"prices\":{\"import\":["), WiFi.macAddress().c_str());
uint8_t currentPricePointIndex = ps->getCurrentPricePointIndex();
uint8_t numberOfPoints = ps->getNumberOfPointsAvailable();
for(int i = currentPricePointIndex; i < numberOfPoints; i++) {
float val = ps->getPricePoint(PRICE_DIRECTION_IMPORT, i);
if(val == PRICE_NO_VALUE) {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("null,"));
} else {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"%d\":%.4f,"), i, values[i]);
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("%.4f,"), val);
}
}
if(rteInit && ps->isExportPricesDifferentFromImport()) {
pos--;
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("],\"export\":["));
for(int i = currentPricePointIndex; i < numberOfPoints; i++) {
float val = ps->getPricePoint(PRICE_DIRECTION_EXPORT, i);
if(val == PRICE_NO_VALUE) {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("null,"));
} else {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("%.4f,"), val);
}
}
}
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"min\":%.4f,\"max\":%.4f,\"cheapest1hr\":\"%s\",\"cheapest3hr\":\"%s\",\"cheapest6hr\":\"%s\"}"),
pos--;
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("],\"min\":%.4f,\"max\":%.4f,\"cheapest1hr\":\"%s\",\"cheapest3hr\":\"%s\",\"cheapest6hr\":\"%s\"}"),
min == INT16_MAX ? 0.0 : min,
max == INT16_MIN ? 0.0 : max,
ts1hr,
ts3hr,
ts6hr
);
float val = ps->getValueForHour(PRICE_DIRECTION_EXPORT, now, 0);
if(val == PRICE_NO_VALUE) {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR(",\"exportprices\":{\"0\":null}"));
} else {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR(",\"exportprices\":{\"0\":%.4f}"), val);
}
char pt[24];
char pt[24];
memset(pt, 0, 24);
if(now > 0) {
tmElements_t tm;
@ -503,11 +517,16 @@ bool HomeAssistantMqttHandler::publishSystem(HwTools* hw, PriceService* ps, Ener
}
void HomeAssistantMqttHandler::publishSensor(const HomeAssistantSensor sensor) {
String uid = String(sensor.path);
uid.replace(".", "");
uid.replace("[", "");
uid.replace("]", "");
uid.replace("'", "");
String uid;
if(strlen(sensor.uid) > 0) {
uid = String(sensor.uid);
} else {
uid = String(sensor.path);
uid.replace(".", "");
uid.replace("[", "");
uid.replace("]", "");
uid.replace("'", "");
}
snprintf_P(json, BufferSize, HADISCOVER_JSON,
sensorNamePrefix.c_str(),
sensor.name,
@ -530,7 +549,7 @@ void HomeAssistantMqttHandler::publishSensor(const HomeAssistantSensor sensor) {
strlen_P(sensor.stacl) > 0 ? (char *) FPSTR(sensor.stacl) : "",
strlen_P(sensor.stacl) > 0 ? "\"" : ""
);
mqtt.publish(sensorTopic + "/" + deviceUid + "_" + uid.c_str() + "/config", json, true, 0);
mqtt.publish(sensorTopic + "/" + deviceUid + "_" + uid + "/config", json, true, 0);
loop();
}
@ -619,7 +638,8 @@ void HomeAssistantMqttHandler::publishRealtimeSensors(EnergyAccounting* ea, Pric
RealtimePeakSensor.ttl,
RealtimePeakSensor.uom,
RealtimePeakSensor.devcl,
RealtimePeakSensor.stacl
RealtimePeakSensor.stacl,
RealtimePeakSensor.uid
};
publishSensor(sensor);
}
@ -658,7 +678,8 @@ void HomeAssistantMqttHandler::publishTemperatureSensor(uint8_t index, String id
TemperatureSensor.ttl,
TemperatureSensor.uom,
TemperatureSensor.devcl,
TemperatureSensor.stacl
TemperatureSensor.stacl,
TemperatureSensor.uid
};
publishSensor(sensor);
tInit[index] = true;
@ -678,45 +699,96 @@ void HomeAssistantMqttHandler::publishPriceSensors(PriceService* ps) {
}
pInit = true;
}
for(uint8_t i = 0; i < 38; i++) {
if(prInit[i]) continue;
float val = ps->getValueForHour(PRICE_DIRECTION_IMPORT, i);
if(val == PRICE_NO_VALUE) continue;
char name[strlen(PriceSensor.name)+2];
snprintf(name, strlen(PriceSensor.name)+2, PriceSensor.name, i, i == 1 ? "hour" : "hours");
char path[strlen(PriceSensor.path)+1];
snprintf(path, strlen(PriceSensor.path)+1, PriceSensor.path, i);
HomeAssistantSensor sensor = {
i == 0 ? "Price current hour" : name,
PriceSensor.topic,
path,
PriceSensor.ttl,
uom.c_str(),
PriceSensor.devcl,
i == 0 ? "total" : PriceSensor.stacl
};
publishSensor(sensor);
prInit[i] = true;
uint8_t currentPricePointIndex = ps->getCurrentPricePointIndex();
uint8_t numberOfPoints = ps->getNumberOfPointsAvailable();
if(priceImportInit < numberOfPoints-currentPricePointIndex) {
uint8_t importPriceSensorNo = 0;
for(int pricePointIndex = currentPricePointIndex; pricePointIndex < numberOfPoints; pricePointIndex++) {
float val = ps->getPricePoint(PRICE_DIRECTION_IMPORT, pricePointIndex);
if(val == PRICE_NO_VALUE) break;
if(importPriceSensorNo < priceImportInit) {
importPriceSensorNo++;
continue;
}
uint8_t resolution = ps->getResolutionInMinutes();
char path[64];
memset(path, 0, 64);
snprintf_P(path, 64, PSTR("prices.import[%d]"), importPriceSensorNo);
char uid[32];
memset(uid, 0, 32);
snprintf_P(uid, 32, PSTR("prices%d"), importPriceSensorNo);
char name[64];
if(resolution == 60)
snprintf_P(name, 64, PSTR("Import price in %02d hour%s"), importPriceSensorNo, importPriceSensorNo == 1 ? "" : "s");
else
snprintf_P(name, 64, PSTR("Import price in %03d minutes"), importPriceSensorNo * resolution);
HomeAssistantSensor sensor = {
importPriceSensorNo == 0 ? "Current import price" : name,
"/prices",
path,
resolution * 60 + 300,
uom.c_str(),
"monetary",
importPriceSensorNo == 0 ? "total" : "",
uid
};
publishSensor(sensor);
priceImportInit = importPriceSensorNo++;
}
}
float exportPrice = ps->getValueForHour(PRICE_DIRECTION_EXPORT, 0);
if(exportPrice != PRICE_NO_VALUE) {
char path[20];
snprintf(path, 20, "exportprices['%d']", 0);
HomeAssistantSensor sensor = {
"Export price current hour",
PriceSensor.topic,
path,
PriceSensor.ttl,
uom.c_str(),
PriceSensor.devcl,
"total"
};
publishSensor(sensor);
if(priceExportInit < numberOfPoints-currentPricePointIndex) {
uint8_t exportPriceSensorNo = 0;
for(int pricePointIndex = currentPricePointIndex; pricePointIndex < numberOfPoints; pricePointIndex++) {
float val = ps->getPricePoint(PRICE_DIRECTION_EXPORT, pricePointIndex);
if(val == PRICE_NO_VALUE) break;
if(exportPriceSensorNo < priceExportInit) {
exportPriceSensorNo++;
continue;
}
uint8_t resolution = ps->getResolutionInMinutes();
char path[64];
memset(path, 0, 64);
snprintf_P(path, 64, PSTR("prices.export[%d]"), exportPriceSensorNo);
char uid[32];
memset(uid, 0, 32);
snprintf_P(uid, 32, PSTR("exportprices%d"), exportPriceSensorNo);
char name[64];
if(resolution == 60)
snprintf_P(name, 64, PSTR("Export price in %02d hour%s"), exportPriceSensorNo, exportPriceSensorNo == 1 ? "" : "s");
else
snprintf_P(name, 64, PSTR("Export price in %03d minutes"), exportPriceSensorNo * resolution);
HomeAssistantSensor sensor = {
exportPriceSensorNo == 0 ? "Current export price" : name,
"/prices",
path,
resolution * 60 + 300,
uom.c_str(),
"monetary",
exportPriceSensorNo == 0 ? "total" : "",
uid
};
publishSensor(sensor);
priceExportInit = exportPriceSensorNo++;
}
}
}
void HomeAssistantMqttHandler::publishSystemSensors() {
if(sInit) return;
for(uint8_t i = 0; i < SystemSensorCount; i++) {
@ -739,7 +811,8 @@ void HomeAssistantMqttHandler::publishThresholdSensors() {
RealtimeThresholdSensor.ttl,
RealtimeThresholdSensor.uom,
RealtimeThresholdSensor.devcl,
RealtimeThresholdSensor.stacl
RealtimeThresholdSensor.stacl,
RealtimeThresholdSensor.uid
};
publishSensor(sensor);
}
@ -786,7 +859,8 @@ void HomeAssistantMqttHandler::onMessage(String &topic, String &payload) {
debugger->printf_P(PSTR("Received online status from HA, resetting sensor status\n"));
l1Init = l2Init = l2eInit = l3Init = l3eInit = l4Init = l4eInit = rtInit = rteInit = pInit = sInit = rInit = false;
for(uint8_t i = 0; i < 32; i++) tInit[i] = false;
for(uint8_t i = 0; i < 38; i++) prInit[i] = false;
priceImportInit = 0;
priceExportInit = 0;
}
} else if(topic.equals(subTopic)) {
if(payload.equals("fwupgrade")) {

View File

@ -34,6 +34,7 @@ public:
private:
HwTools* hw;
bool hasExport = false;
AmsDataStorage* ds;
uint16_t appendJsonHeader(AmsData* data);

View File

@ -56,6 +56,15 @@ bool JsonMqttHandler::publish(AmsData* update, AmsData* previousState, EnergyAcc
ret = publishList4(&data, ea);
mqtt.loop();
}
if(data.getListType() >= 2 && data.getActiveExportPower() > 0.0) {
hasExport = true;
}
if(data.getListType() >= 3 && data.getActiveExportCounter() > 0.0) {
hasExport = true;
}
loop();
return ret;
}
@ -299,7 +308,7 @@ bool JsonMqttHandler::publishTemperatures(AmsConfiguration* config, HwTools* hw)
bool JsonMqttHandler::publishPrices(PriceService* ps) {
if(strlen(mqttConfig.publishTopic) == 0 || !mqtt.connected())
return false;
if(ps->getValueForHour(PRICE_DIRECTION_IMPORT, 0) == PRICE_NO_VALUE)
if(!ps->hasPrice())
return false;
time_t now = time(nullptr);
@ -310,7 +319,7 @@ bool JsonMqttHandler::publishPrices(PriceService* ps) {
float values[38];
for(int i = 0;i < 38; i++) values[i] = PRICE_NO_VALUE;
for(uint8_t i = 0; i < 38; i++) {
float val = ps->getValueForHour(PRICE_DIRECTION_IMPORT, now, i);
float val = ps->getPriceForRelativeHour(PRICE_DIRECTION_IMPORT, i);
values[i] = val;
if(val == PRICE_NO_VALUE) break;
@ -379,38 +388,59 @@ bool JsonMqttHandler::publishPrices(PriceService* ps) {
sprintf_P(ts6hr, PSTR("%04d-%02d-%02dT%02d:00:00Z"), tm.Year+1970, tm.Month, tm.Day, tm.Hour);
}
char pf[4];
uint16_t pos = snprintf_P(json, BufferSize, PSTR("{\"id\":\"%s\","), WiFi.macAddress().c_str());
if(mqttConfig.payloadFormat != 6) {
memset(pf, 0, 4);
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"prices\":{"));
} else {
strcpy_P(pf, PSTR("pr_"));
}
if(mqttConfig.payloadFormat == 6) {
uint16_t pos = snprintf_P(json, BufferSize, PSTR("{\"id\":\"%s\","), WiFi.macAddress().c_str());
for(uint8_t i = 0;i < 38; i++) {
if(values[i] == PRICE_NO_VALUE) {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"%s%d\":null,"), pf, i);
} else {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"%s%d\":%.4f,"), pf, i, values[i]);
for(uint8_t i = 0;i < 38; i++) {
if(values[i] == PRICE_NO_VALUE) {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"pr_%d\":null,"), i);
} else {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"pr_%d\":%.4f,"), i, values[i]);
}
}
}
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"%smin\":%.4f,\"%smax\":%.4f,\"%scheapest1hr\":\"%s\",\"%scheapest3hr\":\"%s\",\"%scheapest6hr\":\"%s\"}"),
pf,
min == INT16_MAX ? 0.0 : min,
pf,
max == INT16_MIN ? 0.0 : max,
pf,
ts1hr,
pf,
ts3hr,
pf,
ts6hr
);
if(mqttConfig.payloadFormat != 6) {
json[pos++] = '}';
json[pos] = '\0';
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("\"pr_min\":%.4f,\"pr_max\":%.4f,\"pr_cheapest1hr\":\"%s\",\"pr_cheapest3hr\":\"%s\",\"pr_cheapest6hr\":\"%s\"}"),
min == INT16_MAX ? 0.0 : min,
max == INT16_MIN ? 0.0 : max,
ts1hr,
ts3hr,
ts6hr
);
} else {
uint16_t pos = snprintf_P(json, BufferSize, PSTR("{\"id\":\"%s\",\"prices\":{\"import\":["), WiFi.macAddress().c_str());
uint8_t currentPricePointIndex = ps->getCurrentPricePointIndex();
uint8_t numberOfPoints = ps->getNumberOfPointsAvailable();
for(int i = currentPricePointIndex; i < numberOfPoints; i++) {
float val = ps->getPricePoint(PRICE_DIRECTION_IMPORT, i);
if(val == PRICE_NO_VALUE) {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("null,"));
} else {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("%.4f,"), val);
}
}
if(hasExport && ps->isExportPricesDifferentFromImport()) {
pos--;
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("],\"export\":["));
for(int i = currentPricePointIndex; i < numberOfPoints; i++) {
float val = ps->getPricePoint(PRICE_DIRECTION_EXPORT, i);
if(val == PRICE_NO_VALUE) {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("null,"));
} else {
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("%.4f,"), val);
}
}
}
pos--;
pos += snprintf_P(json+pos, BufferSize-pos, PSTR("],\"min\":%.4f,\"max\":%.4f,\"cheapest1hr\":\"%s\",\"cheapest3hr\":\"%s\",\"cheapest6hr\":\"%s\"}}"),
min == INT16_MAX ? 0.0 : min,
max == INT16_MIN ? 0.0 : max,
ts1hr,
ts3hr,
ts6hr
);
}
bool ret = false;
if(mqttConfig.payloadFormat == 5) {

View File

@ -25,9 +25,9 @@ struct AmsOctetTimestamp {
class IEC6205675 : public AmsData {
public:
#if defined(AMS_REMOTE_DEBUG)
IEC6205675(const char* payload, uint8_t useMeterType, MeterConfig* meterConfig, DataParserContext &ctx, AmsData &state, RemoteDebug* debugger);
IEC6205675(const char* payload, Timezone* tz, uint8_t useMeterType, MeterConfig* meterConfig, DataParserContext &ctx, AmsData &state, RemoteDebug* debugger);
#else
IEC6205675(const char* payload, uint8_t useMeterType, MeterConfig* meterConfig, DataParserContext &ctx, AmsData &state, Stream* debugger);
IEC6205675(const char* payload, Timezone* tz, uint8_t useMeterType, MeterConfig* meterConfig, DataParserContext &ctx, AmsData &state, Stream* debugger);
#endif
private:
@ -37,8 +37,9 @@ private:
float getNumber(uint8_t* obis, int matchlength, const char* ptr);
float getNumber(CosemData*);
time_t getTimestamp(uint8_t* obis, int matchlength, const char* ptr);
time_t adjustForKnownIssues(CosemDateTime dt, Timezone* tz, uint8_t meterType);
uint8_t AMS_OBIS_UNKNOWN_1[4] = { 25, 9, 0, 255 };
uint8_t AMS_OBIS_UNKNOWN_1[4] = { 25, 9, 0, 255 };
uint8_t AMS_OBIS_VERSION[4] = { 0, 2, 129, 255 };
uint8_t AMS_OBIS_METER_MODEL[4] = { 96, 1, 1, 255 };

View File

@ -12,18 +12,14 @@
#include "hexutils.h"
#if defined(AMS_REMOTE_DEBUG)
IEC6205675::IEC6205675(const char* d, uint8_t useMeterType, MeterConfig* meterConfig, DataParserContext &ctx, AmsData &state, RemoteDebug* debugger) {
IEC6205675::IEC6205675(const char* d, Timezone* tz, uint8_t useMeterType, MeterConfig* meterConfig, DataParserContext &ctx, AmsData &state, RemoteDebug* debugger) {
#else
IEC6205675::IEC6205675(const char* payload, uint8_t useMeterType, MeterConfig* meterConfig, DataParserContext &ctx, AmsData &state, Stream* debugger) {
IEC6205675::IEC6205675(const char* d, Timezone* tz, uint8_t useMeterType, MeterConfig* meterConfig, DataParserContext &ctx, AmsData &state, Stream* debugger) {
#endif
float val;
char str[64];
TimeChangeRule CEST = {"CEST", Last, Sun, Mar, 2, 120};
TimeChangeRule CET = {"CET ", Last, Sun, Oct, 3, 60};
Timezone tz(CEST, CET);
this->packageTimestamp = ctx.timestamp == 0 ? time(nullptr) : ctx.timestamp;
this->packageTimestamp = time(nullptr); // ctx.timestamp is mostly garbage, so we use current time as package timestamp
val = getNumber(AMS_OBIS_ACTIVE_IMPORT, sizeof(AMS_OBIS_ACTIVE_IMPORT), ((char *) (d)));
if(val == NOVALUE) {
@ -31,13 +27,13 @@ IEC6205675::IEC6205675(const char* payload, uint8_t useMeterType, MeterConfig* m
// Kaifa special case...
if(useMeterType == AmsTypeKaifa && data->base.type == CosemTypeDLongUnsigned) {
this->packageTimestamp = this->packageTimestamp > 0 ? tz.toUTC(this->packageTimestamp) : 0;
this->packageTimestamp = this->packageTimestamp > 0 ? tz->toUTC(this->packageTimestamp) : 0;
listType = 1;
meterType = AmsTypeKaifa;
activeImportPower = ntohl(data->dlu.data);
lastUpdateMillis = millis64();
} else if(data->base.type == CosemTypeOctetString) {
this->packageTimestamp = this->packageTimestamp > 0 ? tz.toUTC(this->packageTimestamp) : 0;
this->packageTimestamp = this->packageTimestamp > 0 ? tz->toUTC(this->packageTimestamp) : 0;
memcpy(str, data->oct.data, data->oct.length);
str[data->oct.length] = 0x00;
@ -127,7 +123,7 @@ IEC6205675::IEC6205675(const char* payload, uint8_t useMeterType, MeterConfig* m
if(data->oct.length == 0x0C) {
AmsOctetTimestamp* amst = (AmsOctetTimestamp*) data;
time_t ts = decodeCosemDateTime(amst->dt);
meterTimestamp = tz.toUTC(ts);
meterTimestamp = tz->toUTC(ts);
}
}
}
@ -738,18 +734,7 @@ IEC6205675::IEC6205675(const char* payload, uint8_t useMeterType, MeterConfig* m
CosemData* meterTs = findObis(AMS_OBIS_METER_TIMESTAMP, sizeof(AMS_OBIS_METER_TIMESTAMP), ((char *) (d)));
if(meterTs != NULL) {
AmsOctetTimestamp* amst = (AmsOctetTimestamp*) meterTs;
time_t ts = decodeCosemDateTime(amst->dt);
int16_t deviation = ntohs(amst->dt.deviation);
if(deviation < -720 || deviation > 720) { // Deviation not specified, adjust from localtime to UTC
meterTimestamp = tz.toUTC(ts);
if(ctx.timestamp > 0) {
this->packageTimestamp = tz.toUTC(ctx.timestamp);
}
} else if(meterType == AmsTypeAidon) {
meterTimestamp = ts - 3600; // 21.09.24, the clock is now correct
} else {
meterTimestamp = ts;
}
this->meterTimestamp = adjustForKnownIssues(amst->dt, tz, meterType == AmsTypeUnknown ? useMeterType : meterType);
}
val = getNumber(AMS_OBIS_POWER_FACTOR, sizeof(AMS_OBIS_POWER_FACTOR), ((char *) (d)));
@ -1125,3 +1110,24 @@ time_t IEC6205675::getTimestamp(uint8_t* obis, int matchlength, const char* ptr)
}
return 0;
}
time_t IEC6205675::adjustForKnownIssues(CosemDateTime dt, Timezone* tz, uint8_t meterType) {
time_t ts = decodeCosemDateTime(dt);
int16_t deviation = ntohs(dt.deviation);
if(deviation < -720 || deviation > 720) {
// Time zone not specified
if(meterType == AmsTypeAidon || meterType == AmsTypeKamstrup) {
// Special known case
// 21.09.24, the clock is now correct for Aidon
// 23.10.25, the clock is now correct for Kamstrup
ts -= 3600;
} else {
// Adjust from localtime to UTC
ts = tz->toUTC(ts);
}
} else if(meterType == AmsTypeAidon) {
// 21.09.24, the clock is now correct for Aidon
ts -= 3600;
}
return ts;
}

View File

@ -278,7 +278,7 @@ AmsData* PassiveMeterCommunicator::getData(AmsData& meterState) {
#endif
debugger->printf_P(PSTR("DLMS\n"));
// TODO: Split IEC6205675 into DataParserKaifa and DataParserObis. This way we can add other means of parsing, for those other proprietary formats
data = new IEC6205675(payload, meterState.getMeterType(), &meterConfig, ctx, meterState, debugger);
data = new IEC6205675(payload, tz, meterState.getMeterType(), &meterConfig, ctx, meterState, debugger);
}
} else if(ctx.type == DATA_TAG_DSMR) {
data = new IEC6205621(payload, tz, &meterConfig);

View File

@ -15,28 +15,23 @@
#define DOCPOS_MEASUREMENTUNIT 2
#define DOCPOS_POSITION 3
#define DOCPOS_AMOUNT 4
#define DOCPOS_RESOLUTION 5
class EntsoeA44Parser: public Stream {
public:
EntsoeA44Parser();
EntsoeA44Parser(PricesContainer *container);
virtual ~EntsoeA44Parser();
char* getCurrency();
char* getMeasurementUnit();
float getPoint(uint8_t position);
int available();
int read();
int peek();
void flush();
size_t write(const uint8_t *buffer, size_t size);
size_t write(uint8_t);
void get(PricesContainer*);
private:
char currency[4];
char measurementUnit[4];
float points[25];
PricesContainer *container;
float multiplier = 1.0;
char buf[64];
uint8_t pos = 0;

View File

@ -27,10 +27,6 @@
#define SSL_BUF_SIZE 512
#define PRICE_DIRECTION_IMPORT 0x01
#define PRICE_DIRECTION_EXPORT 0x02
#define PRICE_DIRECTION_BOTH 0x03
#define PRICE_DAY_MO 0x01
#define PRICE_DAY_TU 0x02
#define PRICE_DAY_WE 0x04
@ -57,10 +53,13 @@ struct PriceConfig {
uint8_t end_dayofmonth;
};
struct PricePart {
char name[32];
char description[32];
uint32_t value;
struct AmsPriceV2Header {
char currency[4];
char measurementUnit[4];
char source[4];
uint8_t resolutionInMinutes;
bool differentExportPrices;
uint8_t numberOfPoints;
};
class PriceService {
@ -78,17 +77,25 @@ public:
char* getCurrency();
char* getArea();
char* getSource();
float getValueForHour(uint8_t direction, int8_t hour);
float getValueForHour(uint8_t direction, time_t ts, int8_t hour);
float getEnergyPriceForHour(uint8_t direction, time_t ts, int8_t hour);
uint8_t getResolutionInMinutes();
uint8_t getNumberOfPointsAvailable();
uint8_t getCurrentPricePointIndex();
bool isExportPricesDifferentFromImport();
bool hasPrice() { return hasPrice(PRICE_DIRECTION_IMPORT); }
bool hasPrice(uint8_t direction) { return getCurrentPrice(direction) != PRICE_NO_VALUE; }
bool hasPricePoint(uint8_t direction, int8_t point) { return getPricePoint(direction, point) != PRICE_NO_VALUE; }
float getCurrentPrice(uint8_t direction);
float getPricePoint(uint8_t direction, uint8_t point);
float getPriceForRelativeHour(uint8_t direction, int8_t hour); // If not 60min interval, average
std::vector<PriceConfig>& getPriceConfig();
void setPriceConfig(uint8_t index, PriceConfig &priceConfig);
void cropPriceConfig(uint8_t size);
PricePart getPricePart(uint8_t index);
int16_t getLastError();
bool load();
@ -103,7 +110,7 @@ private:
PriceServiceConfig* config = NULL;
HTTPClient* http = NULL;
uint8_t currentDay = 0, currentHour = 0;
uint8_t currentDay = 0, currentPricePoint = 0;
uint8_t tomorrowFetchMinute = 15; // How many minutes over 13:00 should it fetch prices
uint8_t nextFetchDelayMinutes = 15;
uint64_t lastTodayFetch = 0;
@ -132,5 +139,7 @@ private:
bool retrieve(const char* url, Stream* doc);
float getCurrencyMultiplier(const char* from, const char* to, time_t t);
bool timeIsInPeriod(tmElements_t tm, PriceConfig pc);
float getFixedPrice(uint8_t direction, int8_t hour);
float getEnergyPricePoint(uint8_t direction, uint8_t point);
};
#endif

View File

@ -4,15 +4,43 @@
*
*/
#include <stdint.h>
#ifndef _PRICESCONTAINER_H
#define _PRICESCONTAINER_H
#define PRICE_NO_VALUE -127
#define PRICE_DIRECTION_IMPORT 0x01
#define PRICE_DIRECTION_EXPORT 0x02
#define PRICE_DIRECTION_BOTH 0x03
struct PricesContainer {
char currency[4];
char measurementUnit[4];
int32_t points[25];
class PricesContainer {
public:
PricesContainer(char* source);
void setup(uint8_t resolutionInMinutes, uint8_t numberOfPoints, bool differentExportPrices);
char* getSource();
void setCurrency(char* currency);
char* getCurrency();
bool isExportPricesDifferentFromImport() {
return differentExportPrices;
}
uint8_t getResolutionInMinutes();
uint8_t getNumberOfPoints();
void setPrice(uint8_t point, float value, uint8_t direction);
bool hasPrice(uint8_t point, uint8_t direction);
float getPrice(uint8_t point, uint8_t direction); // int32_t / 10_000
private:
char source[4];
char currency[4];
uint8_t resolutionInMinutes;
bool differentExportPrices;
uint8_t numberOfPoints;
int32_t *points;
};
#endif

View File

@ -7,27 +7,14 @@
#include "EntsoeA44Parser.h"
#include "HardwareSerial.h"
EntsoeA44Parser::EntsoeA44Parser() {
for(int i = 0; i < 25; i++) points[i] = PRICE_NO_VALUE;
EntsoeA44Parser::EntsoeA44Parser(PricesContainer *container) {
this->container = container;
}
EntsoeA44Parser::~EntsoeA44Parser() {
}
char* EntsoeA44Parser::getCurrency() {
return currency;
}
char* EntsoeA44Parser::getMeasurementUnit() {
return measurementUnit;
}
float EntsoeA44Parser::getPoint(uint8_t position) {
if(position >= 25) return PRICE_NO_VALUE;
return points[position];
}
int EntsoeA44Parser::available() {
return 0;
}
@ -57,7 +44,7 @@ size_t EntsoeA44Parser::write(uint8_t byte) {
buf[pos++] = byte;
if(pos == 3) {
buf[pos++] = '\0';
memcpy(currency, buf, pos);
container->setCurrency(buf);
docPos = DOCPOS_SEEK;
pos = 0;
}
@ -65,7 +52,7 @@ size_t EntsoeA44Parser::write(uint8_t byte) {
buf[pos++] = byte;
if(pos == 3) {
buf[pos++] = '\0';
memcpy(measurementUnit, buf, pos);
if(strcmp_P(buf, PSTR("MWH"))) multiplier = 0.001;
docPos = DOCPOS_SEEK;
pos = 0;
}
@ -73,7 +60,7 @@ size_t EntsoeA44Parser::write(uint8_t byte) {
if(byte == '<') {
buf[pos] = '\0';
long pn = String(buf).toInt() - 1;
if(pn < 25) {
if(pn < container->getNumberOfPoints()) {
pointNum = pn;
}
docPos = DOCPOS_SEEK;
@ -85,8 +72,25 @@ size_t EntsoeA44Parser::write(uint8_t byte) {
if(byte == '<') {
buf[pos] = '\0';
float val = String(buf).toFloat();
for(uint8_t i = pointNum; i < 25; i++) {
points[i] = val;
for(uint8_t i = pointNum; i < container->getNumberOfPoints(); i++) {
container->setPrice(i, val * multiplier, PRICE_DIRECTION_IMPORT);
}
docPos = DOCPOS_SEEK;
pos = 0;
} else {
buf[pos++] = byte;
}
} else if(docPos == DOCPOS_RESOLUTION) {
if(byte == '<') {
buf[pos] = '\0';
// This happens if there are two time series in the XML. We are only interrested in the first one, so we ignore the rest of the document
if(container->hasPrice(0, PRICE_DIRECTION_IMPORT)) return 1;
if(strcmp_P(buf, PSTR("PT15M"))) {
container->setup(15, 100, false);
} else if(strcmp_P(buf, PSTR("PT60M"))) {
container->setup(60, 25, false);
}
docPos = DOCPOS_SEEK;
pos = 0;
@ -101,15 +105,17 @@ size_t EntsoeA44Parser::write(uint8_t byte) {
} else if(byte == '>') {
buf[pos++] = byte;
buf[pos] = '\0';
if(strcmp(buf, "<currency_Unit.name>") == 0) {
if(strcmp_P(buf, PSTR("<currency_Unit.name>")) == 0) {
docPos = DOCPOS_CURRENCY;
} else if(strcmp(buf, "<price_Measure_Unit.name>") == 0) {
} else if(strcmp(buf, PSTR("<price_Measure_Unit.name>")) == 0) {
docPos = DOCPOS_MEASUREMENTUNIT;
} else if(strcmp(buf, "<position>") == 0) {
} else if(strcmp(buf, PSTR("<position>")) == 0) {
docPos = DOCPOS_POSITION;
pointNum = 0xFF;
} else if(strcmp(buf, "<price.amount>") == 0) {
} else if(strcmp(buf, PSTR("<price.amount>")) == 0) {
docPos = DOCPOS_AMOUNT;
} else if(strcmp(buf, PSTR("<resolution>")) == 0) {
docPos = DOCPOS_RESOLUTION;
}
pos = 0;
} else {
@ -118,15 +124,3 @@ size_t EntsoeA44Parser::write(uint8_t byte) {
}
return 1;
}
void EntsoeA44Parser::get(PricesContainer* container) {
memset(container, 0, sizeof(*container));
strcpy(container->currency, currency);
strcpy(container->measurementUnit, measurementUnit);
strcpy(container->source, "EOE");
for(uint8_t i = 0; i < 25; i++) {
container->points[i] = points[i] == PRICE_NO_VALUE ? PRICE_NO_VALUE : points[i] * 10000;
}
}

View File

@ -43,6 +43,10 @@ void PriceService::setup(PriceServiceConfig& config) {
this->config = new PriceServiceConfig();
}
memcpy(this->config, &config, sizeof(config));
if(this->config->resolutionInMinutes != 15 && this->config->resolutionInMinutes != 60) {
this->config->resolutionInMinutes = 60;
}
lastTodayFetch = lastTomorrowFetch = lastCurrencyFetch = 0;
if(today != NULL) delete today;
if(tomorrow != NULL) delete tomorrow;
@ -91,55 +95,156 @@ char* PriceService::getArea() {
char* PriceService::getSource() {
if(this->today != NULL && this->tomorrow != NULL) {
if(strcmp(this->today->source, this->tomorrow->source) == 0) {
return this->today->source;
if(strcmp(this->today->getSource(), this->tomorrow->getSource()) == 0) {
return this->today->getSource();
} else {
return "MIX";
}
} else if(today != NULL) {
return this->today->source;
return this->today->getSource();
} else if(tomorrow != NULL) {
return this->tomorrow->source;
return this->tomorrow->getSource();
}
return "";
}
float PriceService::getValueForHour(uint8_t direction, int8_t hour) {
time_t cur = time(nullptr);
return getValueForHour(direction, cur, hour);
uint8_t PriceService::getResolutionInMinutes() {
return today != NULL ? today->getResolutionInMinutes() : 60;
}
float PriceService::getValueForHour(uint8_t direction, time_t ts, int8_t hour) {
float ret = getEnergyPriceForHour(direction, ts, hour);
if(ret == PRICE_NO_VALUE)
return ret;
uint8_t PriceService::getNumberOfPointsAvailable() {
if(today == NULL) return getResolutionInMinutes() == 15 ? 192 : 48;
if(tomorrow != NULL) return today->getNumberOfPoints() + tomorrow->getNumberOfPoints();
return today->getNumberOfPoints();
}
bool PriceService::isExportPricesDifferentFromImport() {
for (uint8_t i = 0; i < priceConfig.size(); i++) {
PriceConfig pc = priceConfig.at(i);
if(pc.direction != PRICE_DIRECTION_BOTH) {
return true;
}
}
return today != NULL && today->isExportPricesDifferentFromImport();
}
float PriceService::getPricePoint(uint8_t direction, uint8_t point) {
float value = getFixedPrice(direction, point * getResolutionInMinutes() / 60);
if(value == PRICE_NO_VALUE) value = getEnergyPricePoint(direction, point);
if(value == PRICE_NO_VALUE) return PRICE_NO_VALUE;
tmElements_t tm;
breakTime(tz->toLocal(ts + (hour * SECS_PER_HOUR)), tm);
time_t ts = time(nullptr);
breakTime(tz->toLocal(ts), tm);
tm.Hour = tm.Minute = tm.Second = 0;
breakTime(makeTime(tm) + (point * SECS_PER_MIN * getResolutionInMinutes()), tm);
for (uint8_t i = 0; i < priceConfig.size(); i++) {
PriceConfig pc = priceConfig.at(i);
if(pc.type == PRICE_TYPE_FIXED) continue;
if((pc.direction & direction) != direction) continue;
if(!timeIsInPeriod(tm, pc)) continue;
float pcVal = pc.value / 10000.0;
switch(pc.type) {
case PRICE_TYPE_ADD:
ret += pc.value / 10000.0;
value += pcVal;
break;
case PRICE_TYPE_SUBTRACT:
ret -= pc.value / 10000.0;
value -= pcVal;
break;
case PRICE_TYPE_PCT:
ret += ((pc.value / 10000.0) * ret) / 100.0;
value += (pcVal * value) / 100.0;
break;
}
}
return ret;
return value;
}
float PriceService::getEnergyPriceForHour(uint8_t direction, time_t ts, int8_t hour) {
float PriceService::getCurrentPrice(uint8_t direction) {
time_t ts = time(nullptr);
tmElements_t tm;
breakTime(tz->toLocal(ts + (hour * SECS_PER_HOUR)), tm);
breakTime(tz->toLocal(ts), tm);
uint8_t pos = getCurrentPricePointIndex();
return getPricePoint(direction, pos);
}
float PriceService::getEnergyPricePoint(uint8_t direction, uint8_t point) {
uint8_t pos = point;
float multiplier = 1.0;
uint8_t numberOfPointsToday = 24;
if(today != NULL) {
numberOfPointsToday = today->getNumberOfPoints();
}
float value = PRICE_NO_VALUE;
if(pos >= numberOfPointsToday) {
pos = pos - numberOfPointsToday;
if(tomorrow == NULL)
return PRICE_NO_VALUE;
if(pos >= tomorrow->getNumberOfPoints()) return PRICE_NO_VALUE;
if(!tomorrow->hasPrice(pos, direction))
return PRICE_NO_VALUE;
value = tomorrow->getPrice(pos, direction);
float mult = getCurrencyMultiplier(tomorrow->getCurrency(), config->currency, time(nullptr));
if(mult == 0) return PRICE_NO_VALUE;
multiplier *= mult;
} else if(pos >= 0) {
if(today == NULL)
return PRICE_NO_VALUE;
if(!today->hasPrice(pos, direction))
return PRICE_NO_VALUE;
value = today->getPrice(pos, direction);
float mult = getCurrencyMultiplier(today->getCurrency(), config->currency, time(nullptr));
if(mult == 0) return PRICE_NO_VALUE;
multiplier *= mult;
}
return value == PRICE_NO_VALUE ? PRICE_NO_VALUE : value * multiplier;
}
float PriceService::getPriceForRelativeHour(uint8_t direction, int8_t hour) {
time_t ts = time(nullptr);
tmElements_t tm;
breakTime(tz->toLocal(ts), tm);
int8_t targetHour = tm.Hour + hour;
tm.Hour = tm.Minute = tm.Second = 0;
time_t startOfDay = tz->toUTC(makeTime(tm));
if((ts + (hour * SECS_PER_HOUR)) < startOfDay) {
return PRICE_NO_VALUE;
}
if(getResolutionInMinutes() == 60) {
return getPricePoint(direction, targetHour);
}
float valueSum = 0.0f;
uint8_t valueCount = 0;
float indexIncrements = 60.0 / today->getResolutionInMinutes();
uint8_t priceMapIndexStart = (uint8_t) floor(indexIncrements * targetHour);
uint8_t priceMapIndexEnd = (uint8_t) ceil(indexIncrements * (targetHour+1));
for(uint8_t mi = priceMapIndexStart; mi < priceMapIndexEnd; mi++) {
float val = getPricePoint(direction, mi);
if(val == PRICE_NO_VALUE) continue;
valueSum += val;
valueCount++;
}
if(valueCount == 0) return PRICE_NO_VALUE;
return valueSum / valueCount;
}
float PriceService::getFixedPrice(uint8_t direction, int8_t hour) {
time_t ts = time(nullptr);
tmElements_t tm;
breakTime(tz->toLocal(ts), tm);
tm.Minute = 0;
tm.Second = 0;
breakTime(makeTime(tm) + (hour * SECS_PER_HOUR), tm);
float value = PRICE_NO_VALUE;
for (uint8_t i = 0; i < priceConfig.size(); i++) {
@ -154,68 +259,7 @@ float PriceService::getEnergyPriceForHour(uint8_t direction, time_t ts, int8_t h
value += pc.value / 10000.0;
}
}
if(value != PRICE_NO_VALUE) return value;
int8_t pos = hour;
breakTime(entsoeTz->toLocal(ts), tm);
while(tm.Hour > 0) {
ts -= 3600;
breakTime(entsoeTz->toLocal(ts), tm);
pos++;
}
uint8_t hoursToday = 0;
uint8_t todayDate = tm.Day;
while(tm.Day == todayDate) {
ts += 3600;
breakTime(entsoeTz->toLocal(ts), tm);
hoursToday++;
}
uint8_t hoursTomorrow = 0;
uint8_t tomorrowDate = tm.Day;
while(tm.Day == tomorrowDate) {
ts += 3600;
breakTime(entsoeTz->toLocal(ts), tm);
hoursTomorrow++;
}
float multiplier = 1.0;
if(pos >= hoursToday) {
pos = pos - hoursToday;
if(pos >= hoursTomorrow) return PRICE_NO_VALUE;
if(tomorrow == NULL)
return PRICE_NO_VALUE;
if(tomorrow->points[pos] == PRICE_NO_VALUE)
return PRICE_NO_VALUE;
value = tomorrow->points[pos] / 10000.0;
if(strcmp(tomorrow->measurementUnit, "KWH") == 0) {
// Multiplier is 1
} else if(strcmp(tomorrow->measurementUnit, "MWH") == 0) {
multiplier *= 0.001;
} else {
return PRICE_NO_VALUE;
}
float mult = getCurrencyMultiplier(tomorrow->currency, config->currency, time(nullptr));
if(mult == 0) return PRICE_NO_VALUE;
multiplier *= mult;
} else if(pos >= 0) {
if(today == NULL)
return PRICE_NO_VALUE;
if(today->points[pos] == PRICE_NO_VALUE)
return PRICE_NO_VALUE;
value = today->points[pos] / 10000.0;
if(strcmp(today->measurementUnit, "KWH") == 0) {
// Multiplier is 1
} else if(strcmp(today->measurementUnit, "MWH") == 0) {
multiplier *= 0.001;
} else {
return PRICE_NO_VALUE;
}
float mult = getCurrencyMultiplier(today->currency, config->currency, time(nullptr));
if(mult == 0) return PRICE_NO_VALUE;
multiplier *= mult;
}
return value == PRICE_NO_VALUE ? PRICE_NO_VALUE : value * multiplier;
return value;
}
bool PriceService::loop() {
@ -223,43 +267,59 @@ bool PriceService::loop() {
if(now < 10000) return false; // Grace period
time_t t = time(nullptr);
if(t < FirmwareVersion::BuildEpoch) return false;
#ifndef AMS2MQTT_PRICE_KEY
if(strlen(getToken()) == 0) {
if(t < FirmwareVersion::BuildEpoch) {
return false;
}
#endif
if(strlen(config->area) == 0)
return false;
if(strlen(config->currency) == 0)
return false;
tmElements_t tm;
breakTime(entsoeTz->toLocal(t), tm);
if(currentDay == 0) {
#if defined(AMS_REMOTE_DEBUG)
if (debugger->isActive(RemoteDebug::INFO))
#endif
debugger->printf_P(PSTR("(PriceService) Day init\n"));
currentDay = tm.Day;
currentHour = tm.Hour;
currentPricePoint = getCurrentPricePointIndex();
}
if(currentDay != tm.Day) {
#if defined(AMS_REMOTE_DEBUG)
if (debugger->isActive(RemoteDebug::INFO))
#endif
debugger->printf_P(PSTR("(PriceService) Day reset\n"));
if(today != NULL) delete today;
if(tomorrow != NULL) {
today = tomorrow;
tomorrow = NULL;
}
currentDay = tm.Day;
currentHour = tm.Hour;
currentPricePoint = getCurrentPricePointIndex();
return today != NULL || (!config->enabled && priceConfig.capacity() != 0); // Only trigger MQTT publish if we have todays prices.
} else if(currentHour != tm.Hour) {
currentHour = tm.Hour;
} else if(currentPricePoint != getCurrentPricePointIndex()) {
#if defined(AMS_REMOTE_DEBUG)
if (debugger->isActive(RemoteDebug::INFO))
#endif
debugger->printf_P(PSTR("(PriceService) Price point reset\n"));
currentPricePoint = getCurrentPricePointIndex();
return today != NULL || (!config->enabled && priceConfig.capacity() != 0); // Only trigger MQTT publish if we have todays prices.
}
if(!config->enabled)
return false;
#ifndef AMS2MQTT_PRICE_KEY
if(strlen(getToken()) == 0) {
return false;
}
#endif
if(strlen(config->area) == 0){
return false;
}
if(strlen(config->currency) == 0) {
return false;
}
bool readyToFetchForTomorrow = tomorrow == NULL && (tm.Hour > 13 || (tm.Hour == 13 && tm.Minute >= tomorrowFetchMinute)) && (lastTomorrowFetch == 0 || now - lastTomorrowFetch > (nextFetchDelayMinutes*60000));
if(today == NULL && (lastTodayFetch == 0 || now - lastTodayFetch > (nextFetchDelayMinutes*60000))) {
@ -273,6 +333,7 @@ bool PriceService::loop() {
}
today = NULL;
}
currentPricePoint = getCurrentPricePointIndex();
return today != NULL && !readyToFetchForTomorrow; // Only trigger MQTT publish if we have todays prices and we are not immediately ready to fetch price for tomorrow.
}
@ -289,6 +350,7 @@ bool PriceService::loop() {
}
tomorrow = NULL;
}
currentPricePoint = getCurrentPricePointIndex();
return tomorrow != NULL;
}
@ -434,24 +496,30 @@ PricesContainer* PriceService::fetchPrices(time_t t) {
if (debugger->isActive(RemoteDebug::DEBUG))
#endif
debugger->printf_P(PSTR("(PriceService) url: %s\n"), buf);
EntsoeA44Parser a44;
if(retrieve(buf, &a44) && a44.getPoint(0) != PRICE_NO_VALUE) {
PricesContainer* ret = new PricesContainer();
a44.get(ret);
PricesContainer* ret = new PricesContainer("EOE");
EntsoeA44Parser a44(ret);
if(retrieve(buf, &a44) && ret->hasPrice(0, PRICE_DIRECTION_IMPORT)) {
return ret;
} else {
delete ret;
return NULL;
}
} else if(hub) {
#if defined(AMS_REMOTE_DEBUG)
if (debugger->isActive(RemoteDebug::DEBUG))
#endif
debugger->printf_P(PSTR("(PriceService) Going to fetch prices from hub\n"));
tmElements_t tm;
breakTime(entsoeTz->toLocal(t), tm);
String data;
snprintf_P(buf, BufferSize, PSTR("http://hub.amsleser.no/hub/price/%s/%d/%d/%d?currency=%s"),
snprintf_P(buf, BufferSize, PSTR("http://hub.amsleser.no/hub/price/%s/%d/%d/%d/pt%dm?currency=%s"),
config->area,
tm.Year+1970,
tm.Month,
tm.Day,
config->resolutionInMinutes,
config->currency
);
#if defined(AMS_REMOTE_DEBUG)
@ -488,13 +556,37 @@ PricesContainer* PriceService::fetchPrices(time_t t) {
GCMParser gcm(key, auth);
int8_t gcmRet = gcm.parse(content, ctx);
if(gcmRet > 0) {
PricesContainer* ret = new PricesContainer();
for(uint8_t i = 0; i < 25; i++) {
ret->points[i] = PRICE_NO_VALUE;
AmsPriceV2Header* header = (AmsPriceV2Header*) (content+gcmRet);
PricesContainer* ret = new PricesContainer(header->source);
#if defined(AMS_REMOTE_DEBUG)
if (debugger->isActive(RemoteDebug::DEBUG))
#endif
debugger->printf_P(PSTR("(PriceService) Setting up price container with pt%dm, %dpts, edi: %d\n"), header->resolutionInMinutes, header->numberOfPoints, header->differentExportPrices);
ret->setup(header->resolutionInMinutes, header->numberOfPoints, header->differentExportPrices);
ret->setCurrency(header->currency);
int32_t* points = (int32_t*) &header[1];
for(uint8_t i = 0; i < header->numberOfPoints; i++) {
int32_t intval = ntohl(points[i]);
float value = intval / 10000.0;
#if defined(AMS_REMOTE_DEBUG)
if (debugger->isActive(RemoteDebug::VERBOSE))
#endif
debugger->printf_P(PSTR("(PriceService) Import price position and value received: %d :: %.2f\n"), i, value);
ret->setPrice(i, value, PRICE_DIRECTION_IMPORT);
}
memcpy(ret, content+gcmRet, sizeof(*ret));
for(uint8_t i = 0; i < 25; i++) {
ret->points[i] = ntohl(ret->points[i]);
if(header->differentExportPrices) {
for(uint8_t i = 0; i < header->numberOfPoints; i++) {
int32_t intval = ntohl(points[i]);
float value = intval / 10000.0;
#if defined(AMS_REMOTE_DEBUG)
if (debugger->isActive(RemoteDebug::VERBOSE))
#endif
debugger->printf_P(PSTR("(PriceService) Export price position and value received: %d :: %.2f\n"), i, value);
ret->setPrice(i, value, PRICE_DIRECTION_EXPORT);
}
}
lastError = 0;
nextFetchDelayMinutes = 1;
@ -658,4 +750,11 @@ bool PriceService::timeIsInPeriod(tmElements_t tm, PriceConfig pc) {
}
return makeTime(tms) <= makeTime(tm) && makeTime(tme) >= makeTime(tm);
}
uint8_t PriceService::getCurrentPricePointIndex() {
time_t ts = time(nullptr);
tmElements_t tm;
breakTime(tz->toLocal(ts), tm);
return ((tm.Hour * 60) + tm.Minute) / getResolutionInMinutes();
}

View File

@ -0,0 +1,67 @@
#include "PricesContainer.h"
#include <cstring>
PricesContainer::PricesContainer(char* source) {
strncpy(this->source, source, 4);
}
void PricesContainer::setup(uint8_t resolutionInMinutes, uint8_t numberOfPoints, bool differentExportPrices) {
this->resolutionInMinutes = resolutionInMinutes;
this->differentExportPrices = differentExportPrices;
this->numberOfPoints = numberOfPoints;
this->points = new int32_t[numberOfPoints * (differentExportPrices ? 2 : 1)];
memset(this->points, PRICE_NO_VALUE * 10000, numberOfPoints * (differentExportPrices ? 2 : 1) * sizeof(int32_t));
}
char* PricesContainer::getSource() {
return this->source;
}
void PricesContainer::setCurrency(char* currency) {
strncpy(this->currency, currency, 4);
}
char* PricesContainer::getCurrency() {
return this->currency;
}
uint8_t PricesContainer::getResolutionInMinutes() {
return this->resolutionInMinutes;
}
uint8_t PricesContainer::getNumberOfPoints() {
return this->numberOfPoints;
}
void PricesContainer::setPrice(uint8_t point, float value, uint8_t direction) {
if(direction == PRICE_DIRECTION_EXPORT && !differentExportPrices) {
return; // Export prices not supported
}
if(direction != PRICE_DIRECTION_EXPORT) {
points[point] = static_cast<int32_t>(value * 10000);
}
if(differentExportPrices && direction != PRICE_DIRECTION_IMPORT) {
points[point + numberOfPoints] = static_cast<int32_t>(value * 10000);
}
}
bool PricesContainer::hasPrice(uint8_t point, uint8_t direction) {
float val = getPrice(point, direction);
return val != PRICE_NO_VALUE;
}
float PricesContainer::getPrice(uint8_t point, uint8_t direction) {
if(differentExportPrices && direction == PRICE_DIRECTION_EXPORT) {
if(point < numberOfPoints) {
return static_cast<float>(points[point + numberOfPoints]) / 10000.0f;
}
}
if(differentExportPrices && direction == PRICE_DIRECTION_BOTH) return PRICE_NO_VALUE; // Can't get a price for both directions if the export prices are different
if(point < numberOfPoints) {
return static_cast<float>(points[point]) / 10000.0f;
}
return PRICE_NO_VALUE; // Invalid point
}

View File

@ -36,6 +36,7 @@ private:
bool full;
String topic;
uint32_t lastThresholdPublish = 0;
bool hasExport = false;
bool publishList1(AmsData* data, AmsData* meterState);
bool publishList2(AmsData* data, AmsData* meterState);

View File

@ -41,6 +41,15 @@ bool RawMqttHandler::publish(AmsData* update, AmsData* previousState, EnergyAcco
publishList1(&data, previousState);
loop();
}
if(data.getListType() >= 2 && data.getActiveExportPower() > 0.0) {
hasExport = true;
}
if(data.getListType() >= 3 && data.getActiveExportCounter() > 0.0) {
hasExport = true;
}
if(ea->isInitialized()) {
publishRealtime(ea);
loop();
@ -230,7 +239,7 @@ bool RawMqttHandler::publishTemperatures(AmsConfiguration* config, HwTools* hw)
bool RawMqttHandler::publishPrices(PriceService* ps) {
if(topic.isEmpty() || !mqtt.connected())
return false;
if(ps->getValueForHour(PRICE_DIRECTION_IMPORT, 0) == PRICE_NO_VALUE)
if(!ps->hasPrice())
return false;
time_t now = time(nullptr);
@ -241,7 +250,7 @@ bool RawMqttHandler::publishPrices(PriceService* ps) {
float values[34];
for(int i = 0;i < 34; i++) values[i] = PRICE_NO_VALUE;
for(uint8_t i = 0; i < 34; i++) {
float val = ps->getValueForHour(PRICE_DIRECTION_IMPORT, now, i);
float val = ps->getPriceForRelativeHour(PRICE_DIRECTION_IMPORT, i);
values[i] = val;
if(i > 23) continue;
@ -308,15 +317,33 @@ bool RawMqttHandler::publishPrices(PriceService* ps) {
sprintf(ts6hr, "%04d-%02d-%02dT%02d:00:00Z", tm.Year+1970, tm.Month, tm.Day, tm.Hour);
}
for(int i = 0; i < 34; i++) {
float val = values[i];
if(val == PRICE_NO_VALUE) {
mqtt.publish(topic + "/price/" + String(i), "", true, 0);
mqtt.publish(topic + "/price/resolution", String(ps->getResolutionInMinutes()), true, 0);
mqtt.loop();
uint8_t relativeIndex = 0;
uint8_t startIndex = ps->getCurrentPricePointIndex();
uint8_t numberOfPoints = ps->getNumberOfPointsAvailable();
for(int i = startIndex; i < numberOfPoints; i++) {
float importVal = ps->getPricePoint(PRICE_DIRECTION_IMPORT, i);
if(importVal == PRICE_NO_VALUE) {
mqtt.publish(topic + "/price/import/" + String(relativeIndex), "", true, 0);
mqtt.loop();
} else {
mqtt.publish(topic + "/price/" + String(i), String(val, 4), true, 0);
mqtt.publish(topic + "/price/import/" + String(relativeIndex), String(importVal, 4), true, 0);
mqtt.loop();
}
if(hasExport && ps->isExportPricesDifferentFromImport()) {
float exportVal = ps->getPricePoint(PRICE_DIRECTION_EXPORT, i);
if(exportVal == PRICE_NO_VALUE) {
mqtt.publish(topic + "/price/export/" + String(relativeIndex), "", true, 0);
mqtt.loop();
} else {
mqtt.publish(topic + "/price/export/" + String(relativeIndex), String(exportVal, 4), true, 0);
mqtt.loop();
}
}
relativeIndex++;
}
if(min != INT16_MAX) {
mqtt.publish(topic + "/price/min", String(min, 4), true, 0);
@ -338,15 +365,6 @@ bool RawMqttHandler::publishPrices(PriceService* ps) {
mqtt.publish(topic + "/price/cheapest/6hr", String(ts6hr), true, 0);
mqtt.loop();
}
float exportPrice = ps->getEnergyPriceForHour(PRICE_DIRECTION_EXPORT, now, 0);
if(exportPrice == PRICE_NO_VALUE) {
mqtt.publish(topic + "/exportprice/0", "", true, 0);
mqtt.loop();
} else {
mqtt.publish(topic + "/exportprice/0", String(exportPrice, 4), true, 0);
mqtt.loop();
}
return true;
}

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
<script>
import { Router, Route, navigate } from "svelte-navigator";
import { getTariff, tariffStore, sysinfoStore, dataStore, pricesStore, dayPlotStore, monthPlotStore, temperaturesStore, getSysinfo } from './lib/DataStores.js';
import { getTariff, tariffStore, sysinfoStore, dataStore, importPricesStore, exportPricesStore, dayPlotStore, monthPlotStore, temperaturesStore, getSysinfo } from './lib/DataStores.js';
import { translationsStore, getTranslations } from "./lib/TranslationService.js";
import Favicon from './assets/favicon.svg'; // Need this for the build
import Header from './lib/Header.svelte';
@ -19,9 +19,14 @@
let basepath = document.getElementsByTagName('base')[0].getAttribute("href");
if(!basepath) basepath = "/";
let prices;
pricesStore.subscribe(update => {
prices = update;
let importPrices;
importPricesStore.subscribe(update => {
importPrices = update;
});
let exportPrices;
exportPricesStore.subscribe(update => {
exportPrices = update;
});
let dayPlot;
@ -100,7 +105,7 @@
<Router basepath={basepath}>
<Header data={data} basepath={basepath}/>
<Route path="/">
<Dashboard data={data} sysinfo={sysinfo} prices={prices} dayPlot={dayPlot} monthPlot={monthPlot} temperatures={temperatures} translations={translations} tariffData={tariffData}/>
<Dashboard data={data} sysinfo={sysinfo} importPrices={importPrices} exportPrices={exportPrices} dayPlot={dayPlot} monthPlot={monthPlot} temperatures={temperatures} translations={translations} tariffData={tariffData}/>
</Route>
<Route path="/configuration">
<ConfigurationPanel sysinfo={sysinfo} basepath={basepath} data={data}/>

View File

@ -89,14 +89,14 @@
<rect
x="{xScale(i) + 2}"
y="{yScale(point.value)}"
width="{barWidth - 4}"
width="{barWidth * 0.95}"
height="{yScale(config.y.min) - yScale(Math.min(config.y.min, 0) + point.value)}"
fill="{point.color}"
/>
{#if barWidth > 15}
<text
width="{barWidth - 4}"
width="{barWidth * 0.95}"
dominant-baseline="middle"
text-anchor="{barWidth < vertSwitch || point.labelAngle ? 'left' : 'middle'}"
fill="{yScale(point.value) > yScale(0)-labelOffset && !config.dark ? point.color : 'white'}"
@ -111,13 +111,13 @@
<rect
x="{xScale(i) + 2}"
y="{yScale(0)}"
width="{barWidth - 4}"
width="{barWidth * 0.95}"
height="{yScale(config.y.min) - yScale(config.y.min + point.value2)}"
fill="{point.color2 ? point.color2 : point.color}"
/>
{#if barWidth > 15}
<text
width="{barWidth - 4}"
width="{barWidth * 0.95}"
dominant-baseline="middle"
text-anchor="{'middle'}"
fill="{yScale(-point.value2) < yScale(0) + 15 && !config.dark ? point.color2 ? point.color2 : point.color : 'white'}"

View File

@ -279,11 +279,11 @@
<select name="pr" bind:value={configuration.p.r} on:change={enablePriceFetch} class="in-f w-full">
<optgroup label="Norway">
{#if !configuration.p.t}
<option value="NO1S">NO1 with support</option>
<option value="NO2S">NO2 with support</option>
<option value="NO3S">NO3 with support</option>
<option value="NO4S">NO4 with support</option>
<option value="NO5S">NO5 with support</option>
<option value="NO1S">NO1 w/support</option>
<option value="NO2S">NO2 w/support</option>
<option value="NO3S">NO3 w/support</option>
<option value="NO4S">NO4 w/support</option>
<option value="NO5S">NO5 w/support</option>
{/if}
<option value="10YNO-1--------2">NO1</option>
<option value="10YNO-2--------T">NO2</option>
@ -317,6 +317,14 @@
<option value="10YCH-SWISSGRIDZ">Switzerland</option>
</select>
</div>
<div>
{translations.conf?.price?.resolution ?? "Resolution"}<br/>
<select name="pm" bind:value={configuration.p.m} class="in-m">
{#each [15,60] as m}
<option value={m}>{m}M</option>
{/each}
</select>
</div>
<div>
{translations.conf?.price?.currency ?? "Currency"}<br/>
<select name="pc" bind:value={configuration.p.c} class="in-l">

View File

@ -14,7 +14,8 @@
export let data = {}
export let sysinfo = {}
export let prices = {}
export let importPrices = {}
export let exportPrices = {}
export let dayPlot = {}
export let monthPlot = {}
export let temperatures = {};
@ -136,9 +137,20 @@
<RealtimePlot title={translations.dashboard?.realtime ?? "Real time"}/>
</div>
{/if}
{#if uiVisibility(sysinfo.ui.p, data.pe && !Number.isNaN(data.p))}
{#if uiVisibility(sysinfo.ui.p, data.p && !Number.isNaN(data.p))}
{#if importPrices?.importExportPriceDifferent && (data.om || data.e > 0)}
<div class="cnt gwf">
<PricePlot title="{translations.dashboard?.price_import ?? "Price import"}" json={importPrices}/>
</div>
{:else}
<div class="cnt gwf">
<PricePlot title={translations.dashboard?.price ?? "Price"} json={importPrices}/>
</div>
{/if}
{/if}
{#if importPrices?.importExportPriceDifferent && (data.om || data.e > 0) && uiVisibility(sysinfo.ui.p, data.pe && !Number.isNaN(data.pe))}
<div class="cnt gwf">
<PricePlot title={translations.dashboard?.price ?? "Price"} json={prices} sysinfo={sysinfo}/>
<PricePlot title={translations.dashboard?.price_export ?? "Price export"} json={exportPrices}/>
</div>
{/if}
{#if uiVisibility(sysinfo.ui.d, dayPlot)}

View File

@ -60,7 +60,7 @@ export const dataStore = readable(data, (set) => {
lastTemp = data.t;
setTimeout(getTemperatures, 2000);
}
if(lastPrice == null && data.pe && data.p != null) {
if(data.pe && data.p != lastPrice) {
lastPrice = data.p;
getPrices();
}
@ -109,41 +109,31 @@ export const dataStore = readable(data, (set) => {
}
});
let prices = {};
let priceShiftTimeout;
export const pricesStore = writable(prices);
export async function shiftPrices() {
let fetchUpdate = false;
pricesStore.update(p => {
for(var i = 0; i < 36; i++) {
if(p[zeropad(i)] == null) {
fetchUpdate = i < 12;
break;
}
p[zeropad(i)] = p[zeropad(i+1)];
}
return p;
});
if(fetchUpdate) {
getPrices();
} else {
let date = new Date();
priceShiftTimeout = setTimeout(shiftPrices, ((60-date.getMinutes())*60000))
}
}
let priceFetchTimeout;
let importPrices = {};
export const importPricesStore = writable(importPrices);
let exportprices = {};
export const exportPricesStore = writable(exportprices);
export async function getPrices() {
if(priceShiftTimeout) {
clearTimeout(priceShiftTimeout);
priceShiftTimeout = 0;
if(priceFetchTimeout) {
clearTimeout(priceFetchTimeout);
priceFetchTimeout = 0;
}
{
const response = await fetchWithTimeout("importprice.json");
importPrices = (await response.json())
importPricesStore.set(importPrices);
}
if(importPrices?.importExportPriceDifferent) {
const response = await fetchWithTimeout("exportprice.json");
exportprices = (await response.json())
exportPricesStore.set(exportprices);
}
const response = await fetchWithTimeout("energyprice.json");
prices = (await response.json())
pricesStore.set(prices);
let date = new Date();
priceShiftTimeout = setTimeout(shiftPrices, ((60-date.getMinutes())*60000))
priceFetchTimeout = setTimeout(getPrices, ((24-date.getHours())*3600000)+10)
}
let dayPlot = {};

View File

@ -145,6 +145,11 @@ export function addHours(date, hours) {
return date;
}
export function addMinutes(date, minutes) {
date.setTime(date.getTime() + minutes * 60000);
return date;
}
export function getPriceSourceName(code) {
if(code == "EOE") return "ENTSO-E";
if(code == "HKS") return "hvakosterstrommen.no";

View File

@ -1,10 +1,10 @@
<script>
import { zeropad, addHours, getPriceSourceName, getPriceSourceUrl, formatCurrency } from './Helpers.js';
import { zeropad, addHours, addMinutes, getPriceSourceName, getPriceSourceUrl, formatCurrency } from './Helpers.js';
import BarChart from './BarChart.svelte';
import { onMount } from 'svelte';
export let title;
export let json;
export let sysinfo;
let config = {};
let max;
@ -12,113 +12,123 @@
let dark = document.documentElement.classList.contains('dark');
let cur = new Date();
onMount(() => {
let timeout;
function scheduleUpdate() {
cur = new Date();
timeout = setTimeout(() => {
scheduleUpdate();
}, (15 - (cur.getMinutes() % 15)) * 60000);
}
scheduleUpdate();
return () => {
clearTimeout(timeout);
};
});
$: {
let currency = json.currency;
let hour = new Date().getUTCHours();
let i = 0;
let val = 0;
let h = 0;
let yTicks = [];
let xTicks = [];
let values = [];
min = max = 0;
let cur = new Date();
addHours(cur, sysinfo.clock_offset - ((24 + cur.getHours() - cur.getUTCHours())%24));
for(i = hour; i<24; i++) {
val = json[zeropad(h++)];
if(val == null) break;
xTicks.push({
label: zeropad(cur.getHours())
});
values.push(val*100);
min = Math.min(min, val*100);
max = Math.max(max, val*100);
addHours(cur, 1);
};
for(i = 0; i < 24; i++) {
val = json[zeropad(h++)];
if(val == null) break;
xTicks.push({
label: zeropad(cur.getHours())
});
values.push(val*100);
min = Math.min(min, val*100);
max = Math.max(max, val*100);
addHours(cur, 1);
};
let ret = formatCurrency(Math.max(Math.abs(min) / 100.0, Math.abs(max) / 100.0), currency);
if(ret && ret[1] && ret[1] != currency) {
currency = ret[1];
min *= 100;
max *= 100;
for(i = 0; i < values.length; i++) {
values[i] *= 100;
if(json?.prices?.length > 0) {
cur = new Date();
let currency = json?.currency;
let val = 0;
let yTicks = [];
let xTicks = [];
let values = [];
min = max = 0;
let i = Math.floor(((cur.getHours()*60) + cur.getMinutes()) / json?.resolution);
cur.setMinutes(Math.floor(cur.getMinutes()/json?.resolution)*json?.resolution,0,0);
while(i < json?.prices?.length) {
val = json.prices[i];
if(val == null) break;
xTicks.push({
label: values.length > 0 && json?.resolution < 60 && cur.getMinutes() != 0 ? '' : zeropad(cur.getHours())
});
values.push(val*100);
min = Math.min(min, val*100);
max = Math.max(max, val*100);
addMinutes(cur, json?.resolution);
i++;
}
}
let points = [];
for(i = 0; i < values.length; i++) {
val = values[i];
let disp = val * 0.01;
let d = Math.abs(val) < 1000 ? 2 : 0;
points.push({
label: disp >= 0 ? disp.toFixed(d) : '',
title: disp >= 0 ? disp.toFixed(2) + ' ' + currency : '',
value: val >= 0 ? Math.abs(val) : 0,
label2: disp < 0 ? disp.toFixed(d) : '',
title2: disp < 0 ? disp.toFixed(2) + ' ' + currency : '',
value2: val < 0 ? Math.abs(val) : 0,
color: dark ? '#5c2da5' : '#7c3aed'
});
}
let range = Math.max(max, Math.abs(min));
let ret = formatCurrency(Math.max(Math.abs(min) / 100.0, Math.abs(max) / 100.0), currency);
if(ret && ret[1] && ret[1] != currency) {
currency = ret[1];
min *= 100;
max *= 100;
for(i = 0; i < values.length; i++) {
values[i] *= 100;
}
}
if(min < 0) {
min = Math.min((range/4)*-1, min);
let yTicksNum = Math.ceil((Math.abs(min)/range) * 4);
let yTickDistDown = min/yTicksNum;
for(i = 1; i < yTicksNum+1; i++) {
let val = (yTickDistDown*i);
let points = [];
for(i = 0; i < values.length; i++) {
val = values[i];
let disp = val * 0.01;
let d = Math.abs(val) < 1000 ? 2 : 0;
points.push({
label: disp >= 0 ? disp.toFixed(d) : '',
title: disp >= 0 ? disp.toFixed(2) + ' ' + currency : '',
value: val >= 0 ? Math.abs(val) : 0,
label2: disp < 0 ? disp.toFixed(d) : '',
title2: disp < 0 ? disp.toFixed(2) + ' ' + currency : '',
value2: val < 0 ? Math.abs(val) : 0,
color: dark ? '#5c2da5' : '#7c3aed'
});
}
let range = Math.max(max, Math.abs(min));
if(min < 0) {
min = Math.min((range/4)*-1, min);
let yTicksNum = Math.ceil((Math.abs(min)/range) * 4);
let yTickDistDown = min/yTicksNum;
for(i = 1; i < yTicksNum+1; i++) {
let val = (yTickDistDown*i);
yTicks.push({
value: val,
label: (val/100).toFixed(2)
});
}
}
max = Math.max((range/4), max);
let xTicksNum = Math.ceil((max/range) * 4);
let yTickDistUp = max/xTicksNum;
for(i = 0; i < xTicksNum+1; i++) {
let val = (yTickDistUp*i);
yTicks.push({
value: val,
label: (val/100).toFixed(2)
});
}
}
max = Math.max((range/4), max);
let xTicksNum = Math.ceil((max/range) * 4);
let yTickDistUp = max/xTicksNum;
for(i = 0; i < xTicksNum+1; i++) {
let val = (yTickDistUp*i);
yTicks.push({
value: val,
label: (val/100).toFixed(2)
});
config = {
title: title + " (" + currency + ")",
dark: document.documentElement.classList.contains('dark'),
padding: { top: 20, right: 15, bottom: 20, left: 35 },
y: {
min: min,
max: max,
ticks: yTicks
},
x: {
ticks: xTicks
},
points: points,
link: {
text: "Provided by: " + getPriceSourceName(json.source),
url: getPriceSourceUrl(json.source),
target: '_blank'
}
};
}
config = {
title: title + " (" + currency + ")",
dark: document.documentElement.classList.contains('dark'),
padding: { top: 20, right: 15, bottom: 20, left: 35 },
y: {
min: min,
max: max,
ticks: yTicks
},
x: {
ticks: xTicks
},
points: points,
link: {
text: "Provided by: " + getPriceSourceName(json.source),
url: getPriceSourceUrl(json.source),
target: '_blank'
}
};
};
</script>
<BarChart config={config} />
{#if config.points && config.points.length > 0}
<BarChart config={config} />
{/if}

View File

@ -41,12 +41,6 @@
min = 0;
max = 0;
/*
console.log("\n--Realtime plot debug--")
console.log("Data length: %d\nSize: %d", realtime?.data?.length, realtime?.size);
console.log("Height: %d\nWidth: %d\nBar width: %s", heightAvailable, widthAvailable, barWidth);
*/
if(realtime.data && heightAvailable > 10 && widthAvailable > 100 && barWidth > 0.1) {
visible = true;
for(let p in realtime.data) {
@ -90,9 +84,6 @@
} else {
visible = false;
}
/*
console.log("Min: %d\nMax: %d\nShow: %s", min, max, visible);
*/
}
</script>

View File

@ -25,8 +25,6 @@
label: 0
});
console.log(realtime);
if(tariffData && !isNaN(realtime?.h?.u)) {
points.push({
label: realtime.h.u.toFixed(2),

View File

@ -40,6 +40,8 @@
"tariffpeak" : "Tariff peaks",
"realtime" : "Real-time plot",
"price" : "Future energy price",
"price_import" : "Future import price",
"price_export" : "Future export price",
"day" : "Energy use last 24 hours",
"month" : "Energy use last {0} days",
"temperature" : "Temperature sensors"

View File

@ -19,6 +19,8 @@ export default defineConfig({
proxy: {
"/data.json": "http://192.168.21.122",
"/energyprice.json": "http://192.168.21.122",
"/importprice.json": "http://192.168.21.122",
"/exportprice.json": "http://192.168.21.122",
"/dayplot.json": "http://192.168.21.122",
"/monthplot.json": "http://192.168.21.122",
"/temperature.json": "http://192.168.21.122",

View File

@ -125,7 +125,10 @@ private:
void dataJson();
void dayplotJson();
void monthplotJson();
void energyPriceJson();
void energyPriceJson(); // Deprecated
void importPriceJson();
void exportPriceJson();
void priceJson(uint8_t direction);
void temperatureJson();
void tariffJson();
void realtimeJson();

View File

@ -2,5 +2,6 @@
"e": %s,
"t": "%s",
"r": "%s",
"c": "%s"
"c": "%s",
"m": %d
},

View File

@ -127,6 +127,8 @@ void AmsWebServer::setup(AmsConfiguration* config, GpioConfig* gpioConfig, AmsDa
server.on(context + F("/dayplot.json"), HTTP_GET, std::bind(&AmsWebServer::dayplotJson, this));
server.on(context + F("/monthplot.json"), HTTP_GET, std::bind(&AmsWebServer::monthplotJson, this));
server.on(context + F("/energyprice.json"), HTTP_GET, std::bind(&AmsWebServer::energyPriceJson, this));
server.on(context + F("/importprice.json"), HTTP_GET, std::bind(&AmsWebServer::importPriceJson, this));
server.on(context + F("/exportprice.json"), HTTP_GET, std::bind(&AmsWebServer::exportPriceJson, this));
server.on(context + F("/temperature.json"), HTTP_GET, std::bind(&AmsWebServer::temperatureJson, this));
server.on(context + F("/tariff.json"), HTTP_GET, std::bind(&AmsWebServer::tariffJson, this));
server.on(context + F("/realtime.json"), HTTP_GET, std::bind(&AmsWebServer::realtimeJson, this));
@ -584,8 +586,8 @@ void AmsWebServer::dataJson() {
mqttStatus = 3;
}
float price = ea->getPriceForHour(PRICE_DIRECTION_IMPORT, 0);
float exportPrice = ea->getPriceForHour(PRICE_DIRECTION_EXPORT, 0);
float price = ps == NULL ? PRICE_NO_VALUE : ps->getCurrentPrice(PRICE_DIRECTION_IMPORT);
float exportPrice = ps == NULL ? PRICE_NO_VALUE : ps->getCurrentPrice(PRICE_DIRECTION_EXPORT);
String peaks = "";
for(uint8_t i = 1; i <= ea->getConfig()->hours; i++) {
@ -712,18 +714,24 @@ void AmsWebServer::monthplotJson() {
}
}
// Deprecated
void AmsWebServer::energyPriceJson() {
if(!checkSecurity(2))
return;
if(ps == NULL || !ps->hasPrice()) {
notFound();
return;
}
float prices[36];
for(int i = 0; i < 36; i++) {
prices[i] = ps == NULL ? PRICE_NO_VALUE : ps->getValueForHour(PRICE_DIRECTION_IMPORT, i);
prices[i] = ps->getPriceForRelativeHour(PRICE_DIRECTION_IMPORT, i);
}
uint16_t pos = snprintf_P(buf, BufferSize, PSTR("{\"currency\":\"%s\",\"source\":\"%s\""),
ps == NULL ? "" : ps->getCurrency(),
ps == NULL ? "" : ps->getSource()
ps->getCurrency(),
ps->getSource()
);
for(uint8_t i = 0;i < 36; i++) {
@ -744,6 +752,58 @@ void AmsWebServer::energyPriceJson() {
server.send(200, MIME_JSON, buf);
}
void AmsWebServer::importPriceJson() {
priceJson(PRICE_DIRECTION_IMPORT);
}
void AmsWebServer::exportPriceJson() {
priceJson(PRICE_DIRECTION_EXPORT);
}
void AmsWebServer::priceJson(uint8_t direction) {
if(!checkSecurity(2))
return;
if(ps == NULL || !ps->hasPrice()) {
notFound();
return;
}
uint8_t numberOfPoints = ps->getNumberOfPointsAvailable();
float prices[numberOfPoints];
for(int i = 0; i < numberOfPoints; i++) {
prices[i] = ps->getPricePoint(direction, i);
}
snprintf_P(buf, BufferSize, PSTR("{\"currency\":\"%s\",\"source\":\"%s\",\"resolution\":%d,\"direction\":\"%s\",\"importExportPriceDifferent\":%s,\"prices\":["),
ps->getCurrency(),
ps->getSource(),
ps->getResolutionInMinutes(),
direction == PRICE_DIRECTION_IMPORT ? "import" : direction == PRICE_DIRECTION_EXPORT ? "export" : "both",
ps->isExportPricesDifferentFromImport() ? "true" : "false"
);
addConditionalCloudHeaders();
server.sendHeader(HEADER_CACHE_CONTROL, CACHE_CONTROL_NO_CACHE);
server.sendHeader(HEADER_PRAGMA, PRAGMA_NO_CACHE);
server.sendHeader(HEADER_EXPIRES, EXPIRES_OFF);
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, MIME_JSON, buf);
for(uint8_t i = 0;i < numberOfPoints; i++) {
if(prices[i] == PRICE_NO_VALUE) {
snprintf_P(buf, BufferSize, PSTR("%snull"), i == 0 ? "" : ",");
server.sendContent(buf);
} else {
snprintf_P(buf, BufferSize, PSTR("%s%.4f"), i == 0 ? "" : ",", prices[i]);
server.sendContent(buf);
}
}
server.sendContent_P(PSTR("]}"));
}
void AmsWebServer::temperatureJson() {
if(!checkSecurity(2))
return;
@ -988,7 +1048,8 @@ void AmsWebServer::configurationJson() {
price.enabled ? "true" : "false",
price.entsoeToken,
price.area,
price.currency
price.currency,
price.resolutionInMinutes
);
server.sendContent(buf);
snprintf_P(buf, BufferSize, CONF_DEBUG_JSON,
@ -1333,18 +1394,20 @@ void AmsWebServer::handleSave() {
if(server.hasArg(F("w")) && server.arg(F("w")) == F("true")) {
long mode = server.arg(F("nc")).toInt();
if(mode > 0 && mode < 3) {
if(mode > 0) {
NetworkConfig network;
config->getNetworkConfig(network);
network.mode = mode;
strcpy(network.ssid, server.arg(F("ws")).c_str());
String psk = server.arg(F("wp"));
if(!psk.equals("***")) {
strcpy(network.psk, psk.c_str());
if(mode < 3) {
strcpy(network.ssid, server.arg(F("ws")).c_str());
String psk = server.arg(F("wp"));
if(!psk.equals("***")) {
strcpy(network.psk, psk.c_str());
}
network.power = server.arg(F("ww")).toFloat() * 10;
network.sleep = server.arg(F("wz")).toInt();
network.use11b = server.hasArg(F("wb")) && server.arg(F("wb")) == F("true");
}
network.power = server.arg(F("ww")).toFloat() * 10;
network.sleep = server.arg(F("wz")).toInt();
network.use11b = server.hasArg(F("wb")) && server.arg(F("wb")) == F("true");
if(server.hasArg(F("nm"))) {
if(server.arg(F("nm")) == "static") {
@ -1564,6 +1627,7 @@ void AmsWebServer::handleSave() {
strcpy(price.entsoeToken, server.arg(F("pt")).c_str());
strcpy(price.area, priceRegion.c_str());
strcpy(price.currency, server.arg(F("pc")).c_str());
price.resolutionInMinutes = server.arg(F("pm")).toInt();
config->setPriceServiceConfig(price);
}

View File

@ -253,6 +253,7 @@ void WiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
if(setupMode) return; // None of this necessary in setup mode
if(ch != NULL) ch->eventHandler(event, info);
switch(event) {
case ARDUINO_EVENT_ETH_CONNECTED:
case ARDUINO_EVENT_WIFI_STA_CONNECTED: {
dnsState = 0;
if(ch != NULL) {
@ -265,6 +266,7 @@ void WiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
}
break;
}
case ARDUINO_EVENT_ETH_GOT_IP:
case ARDUINO_EVENT_WIFI_STA_GOT_IP: {
if(dnsState == 0) {
const ip_addr_t* dns = dns_getserver(0);
@ -282,6 +284,7 @@ void WiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
}
break;
}
case ARDUINO_EVENT_ETH_DISCONNECTED:
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: {
if(WiFi.getMode() == WIFI_STA) {
wifi_err_reason_t reason = (wifi_err_reason_t) info.wifi_sta_disconnected.reason;
@ -1631,7 +1634,7 @@ void MQTT_connect() {
HomeAssistantConfig haconf;
config.getHomeAssistantConfig(haconf);
NetworkConfig network;
ch->getCurrentConfig(network);
config.getNetworkConfig(network);
HomeAssistantMqttHandler* hamh = (HomeAssistantMqttHandler*) &mqttHandler;
hamh->setHomeAssistantConfig(haconf, network.hostname);
break;
@ -1660,7 +1663,7 @@ void MQTT_connect() {
HomeAssistantConfig haconf;
config.getHomeAssistantConfig(haconf);
NetworkConfig network;
ch->getCurrentConfig(network);
config.getNetworkConfig(network);
mqttHandler = new HomeAssistantMqttHandler(mqttConfig, &Debug, (char*) commonBuffer, sysConfig.boardType, haconf, &hw, &updater, network.hostname);
break;
case 255:
@ -1673,7 +1676,7 @@ void MQTT_connect() {
if(mqttHandler != NULL) {
mqttHandler->connect();
mqttHandler->publishSystem(&hw, ps, &ea);
if(ps != NULL && ps->getValueForHour(PRICE_DIRECTION_IMPORT, 0) != PRICE_NO_VALUE) {
if(ps != NULL && ps->hasPrice()) {
mqttHandler->publishPrices(ps);
}
}
@ -1966,12 +1969,6 @@ void configFileParse() {
} else if(strncmp_P(buf, PSTR("entsoeCurrency "), 15) == 0) {
if(!lPrice) { config.getPriceServiceConfig(price); lPrice = true; };
strcpy(price.currency, buf+15);
} else if(strncmp_P(buf, PSTR("entsoeMultiplier "), 17) == 0) {
if(!lPrice) { config.getPriceServiceConfig(price); lPrice = true; };
price.unused1 = String(buf+17).toFloat() * 1000;
} else if(strncmp_P(buf, PSTR("entsoeFixedPrice "), 17) == 0) {
if(!lPrice) { config.getPriceServiceConfig(price); lPrice = true; };
price.unused2 = String(buf+17).toFloat() * 1000;
} else if(strncmp_P(buf, PSTR("priceEnabled "), 13) == 0) {
if(!lPrice) { config.getPriceServiceConfig(price); lPrice = true; };
price.enabled = String(buf+13).toInt() == 1;
@ -1984,12 +1981,6 @@ void configFileParse() {
} else if(strncmp_P(buf, PSTR("priceCurrency "), 14) == 0) {
if(!lPrice) { config.getPriceServiceConfig(price); lPrice = true; };
strcpy(price.currency, buf+14);
} else if(strncmp_P(buf, PSTR("priceMultiplier "), 16) == 0) {
if(!lPrice) { config.getPriceServiceConfig(price); lPrice = true; };
price.unused1 = String(buf+16).toFloat() * 1000;
} else if(strncmp_P(buf, PSTR("priceFixedPrice "), 16) == 0) {
if(!lPrice) { config.getPriceServiceConfig(price); lPrice = true; };
price.unused2 = String(buf+16).toFloat() * 1000;
} else if(strncmp_P(buf, PSTR("priceModifier "), 14) == 0) {
PriceConfig pc;
memset(&pc, 0, sizeof(PriceConfig));