/*
|
|
|
|
PZEM004T V3 Sensor
|
|
|
|
Adapted for ESPurna based on:
|
|
- https://github.com/mandulaj/PZEM-004T-v30 by Jakub Mandula
|
|
- https://innovatorsguru.com/wp-content/uploads/2019/06/PZEM-004T-V3.0-Datasheet-User-Manual.pdf
|
|
- http://www.modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf
|
|
|
|
Copyright (C) 2020 by Maxim Prokhorov <prokhorov dot max at outlook dot com>
|
|
|
|
*/
|
|
|
|
#include "BaseEmonSensor.h"
|
|
|
|
#include "../debug.h"
|
|
#include "../utils.h"
|
|
#include "../terminal.h"
|
|
|
|
#include <cstdint>
|
|
#include <array>
|
|
|
|
#define PZEM_DEBUG_MSG_P(...) if (_debug) DEBUG_MSG_P(__VA_ARGS__)
|
|
|
|
|
|
class PZEM004TV30Sensor : public BaseEmonSensor {
|
|
|
|
private:
|
|
|
|
PZEM004TV30Sensor() : BaseEmonSensor(0) {
|
|
_sensor_id = SENSOR_PZEM004TV30_ID;
|
|
_error = SENSOR_ERROR_OK;
|
|
_count = 6;
|
|
}
|
|
|
|
~PZEM004TV30Sensor() {
|
|
PZEM004TV30Sensor::instance = nullptr;
|
|
}
|
|
|
|
public:
|
|
|
|
static PZEM004TV30Sensor* instance;
|
|
static PZEM004TV30Sensor* create() {
|
|
if (PZEM004TV30Sensor::instance) return PZEM004TV30Sensor::instance;
|
|
PZEM004TV30Sensor::instance = new PZEM004TV30Sensor();
|
|
return PZEM004TV30Sensor::instance;
|
|
}
|
|
|
|
static constexpr unsigned long Baudrate = 9600u;
|
|
|
|
// per MODBUS application protocol specification
|
|
// > 4.1 Protocol description
|
|
// > ...
|
|
// > The size of the MODBUS PDU is limited by the size constraint inherited from the first
|
|
// > MODBUS implementation on Serial Line network (max. RS485 ADU = 256 bytes).
|
|
// > Therefore:
|
|
// > MODBUS PDU for serial line communication = 256 - Server address (1 byte) - CRC (2
|
|
// > bytes) = 253 bytes.
|
|
// However, we only ever expect very small payloads. Maximum being 10 registers at the same time.
|
|
static constexpr size_t BufferSize = 25u;
|
|
|
|
// stock address, cannot be used with multiple devices on the line
|
|
static constexpr uint8_t DefaultAddress = 0xf8;
|
|
|
|
// XXX: pzem manual does not specify anything, these are arbitrary values (ms)
|
|
static constexpr unsigned long DefaultReadTimeout = 200u;
|
|
static constexpr unsigned long DefaultUpdateInterval = 200u;
|
|
|
|
// Device uses Modbus-RTU protocol and implements the following function codes:
|
|
// - 0x03 (Read Holding Register) (NOT IMPLEMENTED)
|
|
// - 0x04 (Read Input Register) (measurements readout)
|
|
// - 0x06 (Write Single Register) (set device address, set alarm is NOT IMPLEMENTED)
|
|
// - 0x41 (Calibration) (NOT IMPLEMENTED)
|
|
// - 0x42 (Reset energy) (can only reset to 0)
|
|
static constexpr uint8_t ReadInputCode = 0x04;
|
|
static constexpr uint8_t WriteCode = 0x06;
|
|
static constexpr uint8_t ResetEnergyCode = 0x42;
|
|
|
|
static constexpr uint8_t ErrorMask = 0x80;
|
|
|
|
// We **can** reset PZEM energy, unlike the original PZEM004T
|
|
// However, we can't set it to a specific value, we can only start from 0
|
|
void resetEnergy(unsigned char, sensor::Energy) override {}
|
|
|
|
void resetEnergy() override {
|
|
_reset_energy = true;
|
|
}
|
|
|
|
void resetEnergy(unsigned char) override {
|
|
_reset_energy = true;
|
|
}
|
|
|
|
double getEnergy(unsigned char index) override {
|
|
return _energy;
|
|
}
|
|
|
|
sensor::Energy totalEnergy(unsigned char index) override {
|
|
return getEnergy(index);
|
|
}
|
|
|
|
size_t countDevices() override {
|
|
return 1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
using buffer_type = std::array<uint8_t, BufferSize>;
|
|
|
|
// - PZEM manual "2.7 CRC check":
|
|
// > CRC check use 16bits format, occupy two bytes, the generator polynomial is X16 + X15 + X2 +1,
|
|
// > the polynomial value used for calculation is 0xA001.
|
|
// - Note that we use a simple function instead of a table to save space and RAM.
|
|
static uint16_t crc16modbus(uint8_t* data, size_t size) {
|
|
auto crc16_update = [](uint16_t crc, uint8_t value) {
|
|
crc ^= static_cast<uint16_t>(value);
|
|
for (size_t index = 0; index < 8; ++index) {
|
|
if (crc & 1) {
|
|
crc = (crc >> 1) ^ 0xa001;
|
|
} else {
|
|
crc = (crc >> 1);
|
|
}
|
|
}
|
|
return crc;
|
|
};
|
|
|
|
uint16_t crc = 0xffff;
|
|
for (size_t index = 0; index < size; ++index) {
|
|
crc = crc16_update(crc, data[index]);
|
|
}
|
|
|
|
return crc;
|
|
}
|
|
|
|
struct adu_builder {
|
|
adu_builder(uint8_t device_address, uint8_t fcode) :
|
|
buffer({device_address, fcode}),
|
|
size(2)
|
|
{}
|
|
|
|
adu_builder& add(uint8_t value) {
|
|
if (!locked && (size < buffer.size())) {
|
|
buffer[size] = value;
|
|
size += 1;
|
|
}
|
|
return *this;
|
|
}
|
|
|
|
adu_builder& add(uint16_t value) {
|
|
if (!locked && ((size + 1) < buffer.size())) {
|
|
buffer[size] = static_cast<uint8_t>((value >> 8) & 0xff);
|
|
buffer[size + 1] = static_cast<uint8_t>(value & 0xff);
|
|
size += 2;
|
|
}
|
|
return *this;
|
|
}
|
|
|
|
// Note that CRC order is reversed in comparison to every other value
|
|
adu_builder& end() {
|
|
static_assert(BufferSize >= 4, "Cannot fit the minimal request");
|
|
if (!locked) {
|
|
uint16_t value = crc16modbus(buffer.data(), size);
|
|
buffer[size] = static_cast<uint8_t>(value & 0xff);
|
|
buffer[size + 1] = static_cast<uint8_t>((value >> 8) & 0xff);
|
|
size += 2;
|
|
locked = true;
|
|
}
|
|
return *this;
|
|
}
|
|
|
|
buffer_type buffer;
|
|
size_t size { 0 };
|
|
bool locked { false };
|
|
};
|
|
|
|
void modbusDebugBuffer(const String& message, buffer_type& buffer, size_t size) {
|
|
hexEncode(buffer.data(), size, _debug_buffer, sizeof(_debug_buffer));
|
|
PZEM_DEBUG_MSG_P(PSTR("[PZEM004TV3] %s: %s (%u bytes)\n"), message.c_str(), _debug_buffer, size);
|
|
}
|
|
|
|
static size_t modbusExpect(const adu_builder& builder) {
|
|
if (!builder.locked) {
|
|
return 0;
|
|
}
|
|
|
|
switch (builder.buffer[1]) {
|
|
case ReadInputCode:
|
|
if (builder.size >= 6) {
|
|
return 3 + (2 * ((builder.buffer[4] << 8) | (builder.buffer[5]))) + 2;
|
|
}
|
|
return 0;
|
|
case WriteCode:
|
|
return builder.size;
|
|
case ResetEnergyCode:
|
|
return builder.size;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
template <typename Callback>
|
|
void modbusProcess(const adu_builder& builder, Callback callback) {
|
|
if (!builder.locked) {
|
|
return;
|
|
}
|
|
|
|
_stream->write(builder.buffer.data(), builder.size);
|
|
|
|
size_t expect = modbusExpect(builder);
|
|
if (!expect) {
|
|
return;
|
|
}
|
|
|
|
uint8_t code = builder.buffer[1];
|
|
uint8_t error_code = ErrorMask | code;
|
|
|
|
size_t bytes = 0;
|
|
|
|
buffer_type buffer;
|
|
|
|
// In case we need multiple devices, we need to manually set each one with an unique address **and** also provide
|
|
// a way to distinguish between bus messages based on addresses received. Multiple instances **could** work,
|
|
// based on the idea that we never receive replies from unknown addresses i.e. we never NOT read responses fully
|
|
// and leave something in the serial buffers.
|
|
// TODO: testing is much easier, b/c we can just grab any modbus simulator and set up multiple devices
|
|
auto ts = millis();
|
|
while ((bytes < expect) && (millis() - ts <= _read_timeout)) {
|
|
int c = _stream->read();
|
|
if (c < 0) {
|
|
continue;
|
|
}
|
|
|
|
if ((0 == bytes) && (_address != c)) {
|
|
continue;
|
|
}
|
|
|
|
if (1 == bytes) {
|
|
if (error_code == c) {
|
|
expect = 5;
|
|
} else if (code != c) {
|
|
bytes = 0;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
buffer[bytes++] = static_cast<uint8_t>(c);
|
|
}
|
|
|
|
if (bytes && _debug) {
|
|
modbusDebugBuffer(F("Received"), buffer, bytes);
|
|
}
|
|
|
|
if (bytes != expect) {
|
|
PZEM_DEBUG_MSG_P(PSTR("[PZEM004TV3] ERROR: Expected %u bytes, got %u\n"), expect, bytes);
|
|
_error = SENSOR_ERROR_OTHER; // TODO: more error codes
|
|
return;
|
|
}
|
|
|
|
uint16_t received_crc = static_cast<uint16_t>(buffer[bytes - 1] << 8) | static_cast<uint16_t>(buffer[bytes - 2]);
|
|
uint16_t crc = crc16modbus(buffer.data(), bytes - 2);
|
|
if (received_crc != crc) {
|
|
PZEM_DEBUG_MSG_P(PSTR("[PZEM004TV3] ERROR: CRC invalid: expected %04X expected, received %04X\n"), crc, received_crc);
|
|
_error = SENSOR_ERROR_CRC;
|
|
return;
|
|
}
|
|
|
|
if (buffer[1] & ErrorMask) {
|
|
PZEM_DEBUG_MSG_P(PSTR("[PZEM004TV3] ERROR: %s (0x%02X)\n"),
|
|
errorToString(buffer[2]).c_str(), buffer[2]);
|
|
return;
|
|
}
|
|
|
|
callback(std::move(buffer), bytes);
|
|
}
|
|
|
|
// Energy reset is a 'custom' function, and it does not take any function params
|
|
bool modbusResetEnergy() {
|
|
auto request = adu_builder(_address, ResetEnergyCode)
|
|
.end();
|
|
|
|
// quoting pzem user manual: "Set up correctly, the slave return to the data which is sent from the master.",
|
|
bool result = false;
|
|
modbusProcess(request, [&](buffer_type&& buffer, size_t size) {
|
|
result = std::equal(request.buffer.begin(), request.buffer.begin() + size, buffer.begin());
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
// Address setter is only needed when we are using multiple devices.
|
|
// Note that we would no longer be able to receive replies without changing _address member too
|
|
bool modbusChangeAddress(uint8_t to) {
|
|
if (_address == to) {
|
|
return true;
|
|
}
|
|
|
|
auto request = adu_builder(_address, WriteCode)
|
|
.add(static_cast<uint16_t>(2))
|
|
.add(static_cast<uint16_t>(to))
|
|
.end();
|
|
|
|
// same as for resetEnergy, we receive echo
|
|
bool result = false;
|
|
modbusProcess(request, [&](buffer_type&& buffer, size_t size) {
|
|
result = std::equal(request.buffer.begin(), request.buffer.begin() + size, buffer.begin());
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
// For more, see MODBUS application protocol specification, 7 MODBUS Exception Responses
|
|
String errorToString(uint8_t error) {
|
|
const __FlashStringHelper *ptr = nullptr;
|
|
switch (error) {
|
|
case 0x01:
|
|
ptr = F("Illegal function");
|
|
break;
|
|
case 0x02:
|
|
ptr = F("Illegal data address");
|
|
break;
|
|
case 0x03:
|
|
ptr = F("Illegal data value");
|
|
break;
|
|
case 0x04:
|
|
ptr = F("Device failure");
|
|
break;
|
|
case 0x05:
|
|
ptr = F("Acknowledged");
|
|
break;
|
|
case 0x06:
|
|
ptr = F("Busy");
|
|
break;
|
|
case 0x08:
|
|
ptr = F("Memory parity error");
|
|
break;
|
|
default:
|
|
ptr = F("Unknown");
|
|
break;
|
|
}
|
|
|
|
return ptr;
|
|
}
|
|
|
|
// Quoting the README.md of the original library repo and datasheet, we have:
|
|
// (name, measuring range, resolution, accuracy)
|
|
// 1. Voltage 80~260V 0.1V 0.5%
|
|
// 2. Current 0~10A or 0~100A* 0.01A or 0.02A* 0.5%
|
|
// 3. Active power 0~2.3kW or 0~23kW* 0.1W 0.5%
|
|
// 4. Active energy 0~9999.99kWh 1Wh 0.5%
|
|
// 5. Frequency 45~65Hz 0.1Hz 0.5%
|
|
// 6. Power factor 0.00~1.00 0.01 1%
|
|
void parseMeasurements(buffer_type&& buffer, size_t size) {
|
|
if (25 != size) {
|
|
PZEM_DEBUG_MSG_P(PSTR("[PZEM004TV3] Expected measurements ADU size to be at least 25 bytes, but got only %u\n"), size);
|
|
return;
|
|
}
|
|
|
|
auto it = buffer.begin() + 3;
|
|
auto end = buffer.end();
|
|
|
|
auto take_2 = [&]() -> double {
|
|
double value = 0.0;
|
|
if (std::distance(it, end) >= 2) {
|
|
value = (static_cast<uint32_t>(*(it)) << 8)
|
|
| static_cast<uint32_t>(*(it + 1));
|
|
it += 2;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
auto take_4 = [&]() -> double {
|
|
double value = 0.0;
|
|
if (std::distance(it, end) >= 4) {
|
|
value = (
|
|
((static_cast<uint32_t>(*(it + 2)) << 24)
|
|
| (static_cast<uint32_t>(*(it + 3)) << 16))
|
|
| ((static_cast<uint32_t>(*it) << 8)
|
|
| static_cast<uint32_t>(*(it + 1))));
|
|
it += 4;
|
|
}
|
|
return value;
|
|
};
|
|
|
|
// - Voltage: 2 bytes, in 0.1V (we return V)
|
|
_voltage = take_2();
|
|
_voltage /= 10.0;
|
|
|
|
// - Current: 4 bytes, in 0.001A (we return A)
|
|
_current = take_4();
|
|
_current /= 1000.0;
|
|
|
|
// - Power: 4 bytes, in 0.1W (we return W)
|
|
_power = take_4();
|
|
_power /= 10.0;
|
|
|
|
// - Energy: 4 bytes, in Wh (we return kWh)
|
|
_energy = take_4();
|
|
_energy /= 1000.0;
|
|
|
|
// - Frequency: 2 bytes, in 0.1Hz (we return Hz)
|
|
_frequency = take_2();
|
|
_frequency /= 10.0;
|
|
|
|
// - Power Factor: 2 bytes in 0.01 (we return %)
|
|
_power_factor = take_2();
|
|
|
|
// - Alarms: 2 bytes, (NOT IMPLEMENTED)
|
|
// XXX: it seems it can only be either 0xffff or 0 for ON and OFF respectively
|
|
// XXX: what this does, exactly?
|
|
_alarm = (0xff == *it) && (0xff == *(it + 1));
|
|
}
|
|
|
|
// Reading measurements is a standard modbus function:
|
|
// - addr, 0x04, rhigh, rlow, rnumhigh, rnumlow, crchigh, crclow
|
|
// ReadInput reply can be one of:
|
|
// - addr, 0x04, nbytes, rndatahigh, rndatalow, rndata..., crchigh, crclow (on success)
|
|
// - addr, 0x84, error_code, crchigh, crclow (on error. modbus rtu sets high bit to 1 i.e. 0b00000100 becomes 0b10000100)
|
|
void modbusReadValues() {
|
|
_error = SENSOR_ERROR_OK;
|
|
|
|
auto request = adu_builder(_address, ReadInputCode)
|
|
.add(static_cast<uint16_t>(0))
|
|
.add(static_cast<uint16_t>(10))
|
|
.end();
|
|
modbusProcess(request, [this](buffer_type&& buffer, size_t size) {
|
|
parseMeasurements(std::move(buffer), size);
|
|
});
|
|
}
|
|
|
|
void flush() {
|
|
while (_stream->read() >= 0) {
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
// Note that the device (aka slave) address needs be changed first via
|
|
// - some external tool. For example, using USB2TTL adapter and a PC app
|
|
// - `pzem.address` with **only** one device on the line
|
|
// (because we would change all 0xf8-addressed devices at the same time)
|
|
void setAddress(uint8_t address) {
|
|
_address = address;
|
|
}
|
|
|
|
void setDebug(bool debug) {
|
|
_debug = debug;
|
|
}
|
|
|
|
void setStream(Stream* stream) {
|
|
_stream = stream;
|
|
_stream->setTimeout(_read_timeout);
|
|
}
|
|
|
|
void setReadTimeout(unsigned long value) {
|
|
_read_timeout = value;
|
|
}
|
|
|
|
void setUpdateInterval(unsigned long value) {
|
|
_update_interval = value;
|
|
}
|
|
|
|
template <typename T>
|
|
void setDescription(T&& description) {
|
|
_description = std::forward<T>(description);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
void begin() override {
|
|
_ready = (_stream != nullptr);
|
|
_last_reading = millis() - _update_interval;
|
|
#if TERMINAL_SUPPORT
|
|
terminalRegisterCommand(F("PZ.ADDRESS"), [](const terminal::CommandContext& ctx) {
|
|
if (ctx.argc != 2) {
|
|
terminalError(ctx.output, F("PZ.ADDRESS <ADDRESS>"));
|
|
return;
|
|
}
|
|
uint8_t updated = settings::internal::convert<uint8_t>(ctx.argv[1]);
|
|
|
|
PZEM004TV30Sensor::instance->flush();
|
|
if (PZEM004TV30Sensor::instance->modbusChangeAddress(updated)) {
|
|
PZEM004TV30Sensor::instance->setAddress(updated);
|
|
setSetting("pzemv30Addr", updated);
|
|
terminalOK(ctx.output);
|
|
return;
|
|
}
|
|
|
|
terminalError(ctx.output, F("Could not change the address"));
|
|
});
|
|
#endif
|
|
}
|
|
|
|
String description() override {
|
|
static const String base(F("PZEM004T V3.0"));
|
|
return base + " @ " + _description + ", 0x" + String(_address, 16);
|
|
}
|
|
|
|
String description(unsigned char) override {
|
|
return description();
|
|
}
|
|
|
|
String address(unsigned char) override {
|
|
return String(_address, 16);
|
|
}
|
|
|
|
unsigned char type(unsigned char index) override {
|
|
switch (index) {
|
|
case 0: return MAGNITUDE_VOLTAGE;
|
|
case 1: return MAGNITUDE_CURRENT;
|
|
case 2: return MAGNITUDE_POWER_ACTIVE;
|
|
case 3: return MAGNITUDE_ENERGY;
|
|
case 4: return MAGNITUDE_FREQUENCY;
|
|
case 5: return MAGNITUDE_POWER_FACTOR;
|
|
}
|
|
return MAGNITUDE_NONE;
|
|
}
|
|
|
|
double value(unsigned char index) override {
|
|
switch (index) {
|
|
case 0: return _voltage;
|
|
case 1: return _current;
|
|
case 2: return _power;
|
|
case 3: return _energy;
|
|
case 4: return _frequency;
|
|
case 5: return _power_factor;
|
|
}
|
|
return 0.0;
|
|
}
|
|
|
|
void pre() override {
|
|
flush();
|
|
if (_reset_energy) {
|
|
if (!modbusResetEnergy()) {
|
|
PZEM_DEBUG_MSG_P(PSTR("[PZEM004TV3] Energy reset failed\n"));
|
|
}
|
|
_reset_energy = false;
|
|
flush();
|
|
}
|
|
if (millis() - _last_reading >= _update_interval) {
|
|
modbusReadValues();
|
|
_last_reading = millis();
|
|
}
|
|
}
|
|
|
|
private:
|
|
|
|
String _description;
|
|
|
|
bool _debug { false };
|
|
char _debug_buffer[(BufferSize * 2) + 1];
|
|
|
|
Stream* _stream { nullptr };
|
|
uint8_t _address { DefaultAddress };
|
|
|
|
bool _reset_energy { false };
|
|
|
|
unsigned long _read_timeout { DefaultReadTimeout };
|
|
unsigned long _update_interval { DefaultUpdateInterval };
|
|
unsigned long _last_reading { 0 };
|
|
|
|
double _voltage { 0.0 };
|
|
double _current { 0.0 };
|
|
double _power { 0.0 };
|
|
double _energy { 0.0 };
|
|
double _frequency { 0.0 };
|
|
double _power_factor { 0.0 };
|
|
bool _alarm { false };
|
|
|
|
};
|
|
|
|
PZEM004TV30Sensor* PZEM004TV30Sensor::instance = nullptr;
|
|
|
|
#undef PZEM_DEBUG_MSG_P
|