Added Kamstrup Multical 40x component (#4200)

Co-authored-by: Chris Feenstra <chris@cfeenstra.nl>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: cfeenstra1024 <git@cfeenstra.nl>
This commit is contained in:
Chris Feenstra 2024-03-13 04:01:22 +01:00 committed by GitHub
parent b34b10888b
commit 64a47f840e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 615 additions and 6 deletions

View file

@ -173,6 +173,7 @@ esphome/components/integration/* @OttoWinter
esphome/components/internal_temperature/* @Mat931
esphome/components/interval/* @esphome/core
esphome/components/json/* @OttoWinter
esphome/components/kamstrup_kmp/* @cfeenstra1024
esphome/components/key_collector/* @ssieb
esphome/components/key_provider/* @ssieb
esphome/components/kuntze/* @ssieb

View file

@ -1,7 +1,7 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome import automation
from esphome.const import CONF_ID, CONF_TRIGGER_ID, CONF_FILE, CONF_DEVICE
from esphome.const import CONF_ID, CONF_TRIGGER_ID, CONF_FILE, CONF_DEVICE, CONF_VOLUME
from esphome.components import uart
DEPENDENCIES = ["uart"]
@ -19,7 +19,6 @@ DFPlayerIsPlayingCondition = dfplayer_ns.class_(
MULTI_CONF = True
CONF_FOLDER = "folder"
CONF_LOOP = "loop"
CONF_VOLUME = "volume"
CONF_EQ_PRESET = "eq_preset"
CONF_ON_FINISHED_PLAYBACK = "on_finished_playback"

View file

@ -1,7 +1,13 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import i2c
from esphome.const import CONF_ADDRESS, CONF_COMMAND, CONF_ID, CONF_DURATION
from esphome.const import (
CONF_ADDRESS,
CONF_COMMAND,
CONF_ID,
CONF_DURATION,
CONF_VOLUME,
)
from esphome import automation
from esphome.automation import maybe_simple_id
@ -9,7 +15,6 @@ CODEOWNERS = ["@carlos-sarmiento"]
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
CONF_VOLUME = "volume"
CONF_VOLUME_PER_MINUTE = "volume_per_minute"
ezo_pmp_ns = cg.esphome_ns.namespace("ezo_pmp")

View file

@ -0,0 +1,301 @@
#include "kamstrup_kmp.h"
#include "esphome/core/log.h"
namespace esphome {
namespace kamstrup_kmp {
static const char *const TAG = "kamstrup_kmp";
void KamstrupKMPComponent::dump_config() {
ESP_LOGCONFIG(TAG, "kamstrup_kmp:");
if (this->is_failed()) {
ESP_LOGE(TAG, "Communication with Kamstrup meter failed!");
}
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Heat Energy", this->heat_energy_sensor_);
LOG_SENSOR(" ", "Power", this->power_sensor_);
LOG_SENSOR(" ", "Temperature 1", this->temp1_sensor_);
LOG_SENSOR(" ", "Temperature 2", this->temp2_sensor_);
LOG_SENSOR(" ", "Temperature Difference", this->temp_diff_sensor_);
LOG_SENSOR(" ", "Flow", this->flow_sensor_);
LOG_SENSOR(" ", "Volume", this->volume_sensor_);
for (int i = 0; i < this->custom_sensors_.size(); i++) {
LOG_SENSOR(" ", "Custom Sensor", this->custom_sensors_[i]);
ESP_LOGCONFIG(TAG, " Command: 0x%04X", this->custom_commands_[i]);
}
this->check_uart_settings(1200, 2, uart::UART_CONFIG_PARITY_NONE, 8);
}
float KamstrupKMPComponent::get_setup_priority() const { return setup_priority::DATA; }
void KamstrupKMPComponent::update() {
if (this->heat_energy_sensor_ != nullptr) {
this->command_queue_.push(CMD_HEAT_ENERGY);
}
if (this->power_sensor_ != nullptr) {
this->command_queue_.push(CMD_POWER);
}
if (this->temp1_sensor_ != nullptr) {
this->command_queue_.push(CMD_TEMP1);
}
if (this->temp2_sensor_ != nullptr) {
this->command_queue_.push(CMD_TEMP2);
}
if (this->temp_diff_sensor_ != nullptr) {
this->command_queue_.push(CMD_TEMP_DIFF);
}
if (this->flow_sensor_ != nullptr) {
this->command_queue_.push(CMD_FLOW);
}
if (this->volume_sensor_ != nullptr) {
this->command_queue_.push(CMD_VOLUME);
}
for (uint16_t custom_command : this->custom_commands_) {
this->command_queue_.push(custom_command);
}
}
void KamstrupKMPComponent::loop() {
if (!this->command_queue_.empty()) {
uint16_t command = this->command_queue_.front();
this->send_command_(command);
this->command_queue_.pop();
}
}
void KamstrupKMPComponent::send_command_(uint16_t command) {
uint32_t msg_len = 5;
uint8_t msg[msg_len];
msg[0] = 0x3F;
msg[1] = 0x10;
msg[2] = 0x01;
msg[3] = command >> 8;
msg[4] = command & 0xFF;
this->clear_uart_rx_buffer_();
this->send_message_(msg, msg_len);
this->read_command_(command);
}
void KamstrupKMPComponent::send_message_(const uint8_t *msg, int msg_len) {
int buffer_len = msg_len + 2;
uint8_t buffer[buffer_len];
// Prepare the basic message and appand CRC
for (int i = 0; i < msg_len; i++) {
buffer[i] = msg[i];
}
buffer[buffer_len - 2] = 0;
buffer[buffer_len - 1] = 0;
uint16_t crc = crc16_ccitt(buffer, buffer_len);
buffer[buffer_len - 2] = crc >> 8;
buffer[buffer_len - 1] = crc & 0xFF;
// Prepare actual TX message
uint8_t tx_msg[20];
int tx_msg_len = 1;
tx_msg[0] = 0x80; // prefix
for (int i = 0; i < buffer_len; i++) {
if (buffer[i] == 0x06 || buffer[i] == 0x0d || buffer[i] == 0x1b || buffer[i] == 0x40 || buffer[i] == 0x80) {
tx_msg[tx_msg_len++] = 0x1b;
tx_msg[tx_msg_len++] = buffer[i] ^ 0xff;
} else {
tx_msg[tx_msg_len++] = buffer[i];
}
}
tx_msg[tx_msg_len++] = 0x0D; // EOM
this->write_array(tx_msg, tx_msg_len);
}
void KamstrupKMPComponent::clear_uart_rx_buffer_() {
uint8_t tmp;
while (this->available()) {
this->read_byte(&tmp);
}
}
void KamstrupKMPComponent::read_command_(uint16_t command) {
uint8_t buffer[20] = {0};
int buffer_len = 0;
int data;
int timeout = 250; // ms
// Read the data from the UART
while (timeout > 0) {
if (this->available()) {
data = this->read();
if (data > -1) {
if (data == 0x40) { // start of message
buffer_len = 0;
}
buffer[buffer_len++] = (uint8_t) data;
if (data == 0x0D) {
break;
}
} else {
ESP_LOGE(TAG, "Error while reading from UART");
}
} else {
delay(1);
timeout--;
}
}
if (timeout == 0 || buffer_len == 0) {
ESP_LOGE(TAG, "Request timed out");
return;
}
// Validate message (prefix and suffix)
if (buffer[0] != 0x40) {
ESP_LOGE(TAG, "Received invalid message (prefix mismatch received 0x%02X, expected 0x40)", buffer[0]);
return;
}
if (buffer[buffer_len - 1] != 0x0D) {
ESP_LOGE(TAG, "Received invalid message (EOM mismatch received 0x%02X, expected 0x0D)", buffer[buffer_len - 1]);
return;
}
// Decode
uint8_t msg[20] = {0};
int msg_len = 0;
for (int i = 1; i < buffer_len - 1; i++) {
if (buffer[i] == 0x1B) {
msg[msg_len++] = buffer[i + 1] ^ 0xFF;
i++;
} else {
msg[msg_len++] = buffer[i];
}
}
// Validate CRC
if (crc16_ccitt(msg, msg_len)) {
ESP_LOGE(TAG, "Received invalid message (CRC mismatch)");
return;
}
// All seems good. Now parse the message
this->parse_command_message_(command, msg, msg_len);
}
void KamstrupKMPComponent::parse_command_message_(uint16_t command, const uint8_t *msg, int msg_len) {
// Validate the message
if (msg_len < 8) {
ESP_LOGE(TAG, "Received invalid message (message too small)");
return;
}
if (msg[0] != 0x3F || msg[1] != 0x10) {
ESP_LOGE(TAG, "Received invalid message (invalid header received 0x%02X%02X, expected 0x3F10)", msg[0], msg[1]);
return;
}
uint16_t recv_command = msg[2] << 8 | msg[3];
if (recv_command != command) {
ESP_LOGE(TAG, "Received invalid message (invalid unexpected command received 0x%04X, expected 0x%04X)",
recv_command, command);
return;
}
uint8_t unit_idx = msg[4];
uint8_t mantissa_range = msg[5];
if (mantissa_range > 4) {
ESP_LOGE(TAG, "Received invalid message (mantissa size too large %d, expected 4)", mantissa_range);
return;
}
// Calculate exponent
float exponent = msg[6] & 0x3F;
if (msg[6] & 0x40) {
exponent = -exponent;
}
exponent = powf(10, exponent);
if (msg[6] & 0x80) {
exponent = -exponent;
}
// Calculate mantissa
uint32_t mantissa = 0;
for (int i = 0; i < mantissa_range; i++) {
mantissa <<= 8;
mantissa |= msg[i + 7];
}
// Calculate the actual value
float value = mantissa * exponent;
// Set sensor value
this->set_sensor_value_(command, value, unit_idx);
}
void KamstrupKMPComponent::set_sensor_value_(uint16_t command, float value, uint8_t unit_idx) {
const char *unit = UNITS[unit_idx];
// Standard sensors
if (command == CMD_HEAT_ENERGY && this->heat_energy_sensor_ != nullptr) {
this->heat_energy_sensor_->publish_state(value);
} else if (command == CMD_POWER && this->power_sensor_ != nullptr) {
this->power_sensor_->publish_state(value);
} else if (command == CMD_TEMP1 && this->temp1_sensor_ != nullptr) {
this->temp1_sensor_->publish_state(value);
} else if (command == CMD_TEMP2 && this->temp2_sensor_ != nullptr) {
this->temp2_sensor_->publish_state(value);
} else if (command == CMD_TEMP_DIFF && this->temp_diff_sensor_ != nullptr) {
this->temp_diff_sensor_->publish_state(value);
} else if (command == CMD_FLOW && this->flow_sensor_ != nullptr) {
this->flow_sensor_->publish_state(value);
} else if (command == CMD_VOLUME && this->volume_sensor_ != nullptr) {
this->volume_sensor_->publish_state(value);
}
// Custom sensors
for (int i = 0; i < this->custom_commands_.size(); i++) {
if (command == this->custom_commands_[i]) {
this->custom_sensors_[i]->publish_state(value);
}
}
ESP_LOGD(TAG, "Received value for command 0x%04X: %.3f [%s]", command, value, unit);
}
uint16_t crc16_ccitt(const uint8_t *buffer, int len) {
uint32_t poly = 0x1021;
uint32_t reg = 0x00;
for (int i = 0; i < len; i++) {
int mask = 0x80;
while (mask > 0) {
reg <<= 1;
if (buffer[i] & mask) {
reg |= 1;
}
mask >>= 1;
if (reg & 0x10000) {
reg &= 0xffff;
reg ^= poly;
}
}
}
return (uint16_t) reg;
}
} // namespace kamstrup_kmp
} // namespace esphome

View file

@ -0,0 +1,131 @@
#pragma once
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
namespace esphome {
namespace kamstrup_kmp {
/*
===========================================================================
=== KAMSTRUP KMP ===
===========================================================================
Kamstrup Meter Protocol (KMP) is a protocol used with Kamstrup district
heating meters, e.g. Kamstrup MULTICAL 403.
These devices register consumed heat from a district heating system.
It does this by measuring the incoming and outgoing water temperature
and by measuring the water flow. The temperature difference (delta T)
together with the water flow results in consumed energy, typically
in giga joule (GJ).
The Kamstrup Multical has an optical interface just above the display.
This interface is essentially an RS-232 interface using a proprietary
protocol (Kamstrup Meter Protocol [KMP]).
The integration uses this optical interface to periodically read the
configured values (sensors) from the meter. Supported sensors are:
- Heat Energy [GJ]
- Current Power Consumption [kW]
- Temperature 1 [°C]
- Temperature 2 [°C]
- Temperature Difference [°K]
- Water Flow [l/h]
- Volume [m3]
Apart from these supported 'fixed' sensors, the user can configure up to
five custom sensors. The KMP command (16 bit unsigned int) has to be
provided in that case.
Note:
The optical interface is enabled as soon as a button on the meter is pushed.
The interface stays active for a few minutes. To keep the interface 'alive'
magnets must be placed around the optical sensor.
Units:
Units are set using the regular Sensor config in the user yaml. However,
KMP does also send the correct unit with every value. When DEBUG logging
is enabled, the received value with the received unit are logged.
Acknowledgement:
This interface was inspired by:
- https://atomstar.tweakblogs.net/blog/19110/reading-out-kamstrup-multical-402-403-with-home-built-optical-head
- https://wiki.hal9k.dk/projects/kamstrup
*/
// KMP Commands
static const uint16_t CMD_HEAT_ENERGY = 0x003C;
static const uint16_t CMD_POWER = 0x0050;
static const uint16_t CMD_TEMP1 = 0x0056;
static const uint16_t CMD_TEMP2 = 0x0057;
static const uint16_t CMD_TEMP_DIFF = 0x0059;
static const uint16_t CMD_FLOW = 0x004A;
static const uint16_t CMD_VOLUME = 0x0044;
// KMP units
static const char *const UNITS[] = {
"", "Wh", "kWh", "MWh", "GWh", "J", "kJ", "MJ", "GJ", "Cal",
"kCal", "Mcal", "Gcal", "varh", "kvarh", "Mvarh", "Gvarh", "VAh", "kVAh", "MVAh",
"GVAh", "kW", "kW", "MW", "GW", "kvar", "kvar", "Mvar", "Gvar", "VA",
"kVA", "MVA", "GVA", "V", "A", "kV", "kA", "C", "K", "l",
"m3", "l/h", "m3/h", "m3xC", "ton", "ton/h", "h", "hh:mm:ss", "yy:mm:dd", "yyyy:mm:dd",
"mm:dd", "", "bar", "RTC", "ASCII", "m3 x 10", "ton x 10", "GJ x 10", "minutes", "Bitfield",
"s", "ms", "days", "RTC-Q", "Datetime"};
class KamstrupKMPComponent : public PollingComponent, public uart::UARTDevice {
public:
void set_heat_energy_sensor(sensor::Sensor *sensor) { this->heat_energy_sensor_ = sensor; }
void set_power_sensor(sensor::Sensor *sensor) { this->power_sensor_ = sensor; }
void set_temp1_sensor(sensor::Sensor *sensor) { this->temp1_sensor_ = sensor; }
void set_temp2_sensor(sensor::Sensor *sensor) { this->temp2_sensor_ = sensor; }
void set_temp_diff_sensor(sensor::Sensor *sensor) { this->temp_diff_sensor_ = sensor; }
void set_flow_sensor(sensor::Sensor *sensor) { this->flow_sensor_ = sensor; }
void set_volume_sensor(sensor::Sensor *sensor) { this->volume_sensor_ = sensor; }
void dump_config() override;
float get_setup_priority() const override;
void update() override;
void loop() override;
void add_custom_sensor(sensor::Sensor *sensor, uint16_t command) {
this->custom_sensors_.push_back(sensor);
this->custom_commands_.push_back(command);
}
protected:
// Sensors
sensor::Sensor *heat_energy_sensor_{nullptr};
sensor::Sensor *power_sensor_{nullptr};
sensor::Sensor *temp1_sensor_{nullptr};
sensor::Sensor *temp2_sensor_{nullptr};
sensor::Sensor *temp_diff_sensor_{nullptr};
sensor::Sensor *flow_sensor_{nullptr};
sensor::Sensor *volume_sensor_{nullptr};
// Custom sensors and commands
std::vector<sensor::Sensor *> custom_sensors_;
std::vector<uint16_t> custom_commands_;
// Command queue
std::queue<uint16_t> command_queue_;
// Methods
// Sends a command to the meter and receives its response
void send_command_(uint16_t command);
// Sends a message to the meter. A prefix/suffix and CRC are added
void send_message_(const uint8_t *msg, int msg_len);
// Clears and data that might be in the UART Rx buffer
void clear_uart_rx_buffer_();
// Reads and validates the response to a send command
void read_command_(uint16_t command);
// Parses a received message
void parse_command_message_(uint16_t command, const uint8_t *msg, int msg_len);
// Sets the received value to the correct sensor
void set_sensor_value_(uint16_t command, float value, uint8_t unit_idx);
};
// "true" CCITT CRC-16
uint16_t crc16_ccitt(const uint8_t *buffer, int len);
} // namespace kamstrup_kmp
} // namespace esphome

