-
-
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 : http://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 : http://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() | |
| c=@sp.read(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+@cmd2+@data.sum)%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 http://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 | |
| res=@v.command_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 : http://openv.wikispaces.com/Bauanleitung+USB (http://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.