-
-
Save mqu/9519e39ccc474f111ffb to your computer and use it in GitHub Desktop.
#!/usr/bin/ruby | |
# encoding: utf-8 | |
# author : Marc Quinton, april 2015 | |
# name : rvitalk : ruby P300 protocol implementation to handle IO to Viessmann heating systems | |
# object : connect to a Viessmann heating system via Optolink adaptator to query internal values. | |
# version : 0.5 - added write mode for commands, P300 constants, | |
# requirements : ruby >= 2.1, ruby-serialport, a serial USB optolink adapter, a Viessman heating system. | |
# licence : MIT | |
# links : https://openv.wikispaces.com/vcontrold ; https://gist.github.com/mqu | |
# Viessman Optolink, serial line parameters are : 4800, 8 E 2, Even parity, 2 bits stop, without Handshake protocol | |
# @baud_rate = 4800 | |
# @data_bits = 8 | |
# @stop_bits = 2 | |
# @parity = SerialPort::EVEN | |
# ------------------------------------------------------------------------------------ | |
# P300 protocol packets details: | |
# ------------------------------------------------------------------------------------ | |
# When data is read: | |
# 41: Telegram start byte | |
# 05: Length of user data (number of bytes between the telegram start byte (0x41) and checksum) | |
# 00: 00 = request, response = 01, 03 = Error | |
# 01: 01 = Read Data, Write Data = 02, 07 = Function Call | |
# XX XX: 2 byte address of the data or procedure | |
# XX: number of bytes expected in the response | |
# XX: CRC=sum total of the values from the 2 bytes (excluding 41) | |
# | |
# If data is to be written: | |
# 41: Telegram start byte | |
# 06: Length of user data (number of bytes between the telegram start byte (0x41) and checksum) | |
# 00: 00 = request, response = 01, 03 = Error | |
# 02: 01 = Read Data, Write Data = 02, 07 = Function Call | |
# XXXX: 2 bytes address of the data or procedure | |
# XX: number of bytes to be written in the | |
# XX: content to be written | |
# XX: CRC=sum total of the values from the 2 bytes (excluding 41) modulo 256 | |
# ------------------------------------------------------------------------------------ | |
# protocol status (error) | |
# status : 0x15: error, 06: OK (ACK), 05: not init, 07=? | |
# - in KW mode, we receive periodicaly 05 (not init) | |
# - when toggleling in P300 protocol, we receive 06 (OK) | |
# - sending packets with CRC error, we receive 0x15 (error) | |
# - some time we receive 0x00 : don't know why. | |
# some variable need to be converted with a factor | |
# temperatures need to be divided by 10 | |
# some variables are formated in little endian (temperature, counters) | |
# address are coded in big endian format. | |
# ------------------------------------------------------------------------------------ | |
# some examples | |
# Example query outside temperature (Vitotronic 333) | |
# Send 41 05 00 01 55 25 02 82 | |
# Receive 06 41 07 01 01 55 25 02 07 01 8D | |
# Answer: 0x0107 = 263 = 26.3 ° | |
# getDeviceID (00F8, 2bytes)-> 20BC | |
# TX: Data: 0x41 0x05 0x00 0x01 0x00 0xf8 0x02 0x00 | |
# RX: Data: 0x06 0x41 0x07 0x01 0x01 0x00 0xf8 0x02 0x20 0xcb 0xee | |
require "serialport" | |
require 'timeout' | |
require "pp" | |
# global variable for debug output traces. | |
$debug=true | |
$debug_level=2 | |
# from vcontrold/framer.c : https://github.com/taupinfada/vcontrold/blob/master/framer.c#L33 | |
# general marker of P300 protocol | |
P300_LEADIN = 0x41 | |
P300_RESET = 0x04 | |
P300_ENABLE = [ 0x16, 0x00, 0x00 ] | |
# message type | |
P300_REQUEST = 0x00 | |
P300_RESPONSE = 0x01 | |
P300_ERROR_REPORT = 0x03 | |
P300X_LINK_MNT = 0x0f | |
# function | |
P300_READ_DATA = 0x01 | |
P300_WRITE_DATA = 0x02 | |
P300_FUNCT_CALL = 0x07 | |
# #define P300X_OPEN 0x01 | |
# #define P300X_CLOSE 0x00 | |
# #define P300X_ATTEMPTS 3 | |
# // response | |
# #define P300_ERROR 0x15 | |
# #define P300_NOT_INIT 0x05 | |
# #define P300_INIT_OK 0x06 | |
# new Exceptions | |
class CRCError < StandardError | |
end | |
class UnsupportedTypeError < StandardError | |
end | |
class APIError < StandardError | |
end | |
class ProtocolError < StandardError | |
end | |
class ApplicationError < StandardError | |
end | |
class UnknownCommand < StandardError | |
end | |
class TimeoutIOError < StandardError | |
end | |
class IOError < StandardError | |
end | |
class Timer | |
@@start = Time.now.to_f | |
def self.time | |
return (Time.now.to_f - @@start).round(4) | |
end | |
def self.reset | |
@@start = Time.now.to_f | |
end | |
end | |
# TODO should use logger facility : https://rubylearning.com/satishtalim/ruby_logging.html | |
class Debug | |
def self.printf level, *args | |
Kernel::printf(*args) if ($debug) && ($debug_level>=level) | |
end | |
end | |
class Array | |
def sum | |
self.inject{|sum,x| sum + x } | |
end | |
def to_hex | |
s=[] | |
self.each {|e| s << sprintf('0x%02x',e.ord)} | |
'[ ' +s.join(', ') + ' ]' | |
end | |
def to_s | |
to_hex | |
end | |
end | |
# manipulate adresses values (optolink format) | |
class Addr | |
# split 16 bits integrer (2 bytes len) as an array of 2 bytes | |
# addr=a1a2 as short -> [ a1, a2 ] as bytes. | |
# ex : 0x00F8 -> [0x00, 0xF8] | |
def self.split addr | |
a1 = (addr & 0xFF00) >> 8 | |
a2 = (addr & 0x00FF) | |
return [a1,a2] | |
end | |
# reverse of split | |
def self.unsplit a1, a2 | |
return a1*256 + a2 | |
end | |
end | |
# handle serial device IO | |
class TTy | |
def initialize _port=nil, _baud_rate=4800, _data_bits=8, _stop_bits=2, _parity=SerialPort::EVEN | |
@baud_rate = _baud_rate | |
@data_bits = _data_bits | |
@stop_bits = _stop_bits | |
@parity = _parity | |
@port=_port | |
raise ApplicationError, sprintf("serial port not found %s", _port) unless File.exists? _port | |
@sp=SerialPort.new(@port, @baud_rate, @data_bits, @stop_bits, @parity) unless @port==nil | |
end | |
def open port | |
@sp = SerialPort.new(port, @baud_rate, @data_bits, @stop_bits, @parity) | |
end | |
def close | |
@sp.close unless @sp==nil | |
@sp=nil | |
end | |
def sync= bool=false | |
@sp.sync=bool | |
end | |
def sync? | |
@sp.sync? | |
end | |
def flush | |
@sp.flush | |
end | |
# read 1 byte from serial | |
# can throw exception : 'write': Input/output error (Errno::EIO) | |
def read | |
begin | |
c=nil | |
# Debug::printf(5, "# TTy::reading ... \n") | |
while c==nil | |
self.flush() | |
[email protected](1) | |
break if c!=nil | |
end | |
# Debug::printf(5, "# TTy::read : 0x%02x\n", c.ord) | |
return c | |
rescue | |
Debug::printf(2, "## TTy::read : IOerror\n") | |
raise IOError, "IO error reading serial" | |
end | |
end | |
# write 1 byte from serial | |
# can throw exception : 'write': Input/output error (Errno::EIO) | |
def write data, trace=false | |
begin | |
if data.is_a? Array | |
# Debug::printf(5, "# TTy::write (array) : %s\n", data.to_s) | |
data.each { |d| self.write(d,false) } | |
elsif data.is_a? String | |
self.write(data.to_i,false) # never tested ... take care | |
elsif data.is_a? Fixnum | |
@sp.putc(data) | |
else | |
raise "unsupported type : " + data.class | |
end | |
rescue | |
Debug::printf(2, "## TTy::write : IOerror\n") | |
raise IOError, "IO error writing serial" | |
end | |
end | |
end | |
# what is a data packet for OptoLink IO ? | |
class Packet | |
def initialize addr, len | |
@start=P300_LEADIN # start byte | |
@plen=5 # len of packet (different pour request and responses) | |
@cmd1=P300_REQUEST # 00=request (host -> Vitoden), 01=response, 03=Error | |
@cmd2=P300_READ_DATA # 01=Read Data, 02=Write Data = 02, 07=Function Call | |
# data | |
@data=nil # for read : address (high byte + low byte) + len (1byte) | |
@len=len # len of data | |
# checksum | |
@crc=nil # CRC from all bytes except start | |
@addr=addr | |
end | |
def size | |
raise "size() method should not be called in Packet class" | |
end | |
# CRC is made with the sum of packet len till data buffer except first byte. | |
def crc | |
sum=(@plen+@cmd1+@[email protected])%256 | |
end | |
# for debug only | |
def to_s | |
self.to_bytes.map {|e| e = sprintf("0x%02x",e)} | |
end | |
# return an array of byte ; will be sent to TTy device. | |
def to_bytes | |
return [@start, @plen, @cmd1, @cmd2, @data, @crc].flatten | |
end | |
end | |
# read request packet : use this class to build a request packet to read : an address with a len. | |
class ReadRequestPacket < Packet | |
def initialize addr, len | |
super addr, len | |
a=Addr::split addr | |
@data=[a[0], a[1], len] | |
@crc=self.crc | |
end | |
# return size of read request packet | |
def size | |
return 5 | |
end | |
# build packet for reading and return it a array of bytes | |
def pack | |
return self.to_bytes | |
end | |
end | |
# read request packet : use this class to build a request packet to read : an address with a len. | |
# samples packets : | |
# get mode (0x2323) -> 1 | |
# TX: Data: 0x41 0x05 0x00 0x01 0x23 0x23 0x01 0x4d | |
# RX: Data: 0x41 0x06 0x01 0x01 0x23 0x23 0x01 0x01 0x50 | |
# set mode 2 | |
# TX: Data: 0x41 0x06 0x00 0x02 0x23 0x23 0x01 0x02 0x51 | |
# RX: Data: 0x41 0x05 0x01 0x02 0x23 0x23 0x01 0x4f | |
# set mode 1 | |
# TX: Data: 0x41 0x06 0x00 0x02 0x23 0x23 0x01 0x01 0x50 | |
# RX: Data: 0x41 0x05 0x01 0x02 0x23 0x23 0x01 0x4f | |
# get reduce_room_temp (0x2307) -> 0x0F -> 15 | |
# TX: Data: 0x41 0x05 0x00 0x01 0x23 0x07 0x01 0x31 | |
# RX: Data: 0x41 0x06 0x01 0x01 0x23 0x07 0x01 0x0f 0x42 | |
# set reduce_room_temp (0x2307) -> 16 (0x10) | |
# TX: Data: 0x41 0x06 0x00 0x02 0x23 0x07 0x01 0x10 0x43 | |
# RX: Data: 0x41 0x05 0x01 0x02 0x23 0x07 0x01 0x33 | |
# [ 0x41, 0x06, 0x00, 0x02, 0x23, 0x02, 0x01, 0x01, 0x2f ] | |
class WriteRequestPacket < Packet | |
# all setting are with a packet size of 1 bytes (may be changed if needed). | |
def initialize addr, value, len=1 | |
super addr, len | |
@plen=6 # len of packet (different pour request and responses) | |
@cmd1=P300_REQUEST # 00=request (host -> Vitoden), 01=response, 03=Error | |
@cmd2=P300_WRITE_DATA # 01=Read Data, 02=Write Data = 02, 07=Function Call | |
a=Addr::split addr | |
@data=[a[0], a[1], len, value] | |
@crc=self.crc | |
end | |
# return size of write request packet | |
def size | |
return 6 | |
end | |
# return an array of byte ; will be sent to TTy device. | |
def to_bytes | |
return [@start, @plen, @cmd1, @cmd2, @data, @crc].flatten | |
end | |
# build packet for reading and return it a array of bytes | |
def pack | |
return self.to_bytes | |
end | |
end | |
# this class will help you decode a response packet from serial. | |
class ResponsePacket < Packet | |
def initialize addr, len | |
super addr, len | |
@plen=self.size # len of packet between 0x41 (P300_LEADIN) and CRC | |
@cmd1=1 # 00=request, 01=response, 03=Error | |
@cmd2=1 # 01=Read Data, 02=Write Data = 02, 07=Function Call | |
@received=nil # received packet | |
end | |
# given data (in bytes) decode response packet | |
def unpack data | |
# firt byte is 0x41 (P300_LEADIN) - packet start. | |
raise ProtocolError, "read error (start byte)" unless P300_LEADIN == data[0].ord | |
raise ProtocolError, "read error (packet len)" unless @plen == data[1].ord | |
raise ProtocolError, "read error (received addr)" unless @addr == Addr::unsplit(data[4].ord, data[5].ord) | |
@received=data | |
@plen=data[1] | |
raise CRCError, "## CRC error" unless check_crc | |
# store collected data | |
# data[6]=number of bytes | |
# data[7..] = read value | |
@data=data[7,@len] | |
case @len | |
when 1 | |
return @data[0].ord | |
when 2 | |
return @data.reverse.join.unpack('S>')[0] | |
when 4 | |
return @data.reverse.join.unpack('l>')[0] | |
end | |
end | |
# CRC is made with the sum of packet len till data buffer except first byte. | |
def crc | |
sum=0 | |
@received[1, @received.size-2].each {|e| sum=sum+e.ord } | |
return sum%256 | |
end | |
# return true is CRC is OK | |
def check_crc | |
self.crc == @received[-1].ord | |
end | |
def unpack_addr | |
d=self.data | |
return Addr::unsplit d[0].ord, d[1].ord | |
end | |
# return result as raw array | |
def raw | |
@received | |
end | |
# data packet | |
def data | |
@received[7,@len] | |
end | |
# return size of a request packet | |
def size | |
return @len+5 | |
end | |
end | |
class WriteResponsePacket < ResponsePacket | |
def initialize addr, len | |
super | |
@cmd2=2 # 01=Read Data, 02=Write Data = 02, 07=Function Call | |
end | |
def size | |
return 5 | |
end | |
# CRC is made with the sum of packet len till data buffer except first byte. | |
def crc | |
sum=0 | |
@received[1, @received.size-2].each {|e| sum=sum+e.ord } | |
return sum%256 | |
end | |
# given data (in bytes) decode response packet | |
def unpack data | |
# firt byte is 0x41 (P300_LEADIN) - packet start. | |
raise ProtocolError, "read error (start byte)" unless P300_LEADIN == data[0].ord | |
raise ProtocolError, "read error (packet len)" unless @plen == data[1].ord | |
raise ProtocolError, "read error (received addr)" unless @addr == Addr::unsplit(data[4].ord, data[5].ord) | |
@received=data | |
raise CRCError, "## CRC error" unless check_crc | |
return true | |
end | |
end | |
# want to send some "command" to Optolink. | |
# - a command store differents parameters (name, addr, size, factor, unit, description) | |
# - with command, you will talk to Optolink via TTy serial class | |
# - you will send and receive packets. | |
# - actualy only P300 protocol is supported. | |
# | |
class Command | |
def initialize addr, size, factor, unit, mode, type, name, descr, enum=nil | |
@addr=addr # address to read in Vito controler | |
@size=size # size in bytes | |
@factor=factor # temperature are read at 1/10 precision : need to divide by this factor to get real value. | |
@unit=unit # unit of data : °C, %, kwh, ... | |
@mode=mode # mode for commande :ro, :rw -> we can write values only for :rw commands. | |
@type=type # data type : :int4, :byte, :short, :addr, :bool, :enum | |
@name=name # name of ressource : ex power, inside_temp, ... | |
@descr=descr # long textual description of ressource. | |
@enum=enum # literal values for mode, and special values | |
end | |
# read some value à specified address (@addr) | |
# - specify size of read, factor for unit. | |
# returns an array of bytes. | |
def read tty | |
req=ReadRequestPacket.new @addr, @size | |
resp=ResponsePacket.new @addr, @size | |
# build packet and send it to tty line. | |
tty.write(req.pack,true) | |
# first byte should be 0x06 (ack) | |
c=tty.read | |
raise ProtocolError, "error : should received 0x06 (ack), but received " . c.ord unless c.ord == 6 | |
# read bytes from TTYusb and collect them in data array | |
data=[] | |
num=resp.size + 3 | |
num.times { data << tty.read } | |
# decode buffer and return as an array of bytes. | |
val = resp.unpack data | |
case @factor | |
when nil | |
# nope | |
else | |
val=val/(1.0*@factor) | |
end | |
# decode won't work for address values | |
case @type | |
when :addr | |
val=resp.unpack_addr | |
when :systime | |
data=resp.data | |
# sample packets : | |
# [ 0x20, 0x15, 0x04, 0x11, 0x06, 0x19, 0x21, 0x26 ] | |
# 0 1 2 3 4 5 6 7 | |
# 0,1 = 20.15 -> 2015 | |
# 2: month = 4=april | |
# 3: day=11 | |
# 4=week day : 0=sunday, 1:monday, ... | |
# 5=hour : 0x19 -> 19h | |
# 6:min : 0x21 -> 21mn | |
# 7:sec : 0x26 -> 26 seconds. | |
# see : https://github.com/taupinfada/vcontrold/blob/master/unit.c#L137 | |
val=sprintf("%02X/%02X/%02X%02X %02X:%02X:%02X ", data[3].ord, data[2].ord, data[0].ord, data[1].ord, data[5].ord, data[6].ord, data[7].ord) | |
when :float | |
val=sprintf("%0.2f", val) | |
when :error | |
# pp resp.data.to_s | |
# sample packet : [ 0xbc, 0x20, 0x15, 0x01, 0x05, 0x01, 0x09, 0x46, 0x27 ] | |
val=resp.data.to_s | |
when :enum | |
if @enum != nil | |
literal=@enum[val] | |
val=sprintf("%d;(%s)", val, literal) | |
end | |
end | |
return { | |
:addr => @addr, | |
:size => @size, | |
:factor=> @factor, | |
:type => @type, | |
:unit => @unit, | |
:name => @name, | |
:descr => @descr, | |
:raw => resp.raw, | |
:data => resp.data, | |
:value => val, | |
:literal => literal | |
} | |
end | |
# this method will allow to write a value at spécified address. | |
# example set mode to off | |
# | |
def write value, tty | |
req=WriteRequestPacket.new @addr, value, @size | |
resp=WriteResponsePacket.new @addr, @size | |
# build packet and send it to tty line. | |
tty.write(req.pack) | |
# first byte should be 0x06 (ack) | |
c=tty.read | |
raise ProtocolError, "Command::write() : error : should received 0x06 (ack), but received " . c.ord unless c.ord == 6 | |
# read bytes from TTYusb and collect them in data array | |
data=[] | |
num=resp.size + 3 | |
num.times { data << tty.read } | |
# decode buffer and return as an array of bytes. | |
return resp.unpack data | |
end | |
end | |
class Viessmann | |
def initialize | |
@tty=nil # tty class for RW (sub-class of SerialPort) | |
@port=nil # serial port (/dev/ttyUSB*) | |
# literals for enums values. | |
@enums = { | |
# may be : 0=only Water Heating; 1=Continuous reduced; 2=constant normal; 3=heat+WH; 4=heat + WH ; 5=off | |
# :mode => ['Standby mode', 'DHW only', 'Heating and hot water'] | |
:mode => ['water heating only', 'continuous reduced', 'constant normal', 'heating + hot water', 'heating + hot water', 'Off'], | |
:switching_valve => ['undefined', 'heating', 'middle position', 'hot water'], | |
} | |
# all supported commands for my device : Viessmann Vitodens 222-W, controler : vitotrol 200A, controler : vitotronic 200H01B (id 0x20CB) | |
# may be command be described in a separate file (vitotronic-20CB.rb) and include it. | |
# you can find an english version (xml) at this address for 20C8 device (share multiple addr with 20CB) : | |
# https://github.com/smbunn/Viessmann-Control/blob/master/vito_20c8_EN.xml | |
@commands = { | |
:deviceid => Command.new(0x00F8, 2, nil, '', :ro, :addr, 'deviceid', 'Device ID'), | |
:indoor_temp => Command.new(0x0896, 2, 10, '°C', :ro, :short, 'indoor_temp', 'Indoor temperature'), | |
:outdoor_temp => Command.new(0x0800, 2, 10, '°C', :ro, :short, 'outdoor_temp', 'Outdoor temperature'), | |
:outdoor_temp_lp => Command.new(0x5525, 2, 10, '°C', :ro, :short, 'outdoor_temp_lp', 'Outdoor temperature low-pass'), | |
:outdoor_temp_smooth => Command.new(0x5527, 2, 10, '°C', :ro, :short, 'outdoor_temp_smooth', 'Outdoor temperature smooth (attenuated)'), | |
:norm_room_temp => Command.new(0x2306, 1, nil, '°C', :rw, :byte, 'norm_room_temp', 'Normal room temperature'), | |
:reduce_room_temp => Command.new(0x2307, 1, nil, '°C', :rw, :byte, 'reduce_room_temp', 'Reduce room temperature'), | |
:boiler_temp => Command.new(0x0802, 2, 10, '°C', :ro, :short, 'boiler_temp', 'Boiler temperature'), | |
:boiler_temp_lp => Command.new(0x0810, 2, 10, '°C', :ro, :short, 'boiler_temp_lp', 'Boiler temperature low-pass'), | |
:boiler_temp_set => Command.new(0x555a, 2, 10, '°C', :ro, :short, 'boiler_temp_set', 'Boiler temperature setpoint'), | |
:hot_water_temp => Command.new(0x0804, 2, 10, '°C', :ro, :short, 'hot_water_temp', 'Hot water temperature'), | |
:hot_water_temp_lp => Command.new(0x0812, 2, 10, '°C', :ro, :short, 'hot_water_temp_lp', 'Hot water temperature low-pass'), | |
:hot_water_temp_set => Command.new(0x2544, 2, 10, '°C', :rw, :short, 'hot_water_temp_set', 'Hot water temperature target'), | |
:flow_temp => Command.new(0x080C, 2, 10, '°C', :ro, :short, 'flow_temp', 'Flow temperature'), | |
:return_temp => Command.new(0x080A, 2, 10, '°C', :ro, :short, 'return_temp', 'Return temperature'), | |
# circuit | |
:circuit_flow_temp => Command.new(0x2544, 2, 10, '°C', :ro, :short, 'circuit_flow_temp', 'Circuit flow temperature'), | |
:curve_level => Command.new(0x27d4, 1, nil, 'K', :ro, :byte, 'curve_level', 'heating curve level'), | |
:curve_slope => Command.new(0x27d3, 1, 10, '', :ro, :byte, 'curve_slope', 'heating curve slope'), | |
# :storage_charge_pump => Command.new(0x0845, 1, nil, '', :ro, :byte, 'storage_charge_pump', 'storage charge pump'), | |
# :circulation_pump => Command.new(0x0846, 1, nil, '', :ro, :byte, 'circulation_pump', 'circulation pump'), | |
# :mixer_position => Command.new(0x254C, 1, 2, '%', :ro, :byte, 'mixer_position', 'mixer position'), | |
:mode => Command.new(0x2301, 1, nil, '', :rw, :enum, 'mode', 'Operating mode', @enums[:mode]), | |
:eco_mode => Command.new(0x2331, 1, nil, '', :rw, :bool, 'eco_mode', 'Eco mode (bool)'), | |
:party_mode => Command.new(0x2330, 1, nil, '', :rw, :bool, 'party_mode', 'Party mode (bool)'), | |
:switching_valve => Command.new(0x0a10, 1, nil, '', :ro, :enum, 'switching_valve','switching valve', @enums[:switching_valve]), | |
:starts => Command.new(0x088a, 4, nil, '', :ro, :int4, 'starts', 'burner starts number'), | |
:runtime => Command.new(0x08A7, 4, nil, 's', :ro, :int4, 'runtime', 'burner runtime (s)'), | |
:runtime_h => Command.new(0x08A7, 4, 3600, 'h', :ro, :float, 'runtime_h', 'burner runtime (h)'), | |
:power_pump => Command.new(0x0a3c, 1, 1, '%', :ro, :byte, 'power_pump', 'power pump in %'), | |
:power => Command.new(0xa38f, 1, 2, '%', :ro, :byte, 'power', 'burner power in %'), | |
:flow => Command.new(0x0c24, 2, 1, 'l/h',:ro, :byte, 'flow', 'flow in l/h'), | |
:exhaust_gaz_temp => Command.new(0x0808, 2, 10, '°C', :ro, :short,'exhaust_gaz_temp', 'exhauts gaz temp in °C'), | |
:boiler_output => Command.new(0xa305, 1, 2, '%', :ro, :byte, 'boiler_output', 'boiler output in %'), # not working ... should see hot water flow ? | |
:frost_danger => Command.new(0x2510, 1, nil, '', :ro, :bool, 'frost_danger', 'frost danger'), | |
:system_time => Command.new(0x088E, 8, nil, '', :ro, :systime, 'system_time', 'System Time'), | |
# :error0 => Command.new(0x7507, 9, nil, '', :ro, :error, 'error0', 'error 0'), # errors : 0:, 1:7510, 2:7519, 3:7522, 4:752B, 5:7534, 6:753D, 7:7546, 8:754F, 9:7558 | |
# :error1 => Command.new(0x7510, 9, nil, '', :ro, :error, 'error1', 'error 1'), # errors : 0:, 1:7510, 2:7519, 3:7522, 4:752B, 5:7534, 6:753D, 7:7546, 8:754F, 9:7558 | |
# :conso => Command.new(0x7574, 4, nil, '', :ro, :long, 'conso', 'consomption'), | |
} | |
# from https://github.com/mqu/vitalk/blob/master/vito_parameter.c | |
# { 0x7507, 1, 1, "errors", "Error History (numerisch)", "", P_ERRORS, &read_errors, NULL }, | |
# { 0x7507, 1, 1, "errors_text", "Error History (text)", "", P_ERRORS, &read_errors_text, NULL }, | |
# { 0x6760, 1, 1, "ww_offset", "target Boiler Offset /WW", "K", P_HOT_WATER, &read_ww_offset, NULL }, // Offset Kessel/WW Soll | |
# | |
# { 0x27e6, 1, 1, "power_pump_max", "power pump Maximal", "%", P_CIRCUIT, &read_pp_max, &write_pp_max }, // Pumpenleistung Maximal | |
# { 0x27e7, 1, 1, "power_pump_min", "power pumpMinimal", "%", P_CIRCUIT, &read_pp_min, &write_pp_min }, // Pumpenleistung Minimal | |
# { 0x0a3c, 1, 1, "power_pump", "power pump", "%", P_HYDRAULIC, &read_pump_power, NULL }, // Pumpenleistung | |
end | |
# open serial device for IO. | |
def open port | |
@port=port | |
# close TTy if it was opened. | |
@tty.close unless @tty==nil | |
@tty = TTy.new port | |
@tty.sync=false | |
end | |
# return tu default protocol (KW) at exit | |
# call at exit, but also with interrupt (CTRL-C) | |
def shutdown reason=:exit | |
return if @tty==nil | |
return if reason==:int # from interrupt ; shutdown will be call at exit | |
# return to KW protocol | |
self.proto_toggle(:kw) | |
@tty.flush | |
@tty=nil | |
end | |
# for debug | |
def raw_read | |
@tty.read | |
end | |
# initialising mode and debug | |
# write data to serial ; you can pass [array], "string" or Fixnum as bytes (0x12) | |
def raw_write data | |
# Debug::printf(5, "# raw_write : %s\n", data) | |
@tty.write data | |
end | |
# toggle between 300 (P300) et KW protocol - see https://openv.wikispaces.com/Protokolle | |
# parameter : :p300 or :kw | |
def proto_toggle p | |
case p | |
when :p300 | |
Debug::printf(2, "# toggling to p300 protocol\n") | |
self.raw_write(P300_ENABLE) | |
when :kw | |
Debug::printf(2, "# toggling to kw protocol\n") | |
self.raw_write([P300_RESET]) | |
else | |
raise "proto_toggle() unsupported protocol" | |
end | |
end | |
# toggle to P300 protocol, and try to control responses. | |
# return true on success. | |
# else trigger exception : ProtocolError | |
def toggle p=:p300, _retry=10, timeout=15 | |
count=0 | |
while true | |
self.proto_toggle(:kw) | |
c=self.raw_read() # may be 0=error, 5:ok (ack) | |
break if c.ord==5 | |
printf("# received 0x%02x (waiting for 0x5)\n", c.ord) | |
sleep 2 | |
count+=1 | |
raise ProtocolError, "##!protocol connexion error toggling to KW" unless count < _retry | |
end | |
count=0 | |
self.proto_toggle(:p300) | |
while true | |
c=self.raw_read() | |
break if c.ord == 6 | |
printf("# received 0x%02x (waiting for 0x06)\n", c.ord) | |
sleep 0.1 unless c.ord==0x05 | |
self.proto_toggle(:p300) | |
count+=1 | |
raise ProtocolError, "##! protocol connexion error toggling to P300" unless count < _retry | |
end | |
Debug::printf(2, "# toggled to p300 protocol ...\n") | |
return true | |
end | |
# TTY device for IO | |
def tty | |
return @tty | |
end | |
# return a hash of available commands | |
def commands | |
return @commands | |
end | |
# send a read request to Optolink | |
# returns a hash with values | |
# may throw an Timeout::Error exception on timout. | |
def command_read cmd, timeout=(6.0/10) | |
# printf("\n\ncommand_read %s\n", cmd.to_s) | |
raise "hash key error" unless @commands.key? cmd | |
# setup a timout for reading serial. | |
# if a timeout occures, an exception will be thrown. | |
# time limit to read is about =~ 8/100 ; take biger value for safety | |
status = Timeout::timeout(timeout){ | |
return @commands[cmd].read @tty | |
} | |
end | |
# send a write request to Optolink | |
# may throw an Timeout::Error exception on timout. | |
def command_write cmd, value, timeout=(6.0/10) | |
raise UnknownCommand, "unknow command" unless @commands.key? cmd | |
# setup a timout for reading serial. | |
# if a timeout occures, an exception will be thrown. | |
# time limit to read is about =~ 8/100 ; take biger value for safety | |
status = Timeout::timeout(timeout){ | |
return @commands[cmd].write value, @tty | |
} | |
end | |
end | |
class Main | |
def initialize | |
# viessmann object | |
@v=Viessmann.new | |
end | |
def tty_open expr="/dev/ttyUSB*" | |
# serial port should be connected to /dev/ttyUSB* | |
@expr=expr # unless expr==nil | |
ports=Dir.glob(expr) | |
if ports.size == 0 | |
printf("##! did not found right %s serial\n", expr) | |
raise ApplicationError, "serial port not found" | |
end | |
@v.open(ports[0]) | |
return true | |
end | |
def lock | |
end | |
def unlock | |
end | |
def shutdown reason=:exit | |
Debug::printf(2, "## shutdown reason=%s\n", reason.to_s); | |
@v.shutdown | |
self.unlock | |
end | |
def init_shutdown | |
at_exit { self.shutdown :exit } | |
trap("INT") { self.shutdown :int ; exit} | |
end | |
def toggle mode=:p300 | |
@v.toggle(mode) | |
end | |
def mainloop | |
while true | |
result = main | |
# pp result | |
puts "# sleeping ..." | |
STDOUT.flush | |
sleep 10 | |
end | |
end | |
def mainloop2 | |
begin | |
count=0 | |
while true | |
res = @v.command_read :power | |
printf("%s=%s%s\n", res[:name], res[:value].to_s, res[:unit]) | |
sleep 0.3 | |
count+=1 | |
exit if count > 100 | |
end | |
rescue IOError | |
# could try to recover. | |
printf("##! IO error (%s)\n", Time.now.strftime("%H:%M:%S - %d/%m/%Y")) | |
exit 2 | |
rescue TimeoutIOError | |
printf("##! timeout IO error (%s)\n", Time.now.strftime("%H:%M:%S - %d/%m/%Y")) | |
exit 3 | |
end | |
end | |
def write cmd, value | |
cmd=cmd.to_sym | |
printf("write : %s=%s\n", cmd.to_s, value.to_s) | |
raise UnknownCommand, "unknown command " + cmd.to_s unless @v.commands.key? cmd | |
[email protected]_write cmd, value | |
end | |
def main | |
begin | |
# time for debug purpose. | |
t=Time.now | |
printf("time=%d/%s\n", t.to_i, t.strftime("%H:%M:%S - %d/%m/%Y")) | |
results={} | |
# run all available commands and display "name"="result" to stdout. | |
@v.commands.keys.each do |cmd| | |
begin | |
tries ||= 5 | |
res=nil | |
t0=Time.now | |
res = @v.command_read cmd, timeout=(4.0/10) | |
time=Time.now - t0 | |
case res[:type] | |
when :addr | |
value=sprintf('0x%02x', res[:value]) | |
else | |
value=res[:value].to_s | |
end | |
printf("%s=%s%s\n", res[:name], value, res[:unit]) | |
# collect each results and return to main. | |
results[cmd] = value | |
STDOUT.flush | |
rescue Timeout::Error => e | |
Debug::printf(1, "##! timeout error in running command : %s (%0.f ms) ; retring ... (%s)\n", cmd.to_s, time.to_f*1000.0, Time.now.strftime("%H:%M:%S - %d/%m/%Y")) | |
pp e.backtrace | |
sleep 15 | |
# try to re-open TTY device and reinitialise protocol | |
self.tty_open @expr | |
self.toggle :p300 | |
retry unless (tries -= 1).zero? | |
Debug::printf(1, "##! did not retried this time\n") | |
raise e | |
rescue ProtocolError => e | |
Debug::printf(1, "##! protocol error in running command : %s (%0.f ms) ; retring ... (%s)\n", cmd.to_s, time.to_f*1000.0, Time.now.strftime("%H:%M:%S - %d/%m/%Y")) | |
pp e | |
retry unless (tries -= 1).zero? | |
raise e | |
rescue StandardError => e | |
Debug::printf(1, "##! error in running command : %s (%0.f ms) ; retring ... (%s)\n", cmd.to_s, time.to_f*1000.0, Time.now.strftime("%H:%M:%S - %d/%m/%Y")) | |
pp e | |
retry unless (tries -= 1).zero? | |
raise e | |
end | |
end | |
return results | |
rescue IOError => e | |
printf("##! IO error\n") | |
raise e | |
rescue TimeoutIOError => e | |
printf("##! timeout IO error\n") | |
raise e | |
rescue CRCError => e | |
printf("##! CRC error in packet received (%s)\n", Time.now.strftime("%H:%M:%S - %d/%m/%Y")) | |
raise e | |
rescue ProtocolError => e | |
printf("##! protocol error (%s)\n", Time.now.strftime("%H:%M:%S - %d/%m/%Y")) | |
raise e | |
#rescue UnsupportedTypeError | |
# printf("##! unsupported type exception\n", cmd.to_s) | |
# rescue APIError | |
# printf("##! timeout read error for value : %s\n", cmd.to_s) | |
#else | |
# printf("##! unknown exception\n") | |
# exit 10 | |
ensure | |
# @v.shutdown | |
end | |
end | |
end | |
main=Main.new | |
# initialize shutdown on interrup or normal exit. | |
main.init_shutdown | |
t0=Time.now | |
begin | |
tries ||= 10 | |
# try to open first serial device based on expr /dev/ttyUSB* | |
throw "## can't open TTY device" unless main.tty_open "/dev/ttyUSB*" | |
# toggle into P300 protocol mode | |
main.toggle :p300 | |
# run mainloop or main function | |
# main.main | |
# main.write :mode, 2 # -> error | |
main.write :reduce_room_temp, 13 # OK. | |
# main.write :eco_mode, 1 | |
# main.write "eco_mode", 1 | |
main.write :party_mode, 1 # ??? | |
main.mainloop | |
rescue StandardError => e | |
printf("## catch an exception ; retrying ... (%s)\n", Time.now.strftime("%H:%M:%S - %d/%m/%Y")) | |
pp e unless e==nil | |
puts e.backtrace | |
if (Time.now - t0).to_f > 200 | |
t0=Time.now | |
tries=0 | |
end | |
sleep 30 | |
retry unless (tries -= 1).zero? | |
end | |
exit 0 |
mqu
commented
Apr 6, 2015
- revision 0.2 : added exception error, timeout error and try to handle properly.
revision 0.3 : more robust rescue methods.
rev 0.4 : better exception handling, enums for mode and switching valve, need to complete systime command.
rev 0.5 : added write mode for commands, P300 constants ; write mode=2 do not work (exception). Other cmds seems OK.
Have you been able to successfully change the boiler mode of operation from "off/standby" to "Hot water only" or "Hot water plus heating"? I want to be able to do this in my holiday home during frost periods.
TODO : try Cristal language (very similar to ruby) to get better performances and binary standalone application.
for smbunn : I am not running this application now ; but, I should. Don't remember exactly for all supported commands. I thing all modes a supported. Nice to see a comment here for this piece off software. best regards.
How do I get the actual optical connection device?
Can I buy the device without a vitoconnect100 for instance?
Or do I build it myself? Schematics?
I found one on eBay ;-)
hi, my little script is not working right on raspberryPI3 ; so, I am writing an other version based on vitalk + ruby script + mqtt gateway. I will post full source code soon on my github account.
for shematic, it's based on this : https://openv.wikispaces.com/Bauanleitung+USB (https://openv.wikispaces.com/file/view/USB_optolink_v2_All.pdf) ; I use "TTL" output directly connected to an USB adaptator.
you may have a look at this repository : https://github.com/mqu/viessmann-mqtt/ (0x20CB).
I found an error in party-mode et eco-mode for my device : mqu/vitalk#1 ; fixed.