[Программирование, Go, Разработка на Raspberry Pi, Умный дом] У нас был один счетчик тепла с M-Bus, RaspberryPi, M-Bus to USB конвертор, Telegram-бот и возможность писать на Go

Автор Сообщение
news_bot ®

Стаж: 6 лет 3 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
23-Июн-2021 13:32

НачалоВсем привет. В этой статье я расскажу, как я упростил себе жизнь, автоматизировав подачу показаний счетчика. После переезда в новое жилье появилась возможность установить счетчик потребления тепла, который в теории (в моем случае, и на практике тоже) должен был сократить расходы на оплату услуг тепловых сетей. После установки прибора нужно каждый месяц в определенный период времени вносить показания тепла в личный кабинет на сайте тепловых сетей. Мой счетчик находится в общем коридоре в фальшстене, доступ к которому осуществляется через ревизионную рамку. В целом ― это неудобный процесс. Стоит отметить, что в странах Европы этот процесс часто автоматизирован на уровне самого поставщика тепловых услуг. Также, в наших широтах я встречал предложение от компаний, которые обслуживают ОСМД, установки такой системы на уровне подъезда или дома. Но так как это не мой случай, меня не покидала мысль, что этот процесс можно возложить на вычислительную машину. В конце концов, как звучал когда-то рекламный слоган IMB:
Machines should work; people should think.― IBM
Извините, данный ресурс не поддреживается. :( Ближе к делуКакого результата хочется достичь:
Что у нас есть из железа: Набор изображен на картинке. Слева блок питания, подключенный через реле. В центре сам Raspberry Pi. Справа M-Bus на USB конвертор (белый шнур идет к счетчику тепла).
Из программной части будем использовать: Программную часть можно условно разделить на две составляющих. Первая ― это чтение данных из счетчика, вторая ― подача данных на сайт теплосетей.M-BusM-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 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.)1449100110570106 Разница на практике: использование первичного адреса в датаграмме занимает всего 1 байт и всегда фиксировано в датаграмме. Но при этом количество устройств в сети ограничено до 250.
Примечание: на картинке указан адрес FDh (253) - это зарезервированное значение, которое значит, что коммуникация осуществляется через вторичный адрес. Значения 254 и 255 обозначают широковещательную рассылку. 251 и 252 ― зарезервированы на будущее. Значения же от 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 метод. Пример: [url=https://api.telegram.org/bot%3Cyour%20bot's%20token%3E/getUpdates]https://api.telegram.org/bot<your bot's token>/getUpdates[/url]
  • В полученном ответе 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. После будет стартовать наше приложение и снимать показания.Спасибо, если вы дочитали эту публикацию до конца. Надеюсь, для кого-то эта информация была полезной. Полный код проекта доступен по ссылке. Использованные материалы:
http://www.rscada.se/libmbus/index.php?lang=en
https://m-bus.com/documentation
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_programmirovanie (Программирование), #_go, #_razrabotka_na_raspberry_pi (Разработка на Raspberry Pi), #_umnyj_dom (Умный дом), #_go, #_golang, #_raspberrypi, #_iot, #_programmirovanie (
Программирование
)
, #_go, #_razrabotka_na_raspberry_pi (
Разработка на Raspberry Pi
)
, #_umnyj_dom (
Умный дом
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 09-Май 21:39
Часовой пояс: UTC + 5