Начало
Всем привет. В этой статье я расскажу, как я упростил себе жизнь, автоматизировав подачу показаний счетчика. После переезда в новое жилье появилась возможность установить счетчик потребления тепла, который в теории (в моем случае, и на практике тоже) должен был сократить расходы на оплату услуг тепловых сетей. После установки прибора нужно каждый месяц в определенный период времени вносить показания тепла в личный кабинет на сайте тепловых сетей. Мой счетчик находится в общем коридоре в фальшстене, доступ к которому осуществляется через ревизионную рамку. В целом ― это неудобный процесс. Стоит отметить, что в странах Европы этот процесс часто автоматизирован на уровне самого поставщика тепловых услуг. Также, в наших широтах я встречал предложение от компаний, которые обслуживают ОСМД, установки такой системы на уровне подъезда или дома. Но так как это не мой случай, меня не покидала мысль, что этот процесс можно возложить на вычислительную машину. В конце концов, как звучал когда-то рекламный слоган IBM:
Machines should work; people should think.
― IBM
Ближе к делу
Какого результата хочется достичь:
Что у нас есть из железа:
Счетчик тепла с поддержкой M-Bus
Raspberry Pi 2 с установленной Raspberry Pi OS
M-Bus master устройство
Wi-Fi реле Sonoff s20
Набор изображен на картинке. Слева блок питания, подключенный через реле. В центре сам Raspberry Pi. Справа M-Bus на USB конвертор (белый шнур идет к счетчику тепла).
Из программной части будем использовать:
Библиотеку для чтения M-Bus датаграмм написанную на языке C
Программную часть можно условно разделить на две составляющих. Первая ― это чтение данных из счетчика, вторая ― подача данных на сайт теплосетей.
M-Bus
M-Bus ― стандарт для удаленного считывания данных из счетчиков тепла или любых других устройств учета потребления, разработанный в Европе. Существует вариант передачи данных по кабелю и беспроводной вариант. В этой статье рассматривается только передача по кабелю.
Некоторые примечания относительно технологи:
К одному master устройству могут быть подключены несколько счетчиков (slave-устройств)
Устройствам назначается уникальный адрес
Диапазон рабочих напряжений соответствует 12-36 V: логический «0» ― 12..24 V, логическая «1» ― 36 V
Рекомендуемый тип кабеля ― стандартный телефонный (JYStY N*2*0.8 mm). Я использовал витую пару с сечением 0.51 мм, потому что она уже была протянута в подъезд.
Для того чтобы иметь возможность снять данные, счетчик должен поддерживать протокол M-Bus (выступать в роли slave-устройства). У меня счетчик этой модели.
Выход провода выглядит так.
Также нужно M-Bus master-устройство, которое в нашем случае еще и конвертирует сигнал из 36 V в 5 V.
Немного об устройстве
На самом деле оно делает немного больше, вроде защиты от короткого замыкания. Я открыл корпус устройства и сделал фото на случай, если кому-то будет интересно. Само устройство было приобретено на Aliexpress. Ссылку на товар нет смысл оставлять, так как она может быстро устареть. Поиск по ключевым словам "M-Bus USB Master" даст вам нужный результат. Важно: это должно быть именно master-устройство. Позже мне на глаза попалась плата расширения для Raspberry Pi. Я не могу дать отзыва по ее работе, но ввиду компактности решения, сейчас я обязательно рассмотрел бы этот вариант.
У нас есть счетчик, подключенный к master-устройству, которое в свою очередь подключено к Raspberry Pi и определяется как последовательный порт (у меня на Raspberry Pi OS устройство определяется как /dev/ttyUSB0). Теперь мы можем отправлять и получать датаграммы. К счастью, в открытом доступе уже есть библиотека и cli инструменты на ее основе, которые реализуют прием и отправку M-Bus датаграмм.
Нам нужен доступ к Raspberry Pi по SSH. И для начала проверим, что наше устройство определяется в сети, получает и отправляет датаграммы. Для этого воспользуемся утилитами в составе библиотеки.
Скачиваем библиотеку и распаковываем архив:
wget https://github.com/rscada/libmbus/archive/master.zip
&& unzip master.zip
&& cd libmbus-masterЕсли требуется, ставим инструменты для сборки:
apt-get install build-essential libtool autoconf m4
Компилируем:
./build.sh
В папке libmbus-master/bin находятся нужные нам утилиты. Утилита mbus-serial-request-data запрашивает данные по умолчанию. Далее мы рассмотрим как указать в датаграмме, какие данные мы хотим запросить. На текущем этапе достаточно формата данных отправляемых по умолчанию.
Вызов утилиты выглядит следующим образом:./mbus-serial-request-data -b 2400 /dev/ttyUSB0 12
где,
-b 2400 ― скорость передачи данных, измеряемая в бодах. Скорости, которые поддерживает ваш счетчик, вы можете найти в его руководстве. У меня это 300 и 2400 бод. Допустимый диапазон согласно протоколу от 300 до 9600 бод.
/dev/ttyUSB0 ― путь к устройству, которое подключено к Raspberry Pi.
12 ― первичный адрес slave-устройства (счетчика).
Здесь стоит немного рассказать про адресацию slave-устройств в m-bus сети. M-Bus определяет два типа адресации: первичный (primary address) и вторичный (secondary address). Воспринимать их можно как логический и уникальный адрес, и реализованы они на канальном и сетевом уровнях соответственно. Первичный адрес принимает значения в диапазоне от 1 до 250 и может быть назначен устройству (если это поддерживается самим устройством) при помощи утилиты mbus-serial-set-address
. Вторичный адрес зашит в устройство (теоретически, если производитель предоставляет такую возможность, тоже может быть изменен) и имеет вид представленный в таблице ниже.
Identification-Nr. |
Manufacturer. (hex.) |
Version (hex.) |
Media (hex.) |
14491001 |
1057 |
01 |
06 |
Разница на практике: использование первичного адреса в датаграмме занимает всего 1 байт и всегда фиксировано в датаграмме. Но при этом количество устройств в сети ограничено до 250.
Master отправляет отдельную датаграмму с CI (control information) полем со значением 52h или 56h, A (address field) полем равным FDh (253) и указанным вторичным адресом в теле датаграммы.
Slave распознает датаграмму, сравнивает вторичный адрес со своим, переходит в «selected state» и отправляет ответ E5h.
Теперь можно отправлять датаграммы на это устройство обращаясь по адресу FDh (253) в A (address field).
После того как коммуникация между master и slave устройством завершена, master должен выйти из «selected state» и отправить датаграмму с CI полем 40h.
Вторичный адрес требует больше накладных расходов, но с его помощью можно иметь в сети большее количество устройств, избежав коллизии первичных адресов. Еще он служит для задания первичного адреса для устройства. Так как в моем случае сеть состоит из одного устройства ― будет использоваться первичный адрес.
Возвращаемся к нашим счетчикам. Скорее всего ваше устройство не инициализировано и мы должны ему присвоить первичный адрес. Для этого нам нужно сначала узнать его вторичный адрес. Здесь есть два варианта: посмотреть инструкцию или найти нужные данные на корпусе устройства и сформировать адрес, как указано таблице. Или же воспользоваться одной из набора утилит.
./mbus-serial-scan-secondary -b 2400 /dev/ttyUSB0
Вывод:
Found a device on secondary address 58740397A511410C [using address mask 5FFFFFFFFFFFFFFF]
Зная вторичный адрес, устанавливаем первичный адрес 12 (или любое другое число в диапазоне):
./mbus-serial-set-address -b 2400 /dev/ttyUSB0 58740397A511410C 12
Вывод:
Set primary address of device to 12
Убедимся, что устройство доступно по установленному адресу:
./mbus-serial-scan -b 2400 /dev/ttyUSB0
Вывод:
Found a M-Bus device at address 12
И наконец-то, запрашиваем данные у счетчика:
./mbus-serial-request-data -b 2400 /dev/ttyUSB0 12
Получаем xml на выходе
<?xml version="1.0" encoding="ISO-8859-1"?>
<MBusData>
<SlaveInformation>
<Id>58630287</Id>
<Manufacturer>DME</Manufacturer>
<Version>65</Version>
<ProductName></ProductName>
<Medium>Heat: Inlet</Medium>
<AccessNumber>5</AccessNumber>
<Status>00</Status>
<Signature>0000</Signature>
</SlaveInformation>
<DataRecord id="0">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Unit>Reserved (0x0d)</Unit>
<Value>12667</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
<DataRecord id="1">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Tariff>1</Tariff>
<Device>0</Device>
<Unit>Reserved (0x0d)</Unit>
<Value>0</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
<DataRecord id="2">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Tariff>2</Tariff>
<Device>0</Device>
<Unit>Reserved (0x0d)</Unit>
<Value>0</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
<DataRecord id="3">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Unit>Volume (m m^3)</Unit>
<Value>1476014</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
<DataRecord id="4">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Unit>Power (W)</Unit>
<Value>0</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
<DataRecord id="5">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Unit>Volume flow (m m^3/h)</Unit>
<Value>0</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
<DataRecord id="6">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Unit>Flow temperature (1e-1 deg C)</Unit>
<Value>207</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
<DataRecord id="7">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Unit>Return temperature (1e-1 deg C)</Unit>
<Value>208</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
<DataRecord id="8">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Unit>Operating time (days)</Unit>
<Value>1323</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
<DataRecord id="9">
<Function>Instantaneous value</Function>
<StorageNumber>0</StorageNumber>
<Unit>Operating time (hours)</Unit>
<Value>0</Value>
<Timestamp>2021-06-08T11:36:35Z</Timestamp>
</DataRecord>
</MBusData>
Пишем код
Аппаратная часть сконфигурирована ― теперь начнем писать код.
Несколько слов на тему "почему Go"?
В коммерческой среде выбор языка и технологии диктуется множеством факторов, от таких, как наличие у вас в штате разработчиков с определенной экспертизой, и до объема легаси кода и целесообразности применения технологии в целом. В домашнем проекте нет таких ограничений. Поэтому, в академических целях, я использовал интересный мне язык за его соотношение усилия программиста/производительность. Можно писать и на чистом С или С++, если это целесообразно.
Мы будем использовать кросс-компилятор внутри docker-контейнера. Кросс-компиляция, потому что рабочая машина собирает код быстрее, чем Raspberry Pi. Docker ― чтобы изолировать среду сборки и не устанавливать инструменты для кросс-компиляции на хостовую машину.
# Берем базовый образ из официального репозитория Golang.
FROM golang:1.16.2 AS buld_heatmeter
# Устанавливаем silent mode для пакетного менеджера (все значения будут установлены по умолчанию).
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update
RUN apt-get upgrade -y
# Устанавливаем утилиты и инструменты сборки.
RUN apt-get install gcc-arm-linux-gnueabihf -y sudo -y make -y git -y autoconf -y libtool -y
RUN apt-get clean
# Отключаем запрос пароля при использовании sudo.
RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Создаем отдельного пользователя.
RUN useradd -rm -d /home/debian -s /bin/bash -g root -G sudo debian
USER debian
ARG HOME_DIR="/home/debian"
WORKDIR ${HOME_DIR}
# Копируем содержимое (предполагается, что исходные коды и скрипты сборки лежат в папке с Dockerfile).
COPY . heatmeter
# Меняем владельца созданной папки.
RUN sudo chown -R debian:root heatmeter
WORKDIR ${HOME_DIR}/heatmeter
# Запускаем скрипт сборки.
RUN bash build.sh
# Создаем отдельную фазу сборки (build stage), куда копируем артефакт сборки.
# При сборке контейнера указываем папку, куда сохранить артефакт на локальной машине.
# Пример: env DOCKER_BUILDKIT=1 docker build --output out .
# https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs
FROM scratch AS export-stage
COPY --from=buld_heatmeter /home/debian/heatmeter/heatmeter .
В строке 33 идет вызов скрипта сборки RUN bash build.sh:
#!/bin/bash
# Клонируем репозиторий libmbus библиотеки в папку build.
BUILD_DIR=./build
if [ ! -d "$BUILD_DIR" ]; then
REPO_URL="https://github.com/rscada/libmbus"
git clone $REPO_URL $BUILD_DIR
fi
pushd ${BUILD_DIR}
# Редактируем build.sh скрипт сборки библиотеки.
# 1. Отключаем сборку динамических библиотек. Линковать библиотеку с нашим приложением будем статически.
sed -i 's/^.*\&\& \.\/configure$/& --enable-shared=no/' build.sh
# 2. Указываем параметры кросс-компиляции https://gcc.gnu.org/onlinedocs/gccint/Configure-Terms.html
sed -i 's/^.*\&\& \.\/configure --enable-shared=no$/& --build=x86_64-ubuntu-linux --host=arm-linux-gnueabihf /' build.sh
# Собираем библиотеку.
./build.sh
popd
# Собираем наше приложение.
# Обязательно указываем компилятор и линковщик для cgo https://golang.org/cmd/cgo/.
# Указываем под какую операционную систему и архитектуру делать сборку.
env CC="arm-linux-gnueabihf-gcc" LD="arm-linux-gnueabihf-ld" GOOS=linux GOARCH=arm GOARM=5 CGO_ENABLED=1 go build -v .
Теперь вызовом команды env DOCKER_BUILDKIT=1 docker build --output out .
можно собрать наше приложение. Артефакт сборки будет находиться в папке out в нашей рабочей директории на хостовой машине.
Создадим пакет mbus, который будет состоять из двух файлов. Файл measurement.go
содержит структуру с полями показаний и функцию для десериализации xml, который сформирует библиотека.
measurement.go
package mbus
import (
"encoding/xml"
"math"
"strconv"
)
type Calories uint16
type CubicMetre float32
type Watt uint16
type CubicMetresPerHour float32
type Celsius uint64
type Seconds uint64
type Measurement struct {
Energy Calories
Volume CubicMetre
Power Watt
VolumeFlow CubicMetresPerHour
FlowTemp Celsius
ReturnTemp Celsius
OperatingTime Seconds
ErrorTime Seconds
}
func (m *Measurement) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
result := struct {
Records []struct {
Value string `xml:"Value"`
} `xml:"DataRecord"`
}{}
if err := d.DecodeElement(&result, &start); err != nil {
return err
}
{
value, err := strconv.ParseFloat(result.Records[0].Value, 32)
if err != nil {
return err
}
m.Energy = Calories(math.Round(value))
}
{
value, err := strconv.ParseFloat(result.Records[3].Value, 32)
if err != nil {
return err
}
m.Volume = CubicMetre(value)
}
{
value, err := strconv.ParseFloat(result.Records[4].Value, 32)
if err != nil {
return err
}
m.Power = Watt(value)
}
{
value, err := strconv.ParseFloat(result.Records[5].Value, 32)
if err != nil {
return err
}
m.VolumeFlow = CubicMetresPerHour(value)
}
{
value, err := strconv.ParseFloat(result.Records[6].Value, 32)
if err != nil {
return err
}
m.FlowTemp = Celsius(math.Round(value))
}
{
value, err := strconv.ParseFloat(result.Records[7].Value, 32)
if err != nil {
return err
}
m.ReturnTemp = Celsius(math.Round(value))
}
{
value, err := strconv.ParseFloat(result.Records[8].Value, 32)
if err != nil {
return err
}
m.OperatingTime = Seconds(value)
}
{
value, err := strconv.ParseFloat(result.Records[9].Value, 32)
if err != nil {
return err
}
m.ErrorTime = Seconds(value)
}
return nil
}
Файл reader.go
, который содержит структуру Reader
, разберем подробнее (для удобства чтения сделаем это частями). Методы структуры внутри будут вызывать C-функции. Для этого в Golang есть механизм биндинга. С ним рекомендовано ознакомиться.
В начале у нас идет содержимое в комментариях, после которого стоит преамбула import "C"
. В этом месте может содержаться код на Си и директива #cgo, которая позволяет задать настройки для компилятора или линковщика.
package mbus
/*
// Указываем линковщику, где находится библиотека mbus.
// Также линкуем Си библиотеку math, которая нужна mbus библиотеке.
#cgo LDFLAGS: -L../build/mbus/.libs/ -lmbus -lm
// Указываем компилятору, где искать заголовочные файлы.
#cgo CFLAGS: -I../build
// Подключаем файл с нужными нам функциями.
#include "mbus/mbus-serial.h"
// Cи умеет неявно конвертировать из void* в конкретный тип (в данном случае mbus_frame*).
// void* в Go представлен как unsafe.Pointer.
// И так как в Go нет возможности конвертировать unsafe.Pointer в C.mbus_frame*,
// мы сделали для этого небольшую функцию-обертку.
mbus_frame* to_frame(void* p){
return p;
}
*/
import "C"
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"unsafe"
"golang.org/x/net/html/charset"
)
type Reader struct {
// Дескриптор, который инициализировала библиотека.
// https://github.com/rscada/libmbus/blob/master/mbus/mbus-protocol-aux.h#L86
handle *C.mbus_handle
// Первичный адрес slave-устройства.
address C.int
}
Метод Open
инициализирует дескриптор, открывает и настраивает порт. Устанавливает handshake.
func (r *Reader) Open(device string, primaryAddress uint8, baudrate uint16) error {
dev := C.CString(device)
defer C.free(unsafe.Pointer(dev))
// Выделяем память для структур предназначенных для работы с последовательным портом.
r.handle = C.mbus_context_serial(dev)
// Открываем и настраиваем порт.
// https://en.wikipedia.org/wiki/Serial_port#Settings
if C.mbus_connect(r.handle) != 0 {
return fmt.Errorf("failed to setup connection to M-bus gateway: %s", device)
}
// Устанавливаем скорость обмена данными.
if C.mbus_serial_set_baudrate(r.handle, C.long(baudrate)) != 0 {
return fmt.Errorf("failed to set baud rate: %d", baudrate)
}
r.address = C.int(primaryAddress)
// Установка handshake. Отправляется датаграмма с control field SND_NKE (40h).
// Если slave успешно принял SND_NKE, он отвечает датаграммой из одного символа E5h.
if C.mbus_send_ping_frame(r.handle, r.address, 1) != 0 {
return fmt.Errorf("failed to setup handshake for address: %d", primaryAddress)
}
return nil
}
Close
закрывает порт и освобождает выделенную память.
func (r *Reader) Close() error {
defer C.mbus_context_free(r.handle)
if C.mbus_disconnect(r.handle) != 0 {
return errors.New("failed to disconnect")
}
return nil
}
И сама функция ReadData
, которая получает данные и возвращает структуру mbus.Measurement
.
func (r *Reader) ReadData() (*Measurement, error) {
// Функция отправляет датаграмму с control information кодом равным 50h.
// Это делает сброс application layer к значениям по умолчанию.
// Так же есть опциональный параметр после этого кода — application reset subcode.
// Этот параметр определяет какие данные будут отправлены при последующем запросе.
// Именно его мы будем использовать. Установка subcode равным 50h (instant values) при следующем запросе будет возвращать нужные значение.
// Список subcod'ов: https://m-bus.com/documentation-wired/06-application-layer#application-reset-subcode-
// Какие именно значение для 50h (instant values) возвращаются я посмотрел в руководстве к своему счетчику.
subcode := 0x50
if C.mbus_send_application_reset_frame(r.handle, r.address, C.int(subcode)) == -1 {
return nil, fmt.Errorf("failed to send reset frame: %s", C.GoString(C.mbus_error_str()))
}
// Получаем ответ и проверяем его на ошибки.
var reply C.mbus_frame
ret := C.mbus_recv_frame(r.handle, &reply)
if ret == C.MBUS_RECV_RESULT_TIMEOUT {
return nil, fmt.Errorf("failed to get a reply from device: timeout expired")
}
if C.mbus_frame_type(&reply) != C.MBUS_FRAME_TYPE_ACK {
return nil, fmt.Errorf("unexpected frame type, receiving ACK telegram is failed")
}
// Делаем запрос на получение данных и сохраняем полученные в ответе датаграммы.
const maxFrames C.int = 16
if C.mbus_sendrecv_request(r.handle, r.address, &reply, maxFrames) != 0 {
C.mbus_frame_free(C.to_frame(reply.next))
return nil, fmt.Errorf("failed to send/receive M-Bus request: %s", C.GoString(C.mbus_error_str()))
}
// Десериализуем датаграммы в объект mbus_frame_data.
var frameData C.mbus_frame_data
if C.mbus_frame_data_parse(&reply, &frameData) != 0 {
return nil, fmt.Errorf("M-bus data parse error: %s", C.GoString(C.mbus_error_str()))
}
// Сериализуем данные в xml формат.
xmlOutput := C.mbus_frame_data_xml_normalized(&frameData)
defer C.free(unsafe.Pointer(xmlOutput))
if frameData.data_var.record != nil {
defer C.mbus_data_record_free(frameData.data_var.record)
}
if xmlOutput == nil {
return nil, fmt.Errorf("failed to generate XML output of the frame: %s", C.GoString(C.mbus_error_str()))
}
// Десереализуем xml в структуру mbus.Measurement.
reader := bytes.NewReader([]byte(C.GoString(xmlOutput)))
decoder := xml.NewDecoder(reader)
decoder.CharsetReader = charset.NewReaderLabel
var measurement Measurement
err := decoder.Decode(&measurement)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal XML output: %w", err)
}
return &measurement, nil
}
Создадим файл main.go
, и напишем код для вывода полученных значений в консоль.
package main
import (
"fmt"
"heatmeter/mbus"
"log"
"os"
"strconv"
)
func main() {
var device, mbusIDVar, baudrateVar string
if device = os.Getenv("HM_DEVICE"); device == "" {
log.Fatal("HM_DEVICE variable is not set")
}
if mbusIDVar = os.Getenv("HM_MBUS_ID"); mbusIDVar == "" {
log.Fatal("HM_MBUS_ID variable is not set")
}
mbusID, err := strconv.Atoi(mbusIDVar)
if err != nil {
log.Fatalf("Wrong mbus ID: %s, %s", mbusIDVar, err)
}
if baudrateVar = os.Getenv("HM_BAUDRATE"); baudrateVar == "" {
log.Fatal("HM_BAUDRATE variable is not set")
}
baudrate, err := strconv.Atoi(baudrateVar)
if err != nil {
log.Fatalf("Wrong baudrate: %s, %s", baudrateVar, err)
}
var reader mbus.Reader
err = reader.Open(device, uint8(mbusID), uint16(baudrate))
defer reader.Close()
if err != nil {
log.Fatal(err)
}
measurement, err := reader.ReadData()
if err != nil {
log.Fatal("Unable to get measurement: ", err)
}
errorHoursStr := strconv.Itoa(int(measurement.ErrorTime) / 3600)
operatingDaysStr := strconv.Itoa(int(measurement.OperatingTime) / 3600 / 24)
flowTempStr := strconv.Itoa(int(measurement.FlowTemp))
returnTempStr := strconv.Itoa(int(measurement.ReturnTemp))
powerStr := strconv.Itoa(int(measurement.Power))
energyStr := fmt.Sprintf("%.3f", float32(measurement.Energy)/1000)
volumeStr := fmt.Sprintf("%.3f", measurement.Volume)
volumeFlowStr := fmt.Sprintf("%.3f", measurement.VolumeFlow/1000)
fmt.Printf("\n Energy: %s, volume: %s, volume flow: %s, power: %s, flow temperature: %s, return temperature: %s, operating days: %s, error hours: %s",
energyStr,
volumeStr,
volumeFlowStr,
powerStr,
flowTempStr,
returnTempStr,
operatingDaysStr,
errorHoursStr)
}
Собираем это командой и получаем вывод в консоль:Energy: 12.667, volume: 1476.014, volume flow: 0.000, power: 0, flow temperature: 22, return temperature: 23, operating days: 1330, error hours: 0
Примечание: так как сейчас у меня кран перекрыт, то значения объема и затраченной энергии равны 0. В принципе, на этом можно было бы и остановиться — показания теперь можно снять, сидя за компьютером, подключившись к Raspberry Pi по SSH. Но все-таки хочется минимального вмешательства со стороны человека.
Подача показаний на сайт теплосетей
Так как сайты локального поставщиков услуг отличаются — нет практического смысла описывать детали реализации. Все сводится к запросу и парсингу веб-страницы. Отправке POST запросов с содержимым формы для авторизации и получения сессионного токена, а также отправке POST запроса с данными показаний.
Telegram-бот
Для удобного получения уведомлений о передаче показаний или возникших ошибках было решено использовать Telegram-бота. Создадим пакет logger и в нем файл telegram.go, в котором реализуем интерфейс io.Writer
для возможности установки его в качестве вывода для стандартного logger'a. Здесь нам понадобятся bot token и идентификатор чата между вами и ботом, чтобы программа могла отправлять боту сообщения. Узнать идентификатор чата можно следующим образом:
Начать чат с ботом.
Вызвать GET метод. Пример: https://api.telegram.org/bot<your bot's token>/getUpdates
В полученном ответе result[0].messsage.chat.id — нужный нам идентификатор чата.
Пример ответа
{
"ok": true,
"result": [
{
"update_id": 999999999,
"message": {
"message_id": 12,
"from": {
"id": 8888888888,
"is_bot": false,
"first_name": "John",
"username": "john_doe"
},
"chat": {
"id": 191191191,
"first_name": "John",
"username": "john_doe",
"type": "private"
},
"date": 1624002263,
"text": "/start",
"entities": [
{
"offset": 0,
"length": 6,
"type": "bot_command"
}
]
}
}
]
}
package logger
import (
"fmt"
"log"
"os"
tgram "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type TelegramLogger struct {
bot *tgram.BotAPI
chatID int64
}
func NewTelegram(token string, chatID int64) (*TelegramLogger, error) {
bot, err := tgram.NewBotAPI(token)
if err != nil {
return nil, err
}
bot.Debug = true
log.Printf("Authorized on account %s", bot.Self.UserName)
logger := TelegramLogger{bot, chatID}
return &logger, nil
}
func (t *TelegramLogger) Write(data []byte) (int, error) {
str := string(data)
m := tgram.NewMessage(t.chatID, str)
// Отправляем сообщение в чат с ботом.
_, err := t.bot.Send(m)
// Дублируем сообщение в стандартный поток вывода ошибок.
n, stdErr := os.Stderr.WriteString(str)
return n, fmt.Errorf("%v: %v", err, stdErr)
}
В начале функции main
в файле main.go устанавливаем вывод для стандартного logger'а.
logger, err := logger.NewTelegram("<your bot's token>", 191191191)
if err != nil {
log.Fatal("unable to create bot: ", err)
}
log.SetOutput(logger)
Теперь все сообщения, которые будут записаны в стандартный logger, также будут отправлены в чат с ботом.
Wi-Fi реле
Было решено не держать Raspberry Pi в режиме 24/7. Задача, которую она выполняет, длится не больше 1-5 минут один раз в месяц. На это также есть еще как минимум две причины:
На просторах интернета была информация о нагревании плат и нестабильной ее работе при длительном uptime'е.
В случае перебоев питания, нужно решить проблему с UPS'ом.
Для решения этой задачи будет использоваться Wi-Fi реле от производителя Sonoff, которое будет по заданному графику включать и отключать питание (настраивается через мобильное приложение).
Если бы плата поддерживала WoL, то можно было бы эту задачу возложить на роутер (в моем случае Mikrotik), сделав отправку нужных пакетов по расписанию. Возможно, в будущем эта возможность будет реализована в новых версиях.
Заметка по выбору реле
Если вы решите приобрести реле этого или другого производителя, посмотрите, чтобы оно поддерживало какой-либо из видов RPC. У Sonoff это DIY режим, который позволяет сделать REST-запрос. Таким образом реле можно включить тем же скриптом с роутера. Это даст возможность настройки более гибкого графика включения под ваши нужды. Моя модель не поддерживает это из коробки и требует сторонней прошивки.
Запускаем наше приложение как службу
Для начала скопируем собранное приложение c рабочей машины на Raspberry Pi:
scp <your build dir>/out/heatmeter pi@<your Raspberry IP>:/home/pi
На Raspberry Pi переместим приложение:
sudo mv heatmeter /usr/local/bin
В целях безопасности, создадим отдельного пользователя для нашей службы без домашней директории и возможности зайти в систему:
sudo useradd -r -s /bin/false --no-create-home heatmeter
Изменим владельца и права для нашего приложения:
sudo chown heatmeter:heatmeter /usr/local/bin/heatmeter
sudo chmod 500 /usr/local/bin/heatmeter
-
Напишем файл конфигурации нашего модуля
/etc/systemd/system/heatmeter.service
.
Наше приложение должно автоматически запуститься при старте операционной системы, выполнить свою работу и дальше систему можно выключить.[Unit] Description=Heatmeter report submitter # Ждем, пока поднимется сеть. After=network-online.target # Ждем синхронизацию времени, чтобы в логах отображалось корректное время. Requires=time-sync.target [Install] # Уровень инициализации ОС, на котором запустится наша служба # https://wiki.debian.org/systemd/CheatSheet WantedBy=multi-user.target [Service] Environment="HM_DEVICE="/dev/ttyUSB0"" Environment="HM_MBUS_ID=12" Environment="HM_BAUDRATE=2400" # Планируем выключение системы через 10 минут. # Время выбрано с запасом, чтобы система успела себя потушить. # После этого, через установленный интервал, # планировщик Wi-Fi реле отключит питание. ExecStartPre=shutdown -P +10 #Запускаем наше приложение. ExecStart=/usr/local/bin/heatmeter
Меняем права для файла конфигурации:
sudo chmod 644 /etc/systemd/system/heatmeter.service
Запустим команду daemon-reload, чтобы systemd подтянул наши изменения:
systemctl daemon-reload
Делаем активной нашу службу при следующем запуске системы:
systemctl enable heatmeter.service
Эпилог
На этом все. Теперь один раз в месяц Wi-Fi реле будет запускать Raspberry Pi. После будет стартовать наше приложение и снимать показания.
Спасибо, если вы дочитали эту публикацию до конца. Надеюсь, для кого-то эта информация была полезной. Полный код проекта доступен по ссылке.
Использованные материалы
Комментарии (17)
jackkum
23.06.2021 12:05Как из пушки по воробьям ))
ESP8266/32yavdoshenko
23.06.2021 12:23+2И что? Если скинуть ссылку на любой девайс этого достаточно, чтобы получить что-то подобное в результате? Может ссылку на реализацию подобной задачи с использованием подобного девайса? И даже если такая есть, это вообще не повод не делать свое, на чем угодно и не повод не поделиться этим с сообществом.
jackkum
23.06.2021 12:41+1Я не имел ничего против статьи, кому-то она может даже будет полезна. Я больше о том, что цена данной реализации очень высока, по моим примерным расчетам 3-5 т.р., если для Вас это небольшая сумма для данной задачи, то никто не будет против. Я лишь дал наводку, куда копать, чтобы сделать это в пределах 1т.р.
Позже скину список модулей для сборки на есп.
yavdoshenko
23.06.2021 12:46+3Мне кажется, что есть миллион способов достигнуть такого же результата. Но в названии статьи уже есть то, что было что-то на руках и можно сделать вот это.
Мне лично вообще нравятся такого рода статьи, не потому что они делают конкретно вот это, а то что читая как автор решает тот или иной шаг я могу взять что-то для себя. Именно в этом самая большая ценность.
valentin13 Автор
23.06.2021 13:44+1Тут важный момент, что я использовал готовую библиотеку по работе с протоколом M-Bus. Если есть тулчейн, чтобы собрать ее под ESP8266/32 — то было бы здорово. И тогда, если говорить о серийном производстве, например, это имело бы смысл. А так прийдется самому писать реализацию по работе с протоколом (или поискать готовую на Python). К тому же, если у вас в квартире еще 2 счетчика на воду и один на газ. А если еще и электросчетчик поддерживает технологию — это уже 5 счетчиков. С Raspberry платой тут проблем возникнуть не должно.
Вообще, как заметили в комментарии, я использовал то железо, которое уже было)) За исключением купленного M-Bus конвертора.jackkum
23.06.2021 15:57Вот гугл выдал первый же результат, правда не совсем python, но с кодом и инструкцией.
valentin13 Автор
23.06.2021 16:31+1Обратите, пожалуйста, внимание, что по указанной вами ссылке используется протокол для коммуникации ModBus по RS485, у которого совсем другой вольтаж. В статье описан другой протокол — M-Bus.
jackkum
23.06.2021 17:09+1Действительно, невнимательно прочитал.
Можно попробовать использовать микропитон, конвертер M-Bus в ttl, судя по статье, можно установить либы для микропитона, например эту, но я такого ещё не пробовал делать.
В отпуске доразведу плату на esp8266, пока они совсем не подорожали, и смогу проверить установку либы в микропитон.
obrianw
25.06.2021 09:33Для водой и газосчетчиков уже есть решение (и открытый проект) watering.ru
Там же на сайте ссылки на магазины (где купить) и на github (собрать самому, если хочется)
valentin13 Автор
25.06.2021 09:39А для тепловых счетчиков — нет)
Вообще, ваш комментарий похож на рекламу, если честно. Я зашел на сайт и не смог найти решения, о которых вы говорите.и на github (собрать самому, если хочется)
Можете скинуть ссылку на github?obrianw
25.06.2021 21:05Прошу прощения, писал с мобилы и как всегда злобная клавиатура от гугла подменила...
вместо watering.ru => waterius.ru
ссылка на git => https://github.com/dontsovcmc/waterius
keydon2
23.06.2021 19:00+1Так как сайты локального поставщиков услуг отличаются — нет практического смысла описывать детали реализации. Все сводится к запросу и парсингу веб-страницы. Отправке POST запросов с содержимым формы для авторизации и получения сессионного токена, а также отправке POST запроса с данными показаний.
Было бы полезно поделиться и этим, чтобы не ваять второй раз велосипед.
Мне например не хочется гробить вечер на парсинг mos.ru. А пару часов на ревью кода нашел бы, заодно мб MR прислал бы.valentin13 Автор
25.06.2021 09:33С большой уверенностью могу сказать, что в этой части проекта не получится вынести общую кодовую базу. При запросе страницы, вы получите совсем другой HTML. Способ авторизации вероятно будет отличаться. Поля формы для подачи, скорее всего, тоже будут отличаться. Да и количество снимаемых параметров, возможно, тоже. На просторах интернета не сложно найти туториалы по разбору сайта, например.
Можете посмотреть на пример кода в репозитории. Я использовал пакет html для парсинга веб-страниц.Z2K
08.07.2021 05:46-1Мне не лень раз в месяц счетчики сфоткать. И чтобы занести три цифры электро, вода, газ в телеграмм нужно буквально 1 минута, зачем такое ваять?? Хотя можно даже фотки через вайбер отсылать даже цифры вводить не надо. :)) А вот пример в посте, чтобы автоматизировать какие-то простейшие вещи сколько надо усилий. Реально много, посколько все проблематично. Отпарсить веб-страницу чтобы передать одну цифру. Жуть.
yavdoshenko
Лонгрид получился. В статье материала, что хватило бы на несколько статей.
valentin13 Автор
Согласен, небольшие статьи легче читаются.