View file

@ -0,0 +1,132 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import sensor, uart
from esphome.const import (
CONF_COMMAND,
CONF_CUSTOM,
CONF_FLOW,
CONF_ID,
CONF_POWER,
CONF_VOLUME,
DEVICE_CLASS_EMPTY,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLUME,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_CELSIUS,
UNIT_CUBIC_METER,
UNIT_EMPTY,
UNIT_KELVIN,
UNIT_KILOWATT,
)
CODEOWNERS = ["@cfeenstra1024"]
DEPENDENCIES = ["uart"]
kamstrup_kmp_ns = cg.esphome_ns.namespace("kamstrup_kmp")
KamstrupKMPComponent = kamstrup_kmp_ns.class_(
"KamstrupKMPComponent", cg.PollingComponent, uart.UARTDevice
)
CONF_HEAT_ENERGY = "heat_energy"
CONF_TEMP1 = "temp1"
CONF_TEMP2 = "temp2"
CONF_TEMP_DIFF = "temp_diff"
UNIT_GIGA_JOULE = "GJ"
UNIT_LITRE_PER_HOUR = "l/h"
# Note: The sensor units are set automatically based un the received data from the meter
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(KamstrupKMPComponent),
cv.Optional(CONF_HEAT_ENERGY): sensor.sensor_schema(
accuracy_decimals=3,
device_class=DEVICE_CLASS_ENERGY,
state_class=STATE_CLASS_TOTAL_INCREASING,
unit_of_measurement=UNIT_GIGA_JOULE,
),
cv.Optional(CONF_POWER): sensor.sensor_schema(
accuracy_decimals=3,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
unit_of_measurement=UNIT_KILOWATT,
),
cv.Optional(CONF_TEMP1): sensor.sensor_schema(
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
unit_of_measurement=UNIT_CELSIUS,
),
cv.Optional(CONF_TEMP2): sensor.sensor_schema(
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
unit_of_measurement=UNIT_CELSIUS,
),
cv.Optional(CONF_TEMP_DIFF): sensor.sensor_schema(
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
unit_of_measurement=UNIT_KELVIN,
),
cv.Optional(CONF_FLOW): sensor.sensor_schema(
accuracy_decimals=1,
device_class=DEVICE_CLASS_VOLUME,
state_class=STATE_CLASS_MEASUREMENT,
unit_of_measurement=UNIT_LITRE_PER_HOUR,
),
cv.Optional(CONF_VOLUME): sensor.sensor_schema(
accuracy_decimals=1,
device_class=DEVICE_CLASS_VOLUME,
state_class=STATE_CLASS_TOTAL_INCREASING,
unit_of_measurement=UNIT_CUBIC_METER,
),
cv.Optional(CONF_CUSTOM): cv.ensure_list(
sensor.sensor_schema(
accuracy_decimals=1,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
unit_of_measurement=UNIT_EMPTY,
).extend({cv.Required(CONF_COMMAND): cv.hex_uint16_t})
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"kamstrup_kmp", baud_rate=1200, require_rx=True, require_tx=True
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
# Standard sensors
for key in [
CONF_HEAT_ENERGY,
CONF_POWER,
CONF_TEMP1,
CONF_TEMP2,
CONF_TEMP_DIFF,
CONF_FLOW,
CONF_VOLUME,
]:
if key not in config:
continue
conf = config[key]
sens = await sensor.new_sensor(conf)
cg.add(getattr(var, f"set_{key}_sensor")(sens))
# Custom sensors
if CONF_CUSTOM in config:
for conf in config[CONF_CUSTOM]:
sens = await sensor.new_sensor(conf)
cg.add(var.add_custom_sensor(sens, conf[CONF_COMMAND]))

View file

@ -3,7 +3,7 @@ import esphome.config_validation as cv
import esphome.codegen as cg
from esphome.automation import maybe_simple_id
from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TRIGGER_ID
from esphome.const import CONF_ID, CONF_ON_STATE, CONF_TRIGGER_ID, CONF_VOLUME
from esphome.core import CORE
from esphome.coroutine import coroutine_with_priority
from esphome.cpp_helpers import setup_entity
@ -43,7 +43,6 @@ VolumeSetAction = media_player_ns.class_(
)
CONF_VOLUME = "volume"
CONF_ON_IDLE = "on_idle"
CONF_ON_PLAY = "on_play"
CONF_ON_PAUSE = "on_pause"

View file

@ -856,6 +856,7 @@ CONF_VISUAL = "visual"
CONF_VOLTAGE = "voltage"
CONF_VOLTAGE_ATTENUATION = "voltage_attenuation"
CONF_VOLTAGE_DIVIDER = "voltage_divider"
CONF_VOLUME = "volume"
CONF_WAIT_TIME = "wait_time"
CONF_WAIT_UNTIL = "wait_until"
CONF_WAKEUP_PIN = "wakeup_pin"

View file

@ -0,0 +1,25 @@
uart:
tx_pin: ${uart_tx_pin}
rx_pin: ${uart_rx_pin}
baud_rate: 1200
stop_bits: 2
sensor:
- platform: kamstrup_kmp
heat_energy:
name: Heat Energy
power:
name: Power
temp1:
name: Temperature 1
temp2:
name: Temperature 2
temp_diff:
name: Temperature Difference
flow:
name: Flow
volume:
name: Volume
custom:
- name: Custom 1
command: 0x1234

View file

@ -0,0 +1,5 @@
substitutions:
uart_tx_pin: GPIO1
uart_rx_pin: GPIO3
<<: !include common.yaml

View file

@ -0,0 +1,5 @@
substitutions:
uart_tx_pin: GPIO1
uart_rx_pin: GPIO3
<<: !include common.yaml

View file

@ -0,0 +1,5 @@
substitutions:
uart_tx_pin: GPIO1
uart_rx_pin: GPIO3
<<: !include common.yaml