Adding Huawei CAN interface, web-api, websocket and Mqtt extensions to access the data
This commit is contained in:
parent
63c956af15
commit
8576034b77
68
include/Huawei_can.h
Normal file
68
include/Huawei_can.h
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include "SPI.h"
|
||||||
|
#include <mcp_can.h>
|
||||||
|
|
||||||
|
#define HUAWEI_MINIMAL_OFFLINE_VOLTAGE 48
|
||||||
|
#define HUAWEI_MINIMAL_ONLINE_VOLTAGE 42
|
||||||
|
|
||||||
|
#define MAX_CURRENT_MULTIPLIER 20
|
||||||
|
|
||||||
|
#define HUAWEI_OFFLINE_VOLTAGE 0x01
|
||||||
|
#define HUAWEI_ONLINE_VOLTAGE 0x00
|
||||||
|
#define HUAWEI_OFFLINE_CURRENT 0x04
|
||||||
|
#define HUAWEI_ONLINE_CURRENT 0x03
|
||||||
|
|
||||||
|
#define R48xx_DATA_INPUT_POWER 0x70
|
||||||
|
#define R48xx_DATA_INPUT_FREQ 0x71
|
||||||
|
#define R48xx_DATA_INPUT_CURRENT 0x72
|
||||||
|
#define R48xx_DATA_OUTPUT_POWER 0x73
|
||||||
|
#define R48xx_DATA_EFFICIENCY 0x74
|
||||||
|
#define R48xx_DATA_OUTPUT_VOLTAGE 0x75
|
||||||
|
#define R48xx_DATA_OUTPUT_CURRENT_MAX 0x76
|
||||||
|
#define R48xx_DATA_INPUT_VOLTAGE 0x78
|
||||||
|
#define R48xx_DATA_OUTPUT_TEMPERATURE 0x7F
|
||||||
|
#define R48xx_DATA_INPUT_TEMPERATURE 0x80
|
||||||
|
#define R48xx_DATA_OUTPUT_CURRENT 0x81
|
||||||
|
#define R48xx_DATA_OUTPUT_CURRENT1 0x82
|
||||||
|
|
||||||
|
struct RectifierParameters_t {
|
||||||
|
float input_voltage;
|
||||||
|
float input_frequency;
|
||||||
|
float input_current;
|
||||||
|
float input_power;
|
||||||
|
float input_temp;
|
||||||
|
float efficiency;
|
||||||
|
float output_voltage;
|
||||||
|
float output_current;
|
||||||
|
float max_output_current;
|
||||||
|
float output_power;
|
||||||
|
float output_temp;
|
||||||
|
float amp_hour;
|
||||||
|
};
|
||||||
|
|
||||||
|
class HuaweiCanClass {
|
||||||
|
public:
|
||||||
|
void init(uint8_t huawei_miso, uint8_t huawei_mosi, uint8_t huawei_clk, uint8_t huawei_irq, uint8_t huawei_cs);
|
||||||
|
void loop();
|
||||||
|
void setValue(float in, uint8_t parameterType);
|
||||||
|
RectifierParameters_t& get();
|
||||||
|
unsigned long getLastUpdate();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void sendRequest();
|
||||||
|
void onReceive(uint8_t* frame, uint8_t len);
|
||||||
|
|
||||||
|
unsigned long previousMillis;
|
||||||
|
unsigned long lastUpdate;
|
||||||
|
RectifierParameters_t _rp;
|
||||||
|
|
||||||
|
SPIClass *hspi;
|
||||||
|
MCP_CAN *CAN;
|
||||||
|
uint8_t _huawei_irq;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
extern HuaweiCanClass HuaweiCan;
|
||||||
21
include/MqttHandleHuawei.h
Normal file
21
include/MqttHandleHuawei.h
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Configuration.h"
|
||||||
|
#include <Huawei_can.h>
|
||||||
|
#include <espMqttClient.h>
|
||||||
|
|
||||||
|
class MqttHandleHuaweiClass {
|
||||||
|
public:
|
||||||
|
void init();
|
||||||
|
void loop();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total);
|
||||||
|
|
||||||
|
uint32_t _lastPublishStats;
|
||||||
|
uint32_t _lastPublish;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
extern MqttHandleHuaweiClass MqttHandleHuawei;
|
||||||
@ -33,6 +33,11 @@ struct PinMapping_t {
|
|||||||
uint8_t victron_rx;
|
uint8_t victron_rx;
|
||||||
uint8_t battery_rx;
|
uint8_t battery_rx;
|
||||||
uint8_t battery_tx;
|
uint8_t battery_tx;
|
||||||
|
uint8_t huawei_miso;
|
||||||
|
uint8_t huawei_mosi;
|
||||||
|
uint8_t huawei_clk;
|
||||||
|
uint8_t huawei_irq;
|
||||||
|
uint8_t huawei_cs;
|
||||||
};
|
};
|
||||||
|
|
||||||
class PinMappingClass {
|
class PinMappingClass {
|
||||||
@ -45,7 +50,8 @@ public:
|
|||||||
bool isValidEthConfig();
|
bool isValidEthConfig();
|
||||||
bool isValidVictronConfig();
|
bool isValidVictronConfig();
|
||||||
bool isValidBatteryConfig();
|
bool isValidBatteryConfig();
|
||||||
|
bool isValidHuaweiConfig();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
PinMapping_t _pinMapping;
|
PinMapping_t _pinMapping;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -24,6 +24,8 @@
|
|||||||
#include "WebApi_ws_live.h"
|
#include "WebApi_ws_live.h"
|
||||||
#include "WebApi_ws_vedirect_live.h"
|
#include "WebApi_ws_vedirect_live.h"
|
||||||
#include "WebApi_vedirect.h"
|
#include "WebApi_vedirect.h"
|
||||||
|
#include "WebApi_ws_Huawei.h"
|
||||||
|
#include "WebApi_Huawei.h"
|
||||||
#include <ESPAsyncWebServer.h>
|
#include <ESPAsyncWebServer.h>
|
||||||
|
|
||||||
class WebApiClass {
|
class WebApiClass {
|
||||||
@ -64,6 +66,9 @@ private:
|
|||||||
WebApiWsLiveClass _webApiWsLive;
|
WebApiWsLiveClass _webApiWsLive;
|
||||||
WebApiWsVedirectLiveClass _webApiWsVedirectLive;
|
WebApiWsVedirectLiveClass _webApiWsVedirectLive;
|
||||||
WebApiVedirectClass _webApiVedirect;
|
WebApiVedirectClass _webApiVedirect;
|
||||||
|
WebApiHuaweiClass _webApiHuaweiClass;
|
||||||
|
WebApiWsHuaweiLiveClass _webApiWsHuaweiLive;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
extern WebApiClass WebApi;
|
extern WebApiClass WebApi;
|
||||||
16
include/WebApi_Huawei.h
Normal file
16
include/WebApi_Huawei.h
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <ESPAsyncWebServer.h>
|
||||||
|
|
||||||
|
class WebApiHuaweiClass {
|
||||||
|
public:
|
||||||
|
void init(AsyncWebServer* server);
|
||||||
|
void loop();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void onStatus(AsyncWebServerRequest* request);
|
||||||
|
void onPost(AsyncWebServerRequest* request);
|
||||||
|
|
||||||
|
AsyncWebServer* _server;
|
||||||
|
};
|
||||||
24
include/WebApi_ws_Huawei.h
Normal file
24
include/WebApi_ws_Huawei.h
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "ArduinoJson.h"
|
||||||
|
#include <ESPAsyncWebServer.h>
|
||||||
|
//#include <HuaweiFrameHandler.h>
|
||||||
|
|
||||||
|
class WebApiWsHuaweiLiveClass {
|
||||||
|
public:
|
||||||
|
WebApiWsHuaweiLiveClass();
|
||||||
|
void init(AsyncWebServer* server);
|
||||||
|
void loop();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void generateJsonResponse(JsonVariant& root);
|
||||||
|
void onLivedataStatus(AsyncWebServerRequest* request);
|
||||||
|
void onWebsocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len);
|
||||||
|
|
||||||
|
AsyncWebServer* _server;
|
||||||
|
AsyncWebSocket _ws;
|
||||||
|
|
||||||
|
uint32_t _lastWsCleanup = 0;
|
||||||
|
uint32_t _lastUpdateCheck = 0;
|
||||||
|
};
|
||||||
181
src/Huawei_can.cpp
Normal file
181
src/Huawei_can.cpp
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2023 Malte Schmidt and others
|
||||||
|
*/
|
||||||
|
#include "Huawei_can.h"
|
||||||
|
#include "MessageOutput.h"
|
||||||
|
#include <SPI.h>
|
||||||
|
#include <mcp_can.h>
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
HuaweiCanClass HuaweiCan;
|
||||||
|
|
||||||
|
void HuaweiCanClass::init(uint8_t huawei_miso, uint8_t huawei_mosi, uint8_t huawei_clk, uint8_t huawei_irq, uint8_t huawei_cs)
|
||||||
|
{
|
||||||
|
|
||||||
|
hspi = new SPIClass(VSPI);
|
||||||
|
hspi->begin(huawei_clk, huawei_miso, huawei_mosi, huawei_cs);
|
||||||
|
pinMode(huawei_cs, OUTPUT);
|
||||||
|
digitalWrite(huawei_cs, HIGH);
|
||||||
|
|
||||||
|
pinMode(huawei_irq, INPUT_PULLUP);
|
||||||
|
_huawei_irq = huawei_irq;
|
||||||
|
|
||||||
|
CAN = new MCP_CAN(hspi, huawei_cs);
|
||||||
|
if(CAN->begin(MCP_ANY, CAN_125KBPS, MCP_8MHZ) == CAN_OK) {
|
||||||
|
MessageOutput.println("MCP2515 Initialized Successfully!");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
MessageOutput.println("Error Initializing MCP2515...");
|
||||||
|
}
|
||||||
|
|
||||||
|
CAN->setMode(MCP_NORMAL); // Change to normal mode to allow messages to be transmitted
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
RectifierParameters_t& HuaweiCanClass::get()
|
||||||
|
{
|
||||||
|
return _rp;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long HuaweiCanClass::getLastUpdate()
|
||||||
|
{
|
||||||
|
return lastUpdate;
|
||||||
|
}
|
||||||
|
uint8_t data[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||||
|
|
||||||
|
// Requests current values from Huawei unit. Response is handled in onReceive
|
||||||
|
void HuaweiCanClass::sendRequest()
|
||||||
|
{
|
||||||
|
if (previousMillis < millis()) {
|
||||||
|
|
||||||
|
// Send extended message
|
||||||
|
byte sndStat = CAN->sendMsgBuf(0x108040FE, 1, 8, data);
|
||||||
|
if(sndStat == CAN_OK){
|
||||||
|
MessageOutput.println("Message Sent Successfully!");
|
||||||
|
} else {
|
||||||
|
MessageOutput.println("Error Sending Message...");
|
||||||
|
}
|
||||||
|
|
||||||
|
previousMillis += 5000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HuaweiCanClass::onReceive(uint8_t* frame, uint8_t len)
|
||||||
|
{
|
||||||
|
if (len != 8) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t value = __bswap32(*(uint32_t*)&frame[4]);
|
||||||
|
|
||||||
|
switch (frame[1]) {
|
||||||
|
case R48xx_DATA_INPUT_POWER:
|
||||||
|
_rp.input_power = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_INPUT_FREQ:
|
||||||
|
_rp.input_frequency = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_INPUT_CURRENT:
|
||||||
|
_rp.input_current = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_OUTPUT_POWER:
|
||||||
|
_rp.output_power = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_EFFICIENCY:
|
||||||
|
_rp.efficiency = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_OUTPUT_VOLTAGE:
|
||||||
|
_rp.output_voltage = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_OUTPUT_CURRENT_MAX:
|
||||||
|
_rp.max_output_current = value / MAX_CURRENT_MULTIPLIER;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_INPUT_VOLTAGE:
|
||||||
|
_rp.input_voltage = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_OUTPUT_TEMPERATURE:
|
||||||
|
_rp.output_temp = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_INPUT_TEMPERATURE:
|
||||||
|
_rp.input_temp = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_OUTPUT_CURRENT1:
|
||||||
|
// printf("Output Current(1) %.02fA\r\n", value / 1024.0);
|
||||||
|
// output_current = value / 1024.0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R48xx_DATA_OUTPUT_CURRENT:
|
||||||
|
_rp.output_current = value / 1024.0;
|
||||||
|
|
||||||
|
/* This is normally the last parameter received. Print */
|
||||||
|
lastUpdate = millis();
|
||||||
|
|
||||||
|
MessageOutput.printf("In: %.02fV, %.02fA, %.02fW\n", _rp.input_voltage, _rp.input_current, _rp.input_power);
|
||||||
|
MessageOutput.printf("Out: %.02fV, %.02fA of %.02fA, %.02fW\n", _rp.output_voltage, _rp.output_current, _rp.max_output_current, _rp.output_power);
|
||||||
|
MessageOutput.printf("Eff: %.01f%%, Temp in: %.01fC, Temp out: %.01fC\n", _rp.efficiency * 100, _rp.input_temp, _rp.output_temp);
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// printf("Unknown parameter 0x%02X, 0x%04X\r\n",frame[1], value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HuaweiCanClass::loop()
|
||||||
|
{
|
||||||
|
|
||||||
|
long unsigned int rxId;
|
||||||
|
unsigned char len = 0;
|
||||||
|
unsigned char rxBuf[8];
|
||||||
|
|
||||||
|
if(!digitalRead(_huawei_irq)) // If CAN_INT pin is low, read receive buffer
|
||||||
|
{
|
||||||
|
CAN->readMsgBuf(&rxId, &len, rxBuf); // Read data: len = data length, buf = data byte(s)
|
||||||
|
|
||||||
|
if((rxId & 0x80000000) == 0x80000000) { // Determine if ID is standard (11 bits) or extended (29 bits)
|
||||||
|
// MessageOutput.printf("Extended ID: 0x%.8lX DLC: %1d \n", (rxId & 0x1FFFFFFF), len);
|
||||||
|
if ((rxId & 0x1FFFFFFF) == 0x1081407F) {
|
||||||
|
onReceive(rxBuf, len);
|
||||||
|
}
|
||||||
|
// Other emitted codes not handled here are: 0x1081407E, 0x1081807E, 0x1081D27F, 0x1001117E, 0x100011FE, 0x108111FE, 0x108081FE. See:
|
||||||
|
// https://github.com/craigpeacock/Huawei_R4850G2_CAN/blob/main/r4850.c
|
||||||
|
// https://www.beyondlogic.org/review-huawei-r4850g2-power-supply-53-5vdc-3kw/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
void HuaweiCanClass::setValue(float in, uint8_t parameterType)
|
||||||
|
{
|
||||||
|
uint16_t value;
|
||||||
|
if (parameterType == HUAWEI_OFFLINE_VOLTAGE || parameterType == HUAWEI_ONLINE_VOLTAGE) {
|
||||||
|
value = in * 1024;
|
||||||
|
} else if (parameterType == HUAWEI_OFFLINE_CURRENT || parameterType == HUAWEI_ONLINE_CURRENT) {
|
||||||
|
value = in * MAX_CURRENT_MULTIPLIER;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t data[8] = {0x01, parameterType, 0x00, 0x00, 0x00, 0x00, (uint8_t)((value & 0xFF00) >> 8), (uint8_t)(value & 0xFF)};
|
||||||
|
|
||||||
|
// Send extended message
|
||||||
|
byte sndStat = CAN->sendMsgBuf(0x108180FE, 1, 8, data);
|
||||||
|
if(sndStat == CAN_OK){
|
||||||
|
MessageOutput.println("Message Sent Successfully!");
|
||||||
|
} else {
|
||||||
|
MessageOutput.println("Error Sending Message...");
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/MqttHandleHuawei.cpp
Normal file
104
src/MqttHandleHuawei.cpp
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Thomas Basler and others
|
||||||
|
*/
|
||||||
|
#include "MqttHandleHuawei.h"
|
||||||
|
#include "MessageOutput.h"
|
||||||
|
#include "MqttSettings.h"
|
||||||
|
#include "Huawei_can.h"
|
||||||
|
// #include "Failsafe.h"
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
|
#define TOPIC_SUB_LIMIT_ONLINE_VOLTAGE "limit_online_voltage"
|
||||||
|
#define TOPIC_SUB_LIMIT_ONLINE_CURRENT "limit_online_current"
|
||||||
|
#define TOPIC_SUB_POWER "power"
|
||||||
|
|
||||||
|
MqttHandleHuaweiClass MqttHandleHuawei;
|
||||||
|
|
||||||
|
void MqttHandleHuaweiClass::init()
|
||||||
|
{
|
||||||
|
using std::placeholders::_1;
|
||||||
|
using std::placeholders::_2;
|
||||||
|
using std::placeholders::_3;
|
||||||
|
using std::placeholders::_4;
|
||||||
|
using std::placeholders::_5;
|
||||||
|
using std::placeholders::_6;
|
||||||
|
|
||||||
|
String topic = MqttSettings.getPrefix();
|
||||||
|
MqttSettings.subscribe(String(topic + "huawei/cmd/" + TOPIC_SUB_LIMIT_ONLINE_VOLTAGE).c_str(), 0, std::bind(&MqttHandleHuaweiClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6));
|
||||||
|
MqttSettings.subscribe(String(topic + "huawei/cmd/" + TOPIC_SUB_LIMIT_ONLINE_CURRENT).c_str(), 0, std::bind(&MqttHandleHuaweiClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6));
|
||||||
|
MqttSettings.subscribe(String(topic + "huawei/cmd/" + TOPIC_SUB_POWER).c_str(), 0, std::bind(&MqttHandleHuaweiClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6));
|
||||||
|
|
||||||
|
//pinMode(13, OUTPUT);
|
||||||
|
//digitalWrite(13,HIGH);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void MqttHandleHuaweiClass::loop()
|
||||||
|
{
|
||||||
|
if (!MqttSettings.getConnected() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_T& config = Configuration.get();
|
||||||
|
const RectifierParameters_t& rp = HuaweiCan.get();
|
||||||
|
|
||||||
|
if (millis() - _lastPublish > (config.Mqtt_PublishInterval * 1000)) {
|
||||||
|
MqttSettings.publish("huawei/data_age", String((millis() - HuaweiCan.getLastUpdate()) / 1000));
|
||||||
|
MqttSettings.publish("huawei/input_voltage", String(rp.input_voltage));
|
||||||
|
MqttSettings.publish("huawei/input_current", String(rp.input_current));
|
||||||
|
MqttSettings.publish("huawei/input_power", String(rp.input_power));
|
||||||
|
MqttSettings.publish("huawei/output_voltage", String(rp.output_voltage));
|
||||||
|
MqttSettings.publish("huawei/output_current", String(rp.output_current));
|
||||||
|
MqttSettings.publish("huawei/max_output_current", String(rp.max_output_current));
|
||||||
|
MqttSettings.publish("huawei/output_power", String(rp.output_power));
|
||||||
|
MqttSettings.publish("huawei/input_temp", String(rp.input_temp));
|
||||||
|
MqttSettings.publish("huawei/output_temp", String(rp.output_temp));
|
||||||
|
MqttSettings.publish("huawei/efficiency", String(rp.efficiency));
|
||||||
|
|
||||||
|
yield();
|
||||||
|
_lastPublish = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void MqttHandleHuaweiClass::onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total)
|
||||||
|
{
|
||||||
|
const CONFIG_T& config = Configuration.get();
|
||||||
|
|
||||||
|
char token_topic[MQTT_MAX_TOPIC_STRLEN + 40]; // respect all subtopics
|
||||||
|
strncpy(token_topic, topic, MQTT_MAX_TOPIC_STRLEN + 40); // convert const char* to char*
|
||||||
|
|
||||||
|
char* setting;
|
||||||
|
char* rest = &token_topic[strlen(config.Mqtt_Topic)];
|
||||||
|
|
||||||
|
strtok_r(rest, "/", &rest); // Remove "huawei"
|
||||||
|
strtok_r(rest, "/", &rest); // Remove "cmd"
|
||||||
|
|
||||||
|
setting = strtok_r(rest, "/", &rest);
|
||||||
|
|
||||||
|
if (setting == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char* strlimit = new char[len + 1];
|
||||||
|
memcpy(strlimit, payload, len);
|
||||||
|
strlimit[len] = '\0';
|
||||||
|
float payload_val = strtof(strlimit, NULL);
|
||||||
|
delete[] strlimit;
|
||||||
|
|
||||||
|
if (!strcmp(setting, TOPIC_SUB_LIMIT_ONLINE_VOLTAGE)) {
|
||||||
|
// Set voltage limit
|
||||||
|
MessageOutput.printf("Limit Voltage: %f V\r\n", payload_val);
|
||||||
|
HuaweiCan.setValue(payload_val, HUAWEI_ONLINE_VOLTAGE);
|
||||||
|
|
||||||
|
} else if (!strcmp(setting, TOPIC_SUB_LIMIT_ONLINE_CURRENT)) {
|
||||||
|
// Set current limit
|
||||||
|
MessageOutput.printf("Limit Current: %f A\r\n", payload_val);
|
||||||
|
HuaweiCan.setValue(payload_val, HUAWEI_ONLINE_CURRENT);
|
||||||
|
} else if (!strcmp(setting, TOPIC_SUB_POWER)) {
|
||||||
|
// Control power on/off
|
||||||
|
MessageOutput.printf("Limit Current: %f A\r\n", payload_val);
|
||||||
|
//digitalWrite(13, payload_val > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -66,6 +66,12 @@ PinMappingClass::PinMappingClass()
|
|||||||
|
|
||||||
_pinMapping.battery_rx = PYLONTECH_PIN_RX;
|
_pinMapping.battery_rx = PYLONTECH_PIN_RX;
|
||||||
_pinMapping.battery_tx = PYLONTECH_PIN_TX;
|
_pinMapping.battery_tx = PYLONTECH_PIN_TX;
|
||||||
|
|
||||||
|
_pinMapping.huawei_miso = HUAWEI_PIN_MISO;
|
||||||
|
_pinMapping.huawei_mosi = HUAWEI_PIN_MOSI;
|
||||||
|
_pinMapping.huawei_clk = HUAWEI_PIN_SCLK;
|
||||||
|
_pinMapping.huawei_cs = HUAWEI_PIN_CS;
|
||||||
|
_pinMapping.huawei_irq = HUAWEI_PIN_IRQ;
|
||||||
}
|
}
|
||||||
|
|
||||||
PinMapping_t& PinMappingClass::get()
|
PinMapping_t& PinMappingClass::get()
|
||||||
@ -124,6 +130,12 @@ bool PinMappingClass::init(const String& deviceMapping)
|
|||||||
_pinMapping.battery_rx = doc[i]["battery"]["rx"] | PYLONTECH_PIN_RX;
|
_pinMapping.battery_rx = doc[i]["battery"]["rx"] | PYLONTECH_PIN_RX;
|
||||||
_pinMapping.battery_tx = doc[i]["battery"]["tx"] | PYLONTECH_PIN_TX;
|
_pinMapping.battery_tx = doc[i]["battery"]["tx"] | PYLONTECH_PIN_TX;
|
||||||
|
|
||||||
|
_pinMapping.huawei_miso = doc[i]["huawei"]["miso"] | HUAWEI_PIN_MISO;
|
||||||
|
_pinMapping.huawei_mosi = doc[i]["huawei"]["mosi"] | HUAWEI_PIN_MOSI;
|
||||||
|
_pinMapping.huawei_clk = doc[i]["huawei"]["clk"] | HUAWEI_PIN_SCLK;
|
||||||
|
_pinMapping.huawei_irq = doc[i]["huawei"]["irq"] | HUAWEI_PIN_IRQ;
|
||||||
|
_pinMapping.huawei_cs = doc[i]["huawei"]["cs"] | HUAWEI_PIN_CS;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -157,3 +169,12 @@ bool PinMappingClass::isValidBatteryConfig()
|
|||||||
return _pinMapping.battery_rx > 0
|
return _pinMapping.battery_rx > 0
|
||||||
&& _pinMapping.battery_tx > 0;
|
&& _pinMapping.battery_tx > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool PinMappingClass::isValidHuaweiConfig()
|
||||||
|
{
|
||||||
|
return _pinMapping.huawei_miso > 0
|
||||||
|
&& _pinMapping.huawei_mosi > 0
|
||||||
|
&& _pinMapping.huawei_clk > 0
|
||||||
|
&& _pinMapping.huawei_irq > 0
|
||||||
|
&& _pinMapping.huawei_cs > 0;
|
||||||
|
}
|
||||||
|
|||||||
@ -40,6 +40,8 @@ void WebApiClass::init()
|
|||||||
_webApiWsLive.init(&_server);
|
_webApiWsLive.init(&_server);
|
||||||
_webApiWsVedirectLive.init(&_server);
|
_webApiWsVedirectLive.init(&_server);
|
||||||
_webApiVedirect.init(&_server);
|
_webApiVedirect.init(&_server);
|
||||||
|
_webApiWsHuaweiLive.init(&_server);
|
||||||
|
_webApiHuaweiClass.init(&_server);
|
||||||
|
|
||||||
_server.begin();
|
_server.begin();
|
||||||
}
|
}
|
||||||
@ -68,6 +70,8 @@ void WebApiClass::loop()
|
|||||||
_webApiWsLive.loop();
|
_webApiWsLive.loop();
|
||||||
_webApiWsVedirectLive.loop();
|
_webApiWsVedirectLive.loop();
|
||||||
_webApiVedirect.loop();
|
_webApiVedirect.loop();
|
||||||
|
_webApiWsHuaweiLive.loop();
|
||||||
|
_webApiHuaweiClass.loop();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool WebApiClass::checkCredentials(AsyncWebServerRequest* request)
|
bool WebApiClass::checkCredentials(AsyncWebServerRequest* request)
|
||||||
|
|||||||
168
src/WebApi_Huawei.cpp
Normal file
168
src/WebApi_Huawei.cpp
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Thomas Basler and others
|
||||||
|
*/
|
||||||
|
#include "WebApi_Huawei.h"
|
||||||
|
#include "Huawei_can.h"
|
||||||
|
#include "WebApi.h"
|
||||||
|
#include "WebApi_errors.h"
|
||||||
|
#include <AsyncJson.h>
|
||||||
|
#include <Hoymiles.h>
|
||||||
|
|
||||||
|
void WebApiHuaweiClass::init(AsyncWebServer* server)
|
||||||
|
{
|
||||||
|
using std::placeholders::_1;
|
||||||
|
|
||||||
|
_server = server;
|
||||||
|
|
||||||
|
_server->on("/api/huawei/status", HTTP_GET, std::bind(&WebApiHuaweiClass::onStatus, this, _1));
|
||||||
|
_server->on("/api/huawei/limit/config", HTTP_POST, std::bind(&WebApiHuaweiClass::onPost, this, _1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApiHuaweiClass::loop()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApiHuaweiClass::onStatus(AsyncWebServerRequest* request)
|
||||||
|
{
|
||||||
|
if (!WebApi.checkCredentialsReadonly(request)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncJsonResponse* response = new AsyncJsonResponse();
|
||||||
|
JsonObject root = response->getRoot();
|
||||||
|
|
||||||
|
const RectifierParameters_t& rp = HuaweiCan.get();
|
||||||
|
|
||||||
|
root["data_age"] = (millis() - HuaweiCan.getLastUpdate()) / 1000;
|
||||||
|
root[F("input_voltage")]["v"] = rp.input_voltage;
|
||||||
|
root[F("input_voltage")]["u"] = "V";
|
||||||
|
root[F("input_current")]["v"] = rp.input_current;
|
||||||
|
root[F("input_current")]["u"] = "A";
|
||||||
|
root[F("input_power")]["v"] = rp.input_power;
|
||||||
|
root[F("input_power")]["u"] = "W";
|
||||||
|
root[F("output_voltage")]["v"] = rp.output_voltage;
|
||||||
|
root[F("output_voltage")]["u"] = "V";
|
||||||
|
root[F("output_current")]["v"] = rp.output_current;
|
||||||
|
root[F("output_current")]["u"] = "A";
|
||||||
|
root[F("max_output_current")]["v"] = rp.max_output_current;
|
||||||
|
root[F("max_output_current")]["u"] = "A";
|
||||||
|
root[F("output_power")]["v"] = rp.output_power;
|
||||||
|
root[F("output_power")]["u"] = "W";
|
||||||
|
root[F("input_temp")]["v"] = rp.input_temp;
|
||||||
|
root[F("input_temp")]["u"] = "°C";
|
||||||
|
root[F("output_temp")]["v"] = rp.output_temp;
|
||||||
|
root[F("output_temp")]["u"] = "°C";
|
||||||
|
root[F("efficiency")]["v"] = rp.efficiency;
|
||||||
|
root[F("efficiency")]["u"] = "%";
|
||||||
|
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApiHuaweiClass::onPost(AsyncWebServerRequest* request)
|
||||||
|
{
|
||||||
|
if (!WebApi.checkCredentials(request)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncJsonResponse* response = new AsyncJsonResponse();
|
||||||
|
JsonObject retMsg = response->getRoot();
|
||||||
|
retMsg[F("type")] = F("warning");
|
||||||
|
|
||||||
|
if (!request->hasParam("data", true)) {
|
||||||
|
retMsg[F("message")] = F("No values found!");
|
||||||
|
retMsg[F("code")] = WebApiError::GenericNoValueFound;
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String json = request->getParam("data", true)->value();
|
||||||
|
|
||||||
|
if (json.length() > 1024) {
|
||||||
|
retMsg[F("message")] = F("Data too large!");
|
||||||
|
retMsg[F("code")] = WebApiError::GenericDataTooLarge;
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicJsonDocument root(1024);
|
||||||
|
DeserializationError error = deserializeJson(root, json);
|
||||||
|
float value;
|
||||||
|
uint8_t online = true;
|
||||||
|
float minimal_voltage;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
retMsg[F("message")] = F("Failed to parse data!");
|
||||||
|
retMsg[F("code")] = WebApiError::GenericParseError;
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.containsKey("online")) {
|
||||||
|
online = root[F("online")].as<bool>();
|
||||||
|
if (online) {
|
||||||
|
minimal_voltage = HUAWEI_MINIMAL_ONLINE_VOLTAGE;
|
||||||
|
} else {
|
||||||
|
minimal_voltage = HUAWEI_MINIMAL_OFFLINE_VOLTAGE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
retMsg[F("message")] = F("Could not read info if data should be set for online/offline operation!");
|
||||||
|
retMsg[F("code")] = WebApiError::LimitInvalidType;
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.containsKey("voltage_valid")) {
|
||||||
|
if (root[F("voltage_valid")].as<bool>()) {
|
||||||
|
if (root[F("voltage")].as<float>() < minimal_voltage || root[F("voltage")].as<float>() > 58) {
|
||||||
|
retMsg[F("message")] = F("voltage not in range between 42 (online)/48 (offline and 58V !");
|
||||||
|
retMsg[F("code")] = WebApiError::LimitInvalidLimit;
|
||||||
|
retMsg[F("param")][F("max")] = 58;
|
||||||
|
retMsg[F("param")][F("min")] = minimal_voltage;
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
value = root[F("voltage")].as<float>();
|
||||||
|
if (online) {
|
||||||
|
HuaweiCan.setValue(value, HUAWEI_ONLINE_VOLTAGE);
|
||||||
|
} else {
|
||||||
|
HuaweiCan.setValue(value, HUAWEI_OFFLINE_VOLTAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.containsKey("current_valid")) {
|
||||||
|
if (root[F("current_valid")].as<bool>()) {
|
||||||
|
if (root[F("current")].as<float>() < 0 || root[F("current")].as<float>() > 60) {
|
||||||
|
retMsg[F("message")] = F("current must be in range between 0 and 60!");
|
||||||
|
retMsg[F("code")] = WebApiError::LimitInvalidLimit;
|
||||||
|
retMsg[F("param")][F("max")] = 60;
|
||||||
|
retMsg[F("param")][F("min")] = 0;
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
value = root[F("current")].as<float>();
|
||||||
|
if (online) {
|
||||||
|
HuaweiCan.setValue(value, HUAWEI_ONLINE_CURRENT);
|
||||||
|
} else {
|
||||||
|
HuaweiCan.setValue(value, HUAWEI_OFFLINE_CURRENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retMsg[F("type")] = F("success");
|
||||||
|
retMsg[F("message")] = F("Settings saved!");
|
||||||
|
retMsg[F("code")] = WebApiError::GenericSuccess;
|
||||||
|
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
}
|
||||||
128
src/WebApi_ws_Huawei.cpp
Normal file
128
src/WebApi_ws_Huawei.cpp
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Thomas Basler and others
|
||||||
|
*/
|
||||||
|
#include "WebApi_ws_Huawei.h"
|
||||||
|
#include "AsyncJson.h"
|
||||||
|
#include "Configuration.h"
|
||||||
|
#include "Huawei_can.h"
|
||||||
|
#include "MessageOutput.h"
|
||||||
|
#include "WebApi.h"
|
||||||
|
#include "defaults.h"
|
||||||
|
|
||||||
|
WebApiWsHuaweiLiveClass::WebApiWsHuaweiLiveClass()
|
||||||
|
: _ws("/huaweilivedata")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApiWsHuaweiLiveClass::init(AsyncWebServer* server)
|
||||||
|
{
|
||||||
|
using std::placeholders::_1;
|
||||||
|
using std::placeholders::_2;
|
||||||
|
using std::placeholders::_3;
|
||||||
|
using std::placeholders::_4;
|
||||||
|
using std::placeholders::_5;
|
||||||
|
using std::placeholders::_6;
|
||||||
|
|
||||||
|
_server = server;
|
||||||
|
_server->on("/api/huaweilivedata/status", HTTP_GET, std::bind(&WebApiWsHuaweiLiveClass::onLivedataStatus, this, _1));
|
||||||
|
|
||||||
|
_server->addHandler(&_ws);
|
||||||
|
_ws.onEvent(std::bind(&WebApiWsHuaweiLiveClass::onWebsocketEvent, this, _1, _2, _3, _4, _5, _6));
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApiWsHuaweiLiveClass::loop()
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
// see: https://github.com/me-no-dev/ESPAsyncWebServer#limiting-the-number-of-web-socket-clients
|
||||||
|
if (millis() - _lastWsCleanup > 1000) {
|
||||||
|
_ws.cleanupClients();
|
||||||
|
_lastWsCleanup = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
// do nothing if no WS client is connected
|
||||||
|
if (_ws.count() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (millis() - _lastUpdateCheck < 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastUpdateCheck = millis();
|
||||||
|
|
||||||
|
DynamicJsonDocument root(1024);
|
||||||
|
JsonVariant var = root;
|
||||||
|
generateJsonResponse(var);
|
||||||
|
|
||||||
|
String buffer;
|
||||||
|
if (buffer) {
|
||||||
|
serializeJson(root, buffer);
|
||||||
|
|
||||||
|
if (Configuration.get().Security_AllowReadonly) {
|
||||||
|
_ws.setAuthentication("", "");
|
||||||
|
} else {
|
||||||
|
_ws.setAuthentication(AUTH_USERNAME, Configuration.get().Security_Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ws.textAll(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApiWsHuaweiLiveClass::generateJsonResponse(JsonVariant& root)
|
||||||
|
{
|
||||||
|
const RectifierParameters_t& rp = HuaweiCan.get();
|
||||||
|
|
||||||
|
root["data_age"] = (millis() - HuaweiCan.getLastUpdate()) / 1000;
|
||||||
|
root[F("input_voltage")]["v"] = rp.input_voltage;
|
||||||
|
root[F("input_voltage")]["u"] = "V";
|
||||||
|
root[F("input_current")]["v"] = rp.input_current;
|
||||||
|
root[F("input_current")]["u"] = "A";
|
||||||
|
root[F("input_power")]["v"] = rp.input_power;
|
||||||
|
root[F("input_power")]["u"] = "W";
|
||||||
|
root[F("output_voltage")]["v"] = rp.output_voltage;
|
||||||
|
root[F("output_voltage")]["u"] = "V";
|
||||||
|
root[F("output_current")]["v"] = rp.output_current;
|
||||||
|
root[F("output_current")]["u"] = "A";
|
||||||
|
root[F("max_output_current")]["v"] = rp.max_output_current;
|
||||||
|
root[F("max_output_current")]["u"] = "A";
|
||||||
|
root[F("output_power")]["v"] = rp.output_power;
|
||||||
|
root[F("output_power")]["u"] = "W";
|
||||||
|
root[F("input_temp")]["v"] = rp.input_temp;
|
||||||
|
root[F("input_temp")]["u"] = "°C";
|
||||||
|
root[F("output_temp")]["v"] = rp.output_temp;
|
||||||
|
root[F("output_temp")]["u"] = "°C";
|
||||||
|
root[F("efficiency")]["v"] = rp.efficiency;
|
||||||
|
root[F("efficiency")]["u"] = "%";
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApiWsHuaweiLiveClass::onWebsocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len)
|
||||||
|
{
|
||||||
|
if (type == WS_EVT_CONNECT) {
|
||||||
|
char str[64];
|
||||||
|
snprintf(str, sizeof(str), "Websocket: [%s][%u] connect", server->url(), client->id());
|
||||||
|
Serial.println(str);
|
||||||
|
MessageOutput.println(str);
|
||||||
|
} else if (type == WS_EVT_DISCONNECT) {
|
||||||
|
char str[64];
|
||||||
|
snprintf(str, sizeof(str), "Websocket: [%s][%u] disconnect", server->url(), client->id());
|
||||||
|
Serial.println(str);
|
||||||
|
MessageOutput.println(str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WebApiWsHuaweiLiveClass::onLivedataStatus(AsyncWebServerRequest* request)
|
||||||
|
{
|
||||||
|
if (!WebApi.checkCredentialsReadonly(request)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AsyncJsonResponse* response = new AsyncJsonResponse(false, 1024U);
|
||||||
|
JsonVariant root = response->getRoot().as<JsonVariant>();
|
||||||
|
generateJsonResponse(root);
|
||||||
|
|
||||||
|
response->setLength();
|
||||||
|
request->send(response);
|
||||||
|
}
|
||||||
14
src/main.cpp
14
src/main.cpp
@ -8,6 +8,7 @@
|
|||||||
#include "MessageOutput.h"
|
#include "MessageOutput.h"
|
||||||
#include "VeDirectFrameHandler.h"
|
#include "VeDirectFrameHandler.h"
|
||||||
#include "PylontechCanReceiver.h"
|
#include "PylontechCanReceiver.h"
|
||||||
|
#include "Huawei_can.h"
|
||||||
#include "MqttHandleDtu.h"
|
#include "MqttHandleDtu.h"
|
||||||
#include "MqttHandleHass.h"
|
#include "MqttHandleHass.h"
|
||||||
#include "MqttHandleVedirectHass.h"
|
#include "MqttHandleVedirectHass.h"
|
||||||
@ -158,6 +159,17 @@ void setup()
|
|||||||
} else {
|
} else {
|
||||||
MessageOutput.println(F("Invalid pin config"));
|
MessageOutput.println(F("Invalid pin config"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize Huawei AC-charger PSU / CAN bus
|
||||||
|
MessageOutput.println(F("Initialize Huawei AC charger interface... "));
|
||||||
|
if (PinMapping.isValidHuaweiConfig()) {
|
||||||
|
MessageOutput.printf("Huawei AC-charger miso = %d, mosi = %d, clk = %d, irq = %d, cs = %d\r\n", pin.huawei_miso, pin.huawei_mosi, pin.huawei_clk, pin.huawei_irq, pin.huawei_cs);
|
||||||
|
HuaweiCan.init(pin.huawei_miso, pin.huawei_mosi, pin.huawei_clk, pin.huawei_irq, pin.huawei_cs);
|
||||||
|
MessageOutput.println(F("done"));
|
||||||
|
} else {
|
||||||
|
MessageOutput.println(F("Invalid pin config"));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop()
|
void loop()
|
||||||
@ -193,4 +205,6 @@ void loop()
|
|||||||
yield();
|
yield();
|
||||||
PylontechCanReceiver.loop();
|
PylontechCanReceiver.loop();
|
||||||
yield();
|
yield();
|
||||||
|
HuaweiCan.loop();
|
||||||
|
yield();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user