У нас был один счетчик тепла с M-Bus, RaspberryPi, M-Bus to USB конвертор, Telegram-бот и возможность писать на Go

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Начало

Всем привет. В этой статье я расскажу, как я упростил себе жизнь, автоматизировав подачу показаний счетчика. После переезда в новое жилье появилась возможность установить счетчик потребления тепла, который в теории (в моем случае, и на практике тоже) должен был сократить расходы на оплату услуг тепловых сетей. После установки прибора нужно каждый месяц в определенный период времени вносить показания тепла в личный кабинет на сайте тепловых сетей. Мой счетчик находится в общем коридоре в фальшстене, доступ к которому осуществляется через ревизионную рамку. В целом ― это неудобный процесс. Стоит отметить, что в странах Европы этот процесс часто автоматизирован на уровне самого поставщика тепловых услуг. Также, в наших широтах я встречал предложение от компаний, которые обслуживают ОСМД, установки такой системы на уровне подъезда или дома. Но так как это не мой случай, меня не покидала мысль, что этот процесс можно возложить на вычислительную машину. В конце концов, как звучал когда-то рекламный слоган IBM:

Machines should work; people should think.

― IBM

Ближе к делу

Какого результата хочется достичь:

Что у нас есть из железа:

  1. Счетчик тепла с поддержкой M-Bus

  2. Raspberry Pi 2 с установленной Raspberry Pi OS

  3. M-Bus master устройство

  4. Wi-Fi реле Sonoff s20

Набор изображен на картинке. Слева блок питания, подключенный через реле. В центре сам Raspberry Pi. Справа M-Bus на USB конвертор (белый шнур идет к счетчику тепла).

Из программной части будем использовать:

  1. Библиотеку для чтения M-Bus датаграмм написанную на языке C

  2. Golang

Программную часть можно условно разделить на две составляющих. Первая ― это чтение данных из счетчика, вторая ― подача данных на сайт теплосетей.

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
Выход M-Bus

Также нужно 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. И для начала проверим, что наше устройство определяется в сети, получает и отправляет датаграммы. Для этого воспользуемся утилитами в составе библиотеки.

  1. Скачиваем библиотеку и распаковываем архив:
    wget https://github.com/rscada/libmbus/archive/master.zip
    && unzip master.zip
    && cd libmbus-master

  2. Если требуется, ставим инструменты для сборки:
    apt-get install build-essential libtool autoconf m4

  3. Компилируем:
    ./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.

Примечание: на картинке указан адрес FDh (253) - это зарезервированное значение, которое значит, что коммуникация осуществляется через вторичный адрес. Значения 254 и 255 обозначают широковещательную рассылку. 251 и 252 ― зарезервированы на будущее. Значения же от 1 до 250 напрямую идентифицируют устройство, как первичный адрес.
Примечание: на картинке указан адрес FDh (253) - это зарезервированное значение, которое значит, что коммуникация осуществляется через вторичный адрес. Значения 254 и 255 обозначают широковещательную рассылку. 251 и 252 ― зарезервированы на будущее. Значения же от 1 до 250 напрямую идентифицируют устройство, как первичный адрес.
  1. Master отправляет отдельную датаграмму с CI (control information) полем со значением 52h или 56h, A (address field) полем равным FDh (253) и указанным вторичным адресом в теле датаграммы.

  2. Slave распознает датаграмму, сравнивает вторичный адрес со своим, переходит в «selected state» и отправляет ответ E5h.

  3. Теперь можно отправлять датаграммы на это устройство обращаясь по адресу FDh (253) в A (address field).

  4. После того как коммуникация между 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 и идентификатор чата между вами и ботом, чтобы программа могла отправлять боту сообщения. Узнать идентификатор чата можно следующим образом:

  1. Начать чат с ботом.

  2. Вызвать GET метод. Пример: https://api.telegram.org/bot<your bot's token>/getUpdates

  3. В полученном ответе 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 минут один раз в месяц. На это также есть еще как минимум две причины:

  1. На просторах интернета была информация о нагревании плат и нестабильной ее работе при длительном uptime'е.

  2. В случае перебоев питания, нужно решить пробелму с UPS'ом.

Для решения этой задачи будет использоваться Wi-Fi реле от производителя Sonoff, которое будет по заданному графику включать и отключать питание (настраивается через мобильное приложение).

Если бы плата поддерживала WoL, то можно было бы эту задачу возложить на роутер (в моем случае Mikrotik), сделав отправку нужных пакетов по расписанию. Возможно, в будущем эта возможность будет реализована в новых версиях.

Заметка по выбору реле

Если вы решите приобрести реле этого или другого производителя, посмотрите, чтобы оно поддерживало какой-либо из видов RPC. У Sonoff это DIY режим, который позволяет сделать REST-запрос. Таким образом реле можно включить тем же скриптом с роутера. Это даст возможность настройки более гибкого графика включения под ваши нужды. Моя модель не поддерживает это из коробки и требует сторонней прошивки.

Запускаем наше приложение как службу

  1. Для начала скопируем собранное приложение c рабочей машины на Raspberry Pi:
    scp <your build dir>/out/heatmeter pi@<your Raspberry IP>:/home/pi

  2. На Raspberry Pi переместим приложение:
    sudo mv heatmeter /usr/local/bin

  3. В целях безопасности, создадим отдельного пользователя для нашей службы без домашней директории и возможности зайти в систему:
    sudo useradd -r -s /bin/false --no-create-home heatmeter

  4. Изменим владельца и права для нашего приложения:
    sudo chown heatmeter:heatmeter /usr/local/bin/heatmeter
    sudo chmod 500 /usr/local/bin/heatmeter

  5. Напишем файл конфигурации нашего модуля /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
  6. Меняем права для файла конфигурации:
    sudo chmod 644 /etc/systemd/system/heatmeter.service

  7. Запустим команду daemon-reload, чтобы systemd подтянул наши изменения:
    systemctl daemon-reload

  8. Делаем активной нашу службу при следующем запуске системы:
    systemctl enable heatmeter.service

Эпилог

На этом все. Теперь один раз в месяц Wi-Fi реле будет запускать Raspberry Pi. После будет стартовать наше приложение и снимать показания.

Спасибо, если вы дочитали эту публикацию до конца. Надеюсь, для кого-то эта информация была полезной. Полный код проекта доступен по ссылке.

Использованные материалы:
http://www.rscada.se/libmbus/index.php?lang=en
https://m-bus.com/documentation

Источник: https://habr.com/ru/post/554250/


Интересные статьи

Интересные статьи

Субботний вечер омрачен скандалом - сайт не работает, провайдер негодяй, админы - не специалисты, а сервера - решето. Вызов принят, или почему при всей нелюбви к 1С-Битри...
Как собрать в прямом эфире 17 000 зрителей? Значит, рецепт такой. Берем 15 актуальных IT-направлений, зовем зарубежных спикеров, дарим подарки за активность в чате, и вуа-ля — крупней...
Система логирования ALog первоначально разрабатывалась для использования в серверных приложениях. Первая реализация ALog была выполнена в 2013 году, на тот момент я и под...
От скорости сайта зависит многое: количество отказов, брошенных корзин. Согласно исследованию Google, большинство посетителей не ждёт загрузки больше 3 секунд и уходит к конкурентам. Бывает, что сайт ...
«Двоичные часы» успели и войти в моду, и выйти из неё, и снова стал актуальным перевод двоично-десятичного кода в более удобный для считывания человеком позиционный или семисегментный. Автор ...