diff --git a/decoders/opentherm/__init__.py b/decoders/opentherm/__init__.py new file mode 100644 index 00000000..e245f58b --- /dev/null +++ b/decoders/opentherm/__init__.py @@ -0,0 +1,24 @@ +## +## This file is part of the libsigrokdecode project. +## +## Copyright (C) 2024 MaxWolf b8e06912cff61c7fc1f5df01ba2f43de51b04ce33fd4d351ce86a40c0cbf9abb +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with this program; if not, see . +## + +''' +Opentherm is Manchester/Bi-phase-L/32-bit frame protocol to monitor/control climate devices +''' + +from .pd import Decoder diff --git a/decoders/opentherm/lists.py b/decoders/opentherm/lists.py new file mode 100644 index 00000000..4bfc98f5 --- /dev/null +++ b/decoders/opentherm/lists.py @@ -0,0 +1,32 @@ +## +## This file is part of the libsigrokdecode project. +## +## Copyright (C) 2024 MaxWolf b8e06912cff61c7fc1f5df01ba2f43de51b04ce33fd4d351ce86a40c0cbf9abb +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with this program; if not, see . +## + + +msg_type = { + 0: ['M2S', 'READ-DATA', 'RD'], + 1: ['M2S', 'WRITE-DATA', 'WD'], + 2: ['M2S', 'INVALID-DATA', 'INV'], + 3: ['M2S', 'RESERVED', 'RSV'], + 4: ['S2M', 'READ-ACK', 'RACK'], + 5: ['S2M', 'WRITE-ACK', 'WACK'], + 6: ['S2M', 'DATA-INVALID', 'INV'], + 7: ['S2M', 'UNKNOWN-DATAID', 'UNK'], +} + + diff --git a/decoders/opentherm/otdecoder.py b/decoders/opentherm/otdecoder.py new file mode 100644 index 00000000..90bdc18d --- /dev/null +++ b/decoders/opentherm/otdecoder.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# see https://www.gnu.org/licenses/ for license terms +# +# OpenTherm detailed decoder +# taken from https://github.com/sthamster/notexplorer +# Copyright (C) 2024 MaxWolf b8e06912cff61c7fc1f5df01ba2f43de51b04ce33fd4d351ce86a40c0cbf9abb +# +import sys +import time +import collections +import re + +###### +# +# Opentherm defines +# +###### +# Message types according to Opentherm specs +OT_READ_DATA=0 +OT_WRITE_DATA=1 +OT_INVALID_DATA=2 +OT_RESERVED=3 +OT_READ_ACK=4 +OT_WRITE_ACK=5 +OT_DATA_INVALID=6 +OT_UNKNOWN_DATA_ID=7 + +###### +# +# Decode/comment Opentherm data +# +###### +class OTData: + def __init__(self, data_id, t_dir, pos, fmt, min, max, units, descr): + self.data_id = data_id # OT data-id + self.t_dir = t_dir # transfer direction ('R' - from slave, 'W' - to slave, 'RW' - both, + # 'I' - from slave special mode (with simulaneous write), O - to slave special mode (with simultaneous read)) + self.pos = pos # bit position, if exists (HB0 - bit 0 of high byte; LB1 - bit 1 of low byte etc; 0-7 - bits of the whole 16-bit word) + self.fmt = fmt # data format: BF - bitfield, U8, U16, S8, S16, F8.8 + self.min = min # min value + self.max = max # max value + self.units = units # value units + self.descr = descr # data description (could contains conditional (==/>=/<=) descriptions + +class OTDecoder: + def __init__(self): + self.OT_MSGS = { + OT_READ_DATA: "READ-DATA", + OT_WRITE_DATA: "WRITE-DATA", + OT_INVALID_DATA: "INVALID-DATA", + OT_RESERVED: "OT-RESERVED", + OT_READ_ACK:"READ-ACK", + OT_WRITE_ACK:"WRITE-ACK", + OT_DATA_INVALID:"DATA-INVALID", + OT_UNKNOWN_DATA_ID:"UNKNOWN-DATAID" } + # Opentherm DATA-ID descriptions collected from + # Opentherm%20Protocol%20v2-2.pdf + # https://www.opentherm.eu/request-details + # http://otgw.tclcode.com/firmware.html + # +forums and other sources... + self.otd = collections.OrderedDict() + self.otd["000"] = OTData("000", "RI", "", "BF", 0, 1, "", "Master/slave status") + self.otd["000I"] = OTData("000", "R", "", "BF", 0, 1, "", "Master/slave status") + self.otd["000I:HB0"] = OTData("000", "R", "HB0", "BF", 0, 1, "", "MS: CH enable") + self.otd["000I:HB1"] = OTData("000", "R", "HB1", "BF", 0, 1, "", "MS: DHW enable") + self.otd["000I:HB2"] = OTData("000", "R", "HB2", "BF", 0, 1, "", "MS: Cooling enable") + self.otd["000I:HB3"] = OTData("000", "R", "HB3", "BF", 0, 1, "", "MS: OTC active") + self.otd["000I:HB4"] = OTData("000", "R", "HB4", "BF", 0, 1, "", "MS: CH2 enable") + self.otd["000I:HB5"] = OTData("000", "R", "HB5", "BF", 0, 1, "", "MS: Summer mode") # 0 - winter, 1 - summer + self.otd["000I:HB6"] = OTData("000", "R", "HB6", "BF", 0, 1, "", "MS: DHW blocked") # 0 - DHW unblocked, 1 - blocked + self.otd["000I:HB7"] = OTData("000", "R", "HB7", "BF", 0, 1, "", "MS: reserved") + self.otd["000:HB0"] = OTData("000", "R", "HB0", "BF", 0, 1, "", "MS: CH enable") + self.otd["000:HB1"] = OTData("000", "R", "HB1", "BF", 0, 1, "", "MS: DHW enable") + self.otd["000:HB2"] = OTData("000", "R", "HB2", "BF", 0, 1, "", "MS: Cooling enable") + self.otd["000:HB3"] = OTData("000", "R", "HB3", "BF", 0, 1, "", "MS: OTC active") + self.otd["000:HB4"] = OTData("000", "R", "HB4", "BF", 0, 1, "", "MS: CH2 enable") + self.otd["000:HB5"] = OTData("000", "R", "HB5", "BF", 0, 1, "", "MS: Summer/winter mode") + self.otd["000:HB6"] = OTData("000", "R", "HB6", "BF", 0, 1, "", "MS: DHW blocking") + self.otd["000:HB7"] = OTData("000", "R", "HB7", "BF", 0, 1, "", "MS: reserved") + self.otd["000:LB0"] = OTData("000", "R", "LB0", "BF", 0, 1, "", "SS: Fault") + self.otd["000:LB1"] = OTData("000", "R", "LB1", "BF", 0, 1, "", "SS: CH mode") + self.otd["000:LB2"] = OTData("000", "R", "LB2", "BF", 0, 1, "", "SS: DHW mode") + self.otd["000:LB3"] = OTData("000", "R", "LB3", "BF", 0, 1, "", "SS: Flame on") + self.otd["000:LB4"] = OTData("000", "R", "LB4", "BF", 0, 1, "", "SS: Cooling on") + self.otd["000:LB5"] = OTData("000", "R", "LB5", "BF", 0, 1, "", "SS: CH2 active") + self.otd["000:LB6"] = OTData("000", "R", "LB6", "BF", 0, 1, "", "SS: Diagnostic/service indication") + self.otd["000:LB7"] = OTData("000", "R", "LB7", "BF", 0, 1, "", "SS: Electricity production") + self.otd["001"] = OTData("001", "RW", "", "", 0, 100, "°C", "CH water temperature Setpoint") + self.otd["001W"] = OTData("001", "W", "", "F8.8", 0, 100, "°C", "CH water temperature Setpoint") + self.otd["001R"] = OTData("001", "R", "", "F8.8", 0, 100, "°C", "CH water temperature Setpoint") + self.otd["002"] = OTData("002", "W", "", "BF", 0, 1, "", "Master configuration") + self.otd["002:LB"] = OTData("002", "W", "0-7", "U8", 0, 255, "", "Master configuration: MemberId code") + self.otd["002:HB0"] = OTData("002", "W", "HB0", "BF", 0, 1, "", "Master configuration: Smart power") + self.otd["003"] = OTData("003", "R", "", "BF", 0, 1, "", "Slave configuration") + self.otd["003:LB"] = OTData("003", "R", "0-7", "U8", 0, 255, "", "Slave configuration: MemberId code") + self.otd["003:HB0"] = OTData("003", "R", "HB0", "BF", 0, 1, "", "DHW present") + self.otd["003:HB1"] = OTData("003", "R", "HB1", "BF", 0, 1, "", "On/Off control only") + self.otd["003:HB2"] = OTData("003", "R", "HB2", "BF", 0, 1, "", "Cooling supported") + self.otd["003:HB3"] = OTData("003", "R", "HB3", "BF", 0, 1, "", "DHW configuration") + self.otd["003:HB4"] = OTData("003", "R", "HB4", "BF", 0, 1, "", "Master low-off&pump control allowed") + self.otd["003:HB5"] = OTData("003", "R", "HB5", "BF", 0, 1, "", "CH2 present") + self.otd["003:HB6"] = OTData("003", "R", "HB6", "BF", 0, 1, "", "Remote water filling function") + self.otd["003:HB7"] = OTData("003", "R", "HB7", "BF", 0, 1, "", "Heat/cool mode control") + self.otd["004"] = OTData("004", "RW", "", "", 0, 0, "", "Slave control") + self.otd["004W"] = OTData("004", "W", "8-15", "U8", 0, 255, "", "==1 Boiler Lockout-reset;==10 Service request reset;==2 Request Water filling") + self.otd["004R"] = OTData("004", "R", "0-7", "U8", 0, 255, "", ">127 response ok;<128 response error") + self.otd["005"] = OTData("005", "R", "", "BF", 0, 1, "", "Boiler faults") + self.otd["005:HB0"] = OTData("005", "R", "HB0", "BF", 0, 1, "", "Service required") + self.otd["005:HB1"] = OTData("005", "R", "HB1", "BF", 0, 1, "", "Lockout-reset enabled") + self.otd["005:HB2"] = OTData("005", "R", "HB2", "BF", 0, 1, "", "Low water pressure") + self.otd["005:HB3"] = OTData("005", "R", "HB3", "BF", 0, 1, "", "Gas/flame fault") + self.otd["005:HB4"] = OTData("005", "R", "HB4", "BF", 0, 1, "", "Air pressure fault") + self.otd["005:HB5"] = OTData("005", "R", "HB5", "BF", 0, 1, "", "Water over-temperature") + self.otd["005:LB"] = OTData("005", "R", "0-7", "U8", 0, 255, "", "OEM fault code") + self.otd["006"] = OTData("006", "R", "", "BF", 0, 1, "", "Remote boiler parameters") + self.otd["006:HB0"] = OTData("006", "R", "HB0", "BF", 0, 1, "", "transfer-enabled: DHW setpoint") + self.otd["006:HB1"] = OTData("006", "R", "HB1", "BF", 0, 1, "", "transfer-enabled: max. CH setpoint") + self.otd["006:HB2"] = OTData("006", "R", "HB2", "BF", 0, 1, "", "transfer-enabled: param 2 (OTC HC ratio)") # unsure + self.otd["006:HB3"] = OTData("006", "R", "HB3", "BF", 0, 1, "", "transfer-enabled: param 3") + self.otd["006:HB4"] = OTData("006", "R", "HB4", "BF", 0, 1, "", "transfer-enabled: param 4") + self.otd["006:HB5"] = OTData("006", "R", "HB5", "BF", 0, 1, "", "transfer-enabled: param 5") + self.otd["006:HB6"] = OTData("006", "R", "HB6", "BF", 0, 1, "", "transfer-enabled: param 6") + self.otd["006:HB7"] = OTData("006", "R", "HB7", "BF", 0, 1, "", "transfer-enabled: param 7") + self.otd["006:LB0"] = OTData("006", "R", "LB0", "BF", 0, 1, "", "read/write: DHW setpoint") + self.otd["006:LB1"] = OTData("006", "R", "LB1", "BF", 0, 1, "", "read/write: max. CH setpoint") + self.otd["006:LB2"] = OTData("006", "R", "LB2", "BF", 0, 1, "", "read/write: param 2 (OTC HC ratio)") # unsure + self.otd["006:LB3"] = OTData("006", "R", "LB3", "BF", 0, 1, "", "read/write: param 3") + self.otd["006:LB4"] = OTData("006", "R", "LB4", "BF", 0, 1, "", "read/write: param 4") + self.otd["006:LB5"] = OTData("006", "R", "LB5", "BF", 0, 1, "", "read/write: param 5") + self.otd["006:LB6"] = OTData("006", "R", "LB6", "BF", 0, 1, "", "read/write: param 6") + self.otd["006:LB7"] = OTData("006", "R", "LB7", "BF", 0, 1, "", "read/write: param 7") + self.otd["007"] = OTData("007", "W", "", "F8.8", 0, 100, "%", "Cooling control signal") + self.otd["008"] = OTData("008", "W", "", "F8.8", 0, 100, "°C", "Control Setpoint for 2nd CH circuit") + self.otd["009"] = OTData("009", "R", "", "F8.8", 0, 30, "", "Remote override room Setpoint") # 0 - no override + self.otd["010"] = OTData("010", "R", "8-15", "U8", 0, 255, "", "Number of Transparent-Slave-Parameters supported by slave") + self.otd["011"] = OTData("011", "RW", "", "", 0, 0, "", "Index/Value of transparent slave parameter") + self.otd["011R"] = OTData("011", "R", "", "BF", 0, 9, "", "Transparent slave parameter") + self.otd["011R:HB"] = OTData("011", "R", "8-15", "U8", 0, 255, "", "Index of read transparent slave parameter") + self.otd["011R:LB"] = OTData("011", "R", "0-7", "U8", 0, 255, "", "Value of read transparent slave parameter") + self.otd["011W"] = OTData("011", "W", "", "BF", 0, 1, "", "Transparent slave parameter to write") + self.otd["011W:HB"] = OTData("011", "W", "8-15","U8", 0, 255, "", "Index of referred-to transparent slave parameter to write") + self.otd["011W:LB"] = OTData("011", "W", "0-7", "U8", 0, 255, "", "Value of referred-to transparent slave parameter to write") + self.otd["012"] = OTData("012", "R", "8-15", "U8" , 0, 255, "", "Size of Fault-History-Buffer supported by slave") + self.otd["013"] = OTData("013", "R", "" , "BF", 0, 1, "", "Fault-history buffer entry") + self.otd["013:HB"] = OTData("013", "R", "8-15", "U8", 0, 255, "", "Index number") + self.otd["013:LB"] = OTData("013", "R", "0-7", "U8", 0, 255, "", "Entry Value") + self.otd["014"] = OTData("014", "W", "" , "F8.8", 0, 100, "", "Maximum relative modulation level setting (%)") + self.otd["015"] = OTData("015", "R", "" , "BF", 0, 0, "", "Boiler capacities") + self.otd["015:HB"] = OTData("015", "R", "8-15", "U8" , 0, 255, "kW", "Maximum boiler capacity") + self.otd["015:LB"] = OTData("015", "R", "0-7", "U8", 0, 100, "%", "Minimum boiler modulation level") + self.otd["016"] = OTData("016", "W", "" , "F8.8", -40, 127, "°C", "Room Setpoint") + self.otd["017"] = OTData("017", "R", "" , "F8.8", 0, 100, "%", "Relative Modulation Level") + self.otd["018"] = OTData("018", "R", "" , "F8.8", 0, 5, "bar", "Water pressure in CH circuit") + self.otd["019"] = OTData("019", "R", "" , "F8.8", 0, 16, "l/min", "Water flow rate in DHW circuit") + self.otd["020"] = OTData("020", "RW", "" , "" , 0, 0, "", "Time and DoW") + self.otd["020R"] = OTData("020", "R", "" , "BF" , 0, 0, "", "") + self.otd["020R:HB0"] = OTData("020", "R", "13-15", "U8", 0, 7, "", "Day of Week") + self.otd["020R:HB1"] = OTData("020", "R", "8-12", "U8", 0, 23, "", "Hours") + self.otd["020R:LB"] = OTData("020", "R", "0-7", "U8", 0, 59, "", "Minutes") + self.otd["020W"] = OTData("020", "W", "" , "" , 0, 0, "", "Day of Week and Time of Day") + self.otd["020W:HB0"] = OTData("020", "W", "13-15", "U8", 0, 7, "", "Day of Week") + self.otd["020W:HB1"] = OTData("020", "W", "8-12", "U8", 0, 23, "", "Hours") + self.otd["020W:LB"] = OTData("020", "W", "0-7", "U8", 0, 59, "", "Minutes") + self.otd["021"] = OTData("021", "RW", "" , "" , 0, 0, "", "Calendar date") + self.otd["021R"] = OTData("021", "R", "" , "BF", 0, 0, "", "Calendar date") + self.otd["021R:HB"] = OTData("021", "R", "8-15", "U8", 1, 12, "", "Month") + self.otd["021R:LB"] = OTData("021", "R", "0-7", "U8", 1, 31, "", "Day") + self.otd["021W"] = OTData("021", "W", "" , "BF", 0, 0, "", "") + self.otd["021W:HB"] = OTData("021", "W", "8-15", "U8", 1, 12, "", "Month") + self.otd["021W:LB"] = OTData("021", "W", "0-7", "U8", 1, 31, "", "Day") + self.otd["022"] = OTData("022", "RW", "" , "" , 0, 0, "", "Calendar year") + self.otd["022R"] = OTData("022", "R", "" , "U16", 0, 65535, "", "Year") + self.otd["022W"] = OTData("022", "W", "" , "U16", 0, 65535, "", "Year") + self.otd["023"] = OTData("023", "W", "" , "F8.8", -40, 127, "°C", "Room Setpoint for 2nd CH circuit") + self.otd["024"] = OTData("024", "W", "" , "F8.8", -40, 127, "°C", "Room temperature (°C)") + self.otd["025"] = OTData("025", "R", "" , "F8.8", -40, 127, "°C", "Boiler flow water temperature") + self.otd["026"] = OTData("026", "R", "" , "F8.8", -40, 127, "°C", "DHW temperature") + self.otd["027"] = OTData("027", "R", "" , "F8.8", -40, 127, "°C", "Outside temperature") + self.otd["028"] = OTData("028", "R", "" , "F8.8", -40, 127, "°C", "Return water temperature") + self.otd["029"] = OTData("029", "R", "" , "F8.8", -40, 127, "°C", "Solar storage temperature") + self.otd["030"] = OTData("030", "R", "" , "S16", -40, 250, "°C", "Solar collector temperature") + self.otd["031"] = OTData("031", "R", "" , "F8.8", -40, 127, "°C", "Flow water temperature CH2 circuit") + self.otd["032"] = OTData("032", "R", "" , "F8.8", -40, 127, "°C", "Domestic hot water temperature 2") + self.otd["033"] = OTData("033", "R", "" , "S16", -40, 500, "°C", "Boiler exhaust temperature") + self.otd["034"] = OTData("034", "R", "" , "F8.8", -40, 127, "°C", "Boiler heat exchanger temperature") # unsure + self.otd["035"] = OTData("035", "R", "" , "U16" , 0, 0, "", "Boiler fan speed") # rpm/60? unsure +# could also be +# self.otd["035:HB"] = OTData("035", "R", "" , "U8" , 0, 255, "", "Boiler fan speed Setpoint") +# self.otd["035:LB"] = OTData("035", "R", "" , "U8" , 0, 255, "", "Boiler fan speed actual value") + self.otd["036"] = OTData("036", "R", "" , "F8.8" , -128, 127, "µA", "Electrical current through burner flame") # unsure + self.otd["037"] = OTData("037", "W", "" , "F8.8" , -40, 127, "°C", "Room temperature for 2nd CH circuit") # unsure + self.otd["038"] = OTData("038", "W", "" , "F8.8" , 0, 0, "%", "Relative Humidity") # unsure + self.otd["048"] = OTData("048", "R", "" , "BF" , 0, 0, "", "DHW Setpoint bounds for adjustment") + self.otd["048:HB"] = OTData("048", "R", "8-15", "S8" , 0, 127, "°C", "Upper bound") + self.otd["048:LB"] = OTData("048", "R", "0-7", "S8" , 0, 127, "°C", "Lower bound") + self.otd["049"] = OTData("049", "R", "" , "BF" , 0, 0, "°C", "Max CH water Setpoint bounds for adjustment") + self.otd["049:HB"] = OTData("049", "R", "8-15", "S8" , 0, 127, "°C", "Upper bound") + self.otd["049:LB"] = OTData("049", "R", "0-7", "S8" , 0, 127, "°C", "Lower bound") + self.otd["050"] = OTData("050", "R", "" , "BF" , 0, 0, "", "OTC HC-Ratio bounds") # unsure + self.otd["050:HB"] = OTData("050", "R", "8-15", "S8" , -128, 127, "", "Upper bound") + self.otd["050:LB"] = OTData("050", "R", "0-7", "S8" , -128, 127, "", "Lower bound") + self.otd["051"] = OTData("051", "R", "" , "BF" , 0, 0, "", "Remote param 3") # unsure + self.otd["051:HB"] = OTData("051", "R", "8-15", "S8" , -128, 127, "", "Upper bound") + self.otd["051:LB"] = OTData("051", "R", "0-7", "S8" , -128, 127, "", "Lower bound") + self.otd["052"] = OTData("052", "R", "" , "BF" , 0, 0, "", "Remote param 4") # unsure + self.otd["052:HB"] = OTData("052", "R", "8-15", "S8" , -128, 127, "", "Upper bound") + self.otd["052:LB"] = OTData("052", "R", "0-7", "S8" , -128, 127, "", "Lower bound") + self.otd["053"] = OTData("053", "R", "" , "BF" , 0, 0, "", "Remote param 5") # unsure + self.otd["053:HB"] = OTData("053", "R", "8-15", "S8" , -128, 127, "", "Upper bound") + self.otd["053:LB"] = OTData("053", "R", "0-7", "S8" , -128, 127, "", "Lower bound") + self.otd["054"] = OTData("054", "R", "" , "BF" , 0, 0, "", "Remote param 6") # unsure + self.otd["054:HB"] = OTData("054", "R", "8-15", "S8" , -128, 127, "", "Upper bound") + self.otd["054:LB"] = OTData("054", "R", "0-7", "S8" , -128, 127, "", "Lower bound") + self.otd["055"] = OTData("055", "R", "" , "BF" , 0, 0, "", "Remote param 7") # unsure + self.otd["055:HB"] = OTData("055", "R", "8-15", "S8" , -128, 127, "", "Upper bound") + self.otd["055:LB"] = OTData("055", "R", "0-7", "S8" , -128, 127, "", "Lower bound") + self.otd["056"] = OTData("056", "RW", "" , "" , 0, 0, "°C", "DHW Setpoint (Remote param 0)") + self.otd["056R"] = OTData("056", "R", "" , "F8.8", 0, 127, "°C", "Current DHW Setpoint (Remote param 0)") + self.otd["056W"] = OTData("056", "W", "" , "F8.8", 0, 127, "°C", "DHW Setpoint to set(Remote param 0)") + self.otd["057"] = OTData("057", "RW", "" , "" , 0, 0, "°C", "Max CH water Setpoint (Remote param 1)") + self.otd["057R"] = OTData("057", "R", "" , "F8.8", 0, 127, "°C", "Current Max CH water Setpoint (Remote param 1)") + self.otd["057W"] = OTData("057", "W", "" , "F8.8", 0, 127, "°C", "Max CH water Setpoint to set (Remote param 1)") + self.otd["058"] = OTData("058", "RW", "" , "" , 0, 0, "°C", "OTC HC Ratio (Remote param 2)") # unsure + self.otd["058R"] = OTData("058", "R", "" , "F8.8", 0, 127, "°C", "Current OTC HC Ratio (Remote param 2)") # unsure + self.otd["058W"] = OTData("058", "W", "" , "F8.8", 0, 127, "°C", "OTC HC Ratio to set (Remote param 2)") # unsure + self.otd["059"] = OTData("059", "RW", "" , "" , 0, 0, "", "(Remote param 3)") + self.otd["059R"] = OTData("059", "R", "" , "F8.8", 0, 127, "", "Current (Remote param 3)") + self.otd["059W"] = OTData("059", "W", "" , "F8.8", 0, 127, "", "to set (Remote param 3)") + self.otd["060"] = OTData("060", "RW", "" , "" , 0, 0, "", "(Remote param 4)") + self.otd["060R"] = OTData("060", "R", "" , "F8.8", 0, 127, "", "Current (Remote param 4)") + self.otd["060W"] = OTData("060", "W", "" , "F8.8", 0, 127, "", "to set (Remote param 4)") + self.otd["061"] = OTData("061", "RW", "" , "" , 0, 0, "", "(Remote param 5)") + self.otd["061R"] = OTData("061", "R", "" , "F8.8", 0, 127, "", "Current (Remote param 5)") + self.otd["061W"] = OTData("061", "W", "" , "F8.8", 0, 127, "", "to set (Remote param 5)") + self.otd["062"] = OTData("062", "RW", "" , "" , 0, 0, "", "(Remote param 6)") + self.otd["062R"] = OTData("062", "R", "" , "F8.8", 0, 127, "", "Current (Remote param 6)") + self.otd["062W"] = OTData("062", "W", "" , "F8.8", 0, 127, "", "to set (Remote param 6)") + self.otd["063"] = OTData("063", "RW", "" , "" , 0, 0, "", "(Remote param 7)") + self.otd["063R"] = OTData("063", "R", "" , "F8.8", 0, 127, "", "Current (Remote param 7)") + self.otd["063W"] = OTData("063", "W", "" , "F8.8", 0, 127, "", "to set (Remote param 7)") + self.otd["070"] = OTData("070", "R", "", "BF", 0, 0, "", "Status ventilation / heat-recovery") # unsure + self.otd["070:HB0"] = OTData("070", "R", "HB0", "BF", 0, 1, "", "Master status ventilation / heat-recovery: Ventilation enable") # unsure + self.otd["070:HB1"] = OTData("070", "R", "HB1", "BF", 0, 1, "", "Master status ventilation / heat-recovery: Bypass postion") # unsure + self.otd["070:HB2"] = OTData("070", "R", "HB2", "BF", 0, 1, "", "Master status ventilation / heat-recovery: Bypass mode") # unsure + self.otd["070:HB3"] = OTData("070", "R", "HB3", "BF", 0, 1, "", "Master status ventilation / heat-recovery: Free ventilation mode") # unsure + self.otd["070:LB0"] = OTData("070", "R", "LB0", "BF", 0, 1, "", "Slave status ventilation / heat-recovery: Fault indication") # unsure + self.otd["070:LB1"] = OTData("070", "R", "LB1", "BF", 0, 1, "", "Slave status ventilation / heat-recovery: Ventilation mode") # unsure + self.otd["070:LB2"] = OTData("070", "R", "LB2", "BF", 0, 1, "", "Slave status ventilation / heat-recovery: Bypass status") # unsure + self.otd["070:LB3"] = OTData("070", "R", "LB3", "BF", 0, 1, "", "Slave status ventilation / heat-recovery: Bypass automatic status") # unsure + self.otd["070:LB4"] = OTData("070", "R", "LB4", "BF", 0, 1, "", "Slave status ventilation / heat-recovery: Free ventilation status") # unsure + self.otd["070:LB6"] = OTData("070", "R", "LB6", "BF", 0, 1, "", "Slave status ventilation / heat-recovery: Diagnostic indication") # unsure + self.otd["071"] = OTData("071", "R", "" , "" , 0, 0, "", "Relative ventilation position (0-100%). 0% is the minimum set ventilation and 100% is the maximum set ventilation") # unsure + self.otd["072"] = OTData("072", "R", "" , "" , 0, 0, "", "Application-specific fault flags and OEM fault code ventilation / heat-recovery") # unsure + self.otd["073"] = OTData("073", "R", "" , "" , 0, 0, "", "An OEM-specific diagnostic/service code for ventilation / heat-recovery system") # unsure + self.otd["074"] = OTData("074", "R", "", "BF", 0, 1, "", "Slave Configuration ventilation / heat-recovery") # unsure + self.otd["074:HB0"] = OTData("074", "R", "HB0", "BF", 0, 1, "", "Ventilation enabled") # unsure + self.otd["074:HB1"] = OTData("074", "R", "HB1", "BF", 0, 1, "", "Bypass position") # unsure + self.otd["074:HB2"] = OTData("074", "R", "HB2", "BF", 0, 1, "", "Bypass mode") # unsure + self.otd["074:HB3"] = OTData("074", "R", "HB3", "BF", 0, 1, "", "Speed control") # unsure + self.otd["074:LB"] = OTData("074", "R", "0-7", "U8", 0, 255, "", "Slave MemberID Code ventilation / heat-recovery") # unsure +#!!! not properly checked from below + self.otd["075"] = OTData("075", "R", "" , "U16", 0, 0, "", "The implemented version of the OpenTherm Protocol Specification in the ventilation / heat-recovery system") + self.otd["076"] = OTData("076", "R", "" , "U16", 0, 0, "", "Ventilation / heat-recovery product version number and type") + self.otd["077"] = OTData("077", "R", "" , "U16", 0, 100, "%", "Relative ventilation") + self.otd["078"] = OTData("078", "R", "" , "U16", 0, 100, "%", "Relative humidity exhaust air") + self.otd["079"] = OTData("079", "R", "" , "U16", 0, 2000, "ppm", "CO2 level exhaust air") + self.otd["080"] = OTData("080", "R", "" , "U16", 0, 0, "°C", "Supply inlet temperature") + self.otd["081"] = OTData("081", "R", "" , "U16", 0, 0, "°C", "Supply outlet temperature") + self.otd["082"] = OTData("082", "R", "" , "U16", 0, 0, "°C", "mExhaust inlet temperature") + self.otd["083"] = OTData("083", "R", "" , "U16", 0, 0, "°C", "Exhaust outlet temperature") + self.otd["084"] = OTData("084", "R", "" , "U16", 0, 0, "rpm", "Exhaust fan speed") + self.otd["085"] = OTData("085", "R", "" , "U16", 0, 0, "rpm", "Supply fan speed") + self.otd["086"] = OTData("086", "R", "", "BF", 0, 0, "", "Remote ventilation / heat-recovery parameter:") + self.otd["086:HB0"] = OTData("086", "R", "HB0", "BF", 0, 0, "", "Transfer-enable: Nominal ventilation value") + self.otd["086:LB0"] = OTData("086", "R", "LB0", "BF", 0, 0, "", "Read/write : Nominal ventilation value") + self.otd["087"] = OTData("087", "R", "" , "U16", 0, 100, "%", "Nominal relative value for ventilation") + self.otd["088"] = OTData("088", "R", "" , "U16", 0, 255, "", "Number of Transparent-Slave-Parameters supported by TSP’s ventilation / heat-recovery") + self.otd["089"] = OTData("089", "R", "" , "U16", 0, 255, "", "Index number / Value of referred-to transparent TSP’s ventilation / heat-recovery parameter") + self.otd["090"] = OTData("090", "R", "" , "U16", 0, 255, "", "Size of Fault-History-Buffer supported by ventilation / heat-recovery") + self.otd["091"] = OTData("091", "R", "" , "U16", 0, 255, "", "Index number / Value of referred-to fault-history buffer entry ventilation / heat-recovery") +# from https://www.opentherm.eu/request-details/?post_ids=3931 + self.otd["093"] = OTData("093", "R", "" , "U16", 0, 65535, "", "Brand Index / Slave Brand name") + self.otd["094"] = OTData("094", "R", "" , "U16", 0, 65535, "", "Brand Version Index / Slave product type/version") + self.otd["095"] = OTData("095", "R", "" , "U16", 0, 65535, "", "Brand Serial Number index / Slave product serialnumber") + self.otd["098"] = OTData("098", "R", "" , "U16", 0, 255, "", "For a specific RF sensor the RF strength and battery level is written") + self.otd["099"] = OTData("099", "R", "" , "U16", 0, 255, "", "Operating Mode HC1, HC2/ Operating Mode DHW") +# to check + self.otd["100"] = OTData("100", "R", "" , "U16", 0, 255, "", "Function of manual and program changes in master and remote room Setpoint") + self.otd["101"] = OTData("101", "R", "" , "BF", 0, 0, "", "Solar Storage:") + self.otd["101:HB"] = OTData("101", "R", "8-10", "U8", 0, 0, "", "Master Solar Storage: Solar mode") + self.otd["101:LB0"] = OTData("101", "R", "LB0", "BF", 0, 0, "", "Slave Solar Storage: Fault indication") + self.otd["101:LB1"] = OTData("101", "R", "1-3", "U8", 0, 7, "", "Slave Solar Storage: Solar mode status") + self.otd["101:LB2"] = OTData("101", "R", "4-5", "U8", 0, 3, "", "Slave Solar Storage: Solar status") + self.otd["102"] = OTData("102", "R", "" , "" , 0, 0, "", "Application-specific fault flags and OEM fault code Solar Storage") + self.otd["103"] = OTData("103", "R", "", "BF", 0, 0, "", "Slave Configuration Solar Storage") + self.otd["103:HB0"] = OTData("103", "R", "HB0", "BF", 0, 0, "", "System type") + self.otd["103:LB"] = OTData("103", "R", "0-7", "U8", 0, 255, "", "Slave MemberID") + self.otd["104"] = OTData("104", "R", "" , "U16", 0, 255, "", "Solar Storage product version number and type") + self.otd["105"] = OTData("105", "R", "" , "U16", 0, 255, "", "Number of Transparent-Slave-Parameters supported by TSP’s Solar Storage") + self.otd["106"] = OTData("106", "R", "" , "U16", 0, 255, "", "Index number / Value of referred-to transparent TSP’s Solar Storage parameter") + self.otd["107"] = OTData("107", "R", "" , "U16", 0, 255, "", "Size of Fault-History-Buffer supported by Solar Storage") + self.otd["108"] = OTData("108", "R", "" , "U16", 0, 255, "", "Index number / Value of referred-to fault-history buffer entry Solar Stor") + self.otd["109"] = OTData("109", "R", "" , "U16", 0, 255, "", "Electricity producer starts") + self.otd["110"] = OTData("110", "R", "" , "U16", 0, 255, "", "Electricity producer hours") + self.otd["111"] = OTData("111", "R", "" , "U16", 0, 255, "", "Electricity production") + self.otd["112"] = OTData("112", "R", "" , "U16", 0, 255, "", "Cumulativ Electricity production") + self.otd["113"] = OTData("113", "R", "" , "U16", 0, 255, "", "Number of un-successful burner starts") + self.otd["114"] = OTData("114", "R", "" , "U16", 0, 255, "", "Number of times flame signal was too low") +# below data-ids are checked up against specs + self.otd["115"] = OTData("115", "R", "" , "U16", 0, 255, "", "OEM-specific diagnostic/service code") + # below ids are RW (write 0 to reset) + self.otd["116"] = OTData("116", "R", "" , "U16", 0, 65535, "", "Number of succesful starts burner") + self.otd["117"] = OTData("117", "R", "" , "U16", 0, 65535, "", "Number of starts CH pump") + self.otd["118"] = OTData("118", "R", "" , "U16", 0, 65535, "", "Number of starts DHW pump/valve") + self.otd["119"] = OTData("119", "R", "" , "U16", 0, 65535, "", "Number of starts burner during DHW mode") + self.otd["120"] = OTData("120", "R", "" , "U16", 0, 65535, "", "Number of hours that burner is in operation (i.e. flame on)") + self.otd["121"] = OTData("121", "R", "" , "U16", 0, 65535, "", "Number of hours that CH pump has been running") + self.otd["122"] = OTData("122", "R", "" , "U16", 0, 65535, "", "Number of hours that DHW pump has been running or DHW valve has been opened") + self.otd["123"] = OTData("123", "R", "" , "U16", 0, 65535, "", "Number of hours that burner is in operation during DHW mode") + # ^^^ + self.otd["124"] = OTData("124", "W", "" , "F8.8", 1, 127, "", "The implemented version of the OpenTherm Protocol Specification in the master") + self.otd["125"] = OTData("125", "R", "" , "F8.8", 1, 127, "", "The implemented version of the OpenTherm Protocol Specification in the slave") + self.otd["126"] = OTData("126", "W", "" , "BF" , 0, 0, "", "Master product version number and type") + self.otd["126:HB"] = OTData("126", "W", "8-15", "U8" , 0, 255, "", "Master product version number and type") + self.otd["126:LB"] = OTData("126", "W", "0-7", "U8" , 0, 255, "", "Master product version number and type") + self.otd["127"] = OTData("127", "R", "" , "BF" , 0, 0, "", "Slave product version number and type") + self.otd["127:HB"] = OTData("127", "R", "8-15", "U8" , 0, 255, "", "Slave product version number and type") + self.otd["127:LB"] = OTData("127", "R", "0-7", "U8" , 0, 255, "", "Slave product version number and type") +# baxi ecofour unspecified regs + self.otd["129"] = OTData("129", "R", "" , "U16", 0, 65535, "", "BAXI data-id 129") + self.otd["130"] = OTData("130", "R", "" , "U16", 0, 65535, "", "BAXI data-id 130") + self.otd["131"] = OTData("131", "R", "" , "BF", 0, 0, "", "Remeha codes:") + self.otd["131:HB"] = OTData("131", "R", "8-15", "U8", 0, 255, "", "dU") + self.otd["131:LB"] = OTData("131", "R", "0-7", "U8", 0, 255, "", "dF") + self.otd["132"] = OTData("132", "R", "" , "BF", 0, 0, "", "Remeha Servicemessage:") + self.otd["132:HB"] = OTData("132", "R", "8-15", "U8", 0, 255, "", "Next service type") + self.otd["132:LB"] = OTData("132", "R", "0-7", "U8", 0, 255, "", "?") + self.otd["133"] = OTData("133", "W", "" , "U16", 0, 511, "", "Remeha detection connected SCU’s") + self.otd["149"] = OTData("149", "R", "" , "U16", 0, 65535, "", "BAXI data-id 149") + self.otd["150"] = OTData("150", "R", "" , "U16", 0, 65535, "", "BAXI data-id 150") + self.otd["151"] = OTData("151", "R", "" , "U16", 0, 65535, "", "BAXI data-id 151") + self.otd["173"] = OTData("173", "R", "" , "U16", 0, 65535, "", "BAXI data-id 173") + self.otd["198"] = OTData("198", "R", "" , "U16", 0, 65535, "", "BAXI data-id 198") + self.otd["199"] = OTData("199", "R", "" , "U16", 0, 65535, "", "BAXI data-id 199") + self.otd["200"] = OTData("200", "R", "" , "U16", 0, 65535, "", "BAXI data-id 200") + self.otd["202"] = OTData("202", "R", "" , "U16", 0, 65535, "", "BAXI data-id 202") + self.otd["203"] = OTData("203", "R", "" , "U16", 0, 65535, "", "BAXI data-id 203") + self.otd["204"] = OTData("204", "R", "" , "U16", 0, 65535, "", "BAXI data-id 204") + self.otd["209"] = OTData("209", "R", "" , "U16", 0, 65535, "", "BAXI data-id 209") + + + # from https://otgw.tclcode.com/details.cgi + self.OT_MEMBERS = { + 0: "Unspecified", + 2: "AWB", + 4: "Multibrand", # Atag, Baxi Slim, Brötje, Elco + 5: "Itho Daalderop", + 6: "Daikin/Ideal", + 8: "Biasi/Buderus/Logamax", + 9: "Ferroli/Agpo", + 11: "De Dietrich/Remeha/Baxi Prime", + 13: "Cetetherm", + 16: "Unical", + 18: "Bosch", + 24: "Vaillant/AWB/Bulex", + 27: "Baxi", + 29: "Daalderop/Itho", + 33: "Viessmann", + 41: "Radiant", + 56: "Baxi Luna", + 131: "Netfit/Bosch", + 173: "Intergas" + } + + + # return opentherm master-to-slave and slave-to-master message names by numeric id + def msg_descr(self, resp): + if resp < 0 or resp > 7: + return "!LAME!" + else: + return self.OT_MSGS[resp] + + # extract response bits based on otd descriptions of data position + def get_bits(self, value, pos): + if pos[0:2] == "LB": + return (value >> int(pos[2])) & 1 + elif pos[0:2] == "HB": + return (value >> (8+int(pos[2]))) & 1 + else: + iv = pos.split("-") + mask = 0 + for i in range(0, int(iv[1]) - int(iv[0]) + 1): + mask = (mask << 1) | 1 + return (value >> int(iv[0])) & mask + + # decode opentherm data-value based on it's pos and fmt; + # return: ( string representation, 1 if success else -1 ) + def decode_value(self, value, fmt, pos): + if fmt == "U8" or fmt == "BF": + if pos != "": + v = self.get_bits(value, pos) & 0xff + else: + v = (value & 0xff) + return "%u" % v, 1 + elif fmt == "U16": + return "%u" % (value & 0xffff), 1 + elif fmt == "S8": + if pos != "": + v = self.get_bits(value, pos) & 0xff + else: + v = value & 0xff + if v > 127: + return "-%u" % (256 - v), 1 + else: + return "%u" % v, 1 + elif fmt == "S16": + if value > 32767: + return "-%u" % (65536 - (value & 0xffff)), 1 + else: + return "%u" % (value & 0xffff), 1 + elif fmt == "F8.8": # # 1/256 gives 0.00390625 i.e. 8 fractional decimal digits but it useless in this certain practical case, let's limit output to 3 decimal digits to get something out of smallest possible number + v = value & 0xffff + if v > 32767: + return ("-%.3f" % ((65536-v)/256)).rstrip('0').rstrip('.'), 1 # default %f formatting produces extra trailing zeroes + else: + return ("%.3f" % (v/256)).rstrip('0').rstrip('.'), 1 + else: + # unknown format + return "###", -1 + + # decode description string (if it has conditional parts separated by ';') + def decode_descr(self, descr, val): + if ';' not in descr: + return descr; + ds = descr.split(';') + for d in ds: + c = d.split(' ', 1) + if eval(val + c[0]): + return c[1] + return "unknown value " + val + + # decode and describe opentherm data-value using direct otd[] lookup by dids + def describe_dataid(self, dids, val): + descr = "" + if self.otd[dids].fmt == "BF": + descr = self.otd[dids].descr + for variant in ( "HB", "HB0", "HB1", "HB2", "HB3", "HB4", "HB5", "HB6", "HB7", "LB", "LB0", "LB1", "LB2", "LB3", "LB4", "LB5", "LB6", "LB7" ): + varid = dids + ":" + variant + if varid in self.otd: + vv, vc = self.decode_value(val, self.otd[varid].fmt, self.otd[varid].pos) + if vc > 0: + if '-' in self.otd[varid].pos: # multibit field + descr = descr + "; " + self.decode_descr(self.otd[varid].descr, vv) + " = " + vv + self.otd[varid].units + if float(vv) < self.otd[varid].min or float(vv) > self.otd[varid].max: + descr = descr + " - out of range!" + else: + descr = descr + "; " + ("+" if vv == "1" else "-") + self.otd[varid].descr + if varid == "003:LB" or varid == "002:LB" or varid == "074:LB" or varid == "103:LB": + descr = descr + " (" + self.describe_member(int(vv)) + ")" + else: + return "Unable to decode value \'" + val + "\' as per fmt " + self.otd[varid].fmt + " from pos " + self.otd[varid].pos , -1 + else: + v,c = self.decode_value(val, self.otd[dids].fmt, self.otd[dids].pos) + if c < 0: + return "Unable to decode value \'" + val + "\' as per fmt " + self.otd[dids].fmt + " from pos " + self.otd[dids].pos , -1 + descr = self.decode_descr(self.otd[dids].descr, v) + "; " + v + self.otd[dids].units + if float(v) < self.otd[dids].min or float(v) > self.otd[dids].max: + descr = descr + " - out of range!" + return descr, 1 + + + # describe either read or write opentherm request/response + def describe_param_internal(self, dids, t_dir, val_sent, val_received, do_print): + descr = "" + if not dids in self.otd: + if do_print: + print("Data-id " + dids + " is unknown") + return "Unknown data-id", -3 + if not t_dir in self.otd[dids].t_dir: + if do_print: + print("Data-id " + dids + "/" + t_dir + " is unknown") + return "Unknown data-id direction", -3 + + if t_dir == "R": + if (val_sent >= 0) and ("I" in self.otd[dids].t_dir): + descr = descr + " Read input value: " + vv2, vc2 = self.describe_dataid(dids + "I", val_sent) + if vc2 < 0: + return vv2, vc2 + descr = descr + vv2 + " " + else: + descr = self.otd[dids].descr + if (val_received >= 0): + vv, vc = self.describe_dataid(dids, val_received) + if vc < 0: + return vv, vc + descr = descr + " Response: " + vv + else: # assume "W" + if (val_sent >=0): + vv, vc = self.describe_dataid(dids, val_sent) + if vc < 0: + return vv, vc + descr = descr + "Written: " + vv + " " + if (val_received >=0) and ("O" in self.otd[dids].t_dir): + descr = descr + " Write output value: " + vv2, vc2 = self.describe_dataid(dids + "I", val_received) + if vc2 < 0: + return vv2, vc2 + descr = descr + vv2 + else: + descr = self.otd[dids].descr + if do_print: + print(descr) + return descr, 1 + + # most generic opentherm request/response decoder + def describe_param(self, data_id, t_dir, value_sent, value_received, do_print=False): + if value_sent is int: + vsn = value_sent + else: + try: + vsn = int(value_sent) + except: + if do_print: + print("Value \'" + value_sent + "\' is not a number") + return "NaN", -2 + if value_received is int: + vrn = value_received + else: + try: + vrn = int(value_received) + except: + if do_print: + print("Value \'" + value_received + "\' is not a number") + return "NaN", -2 + if type(data_id) is int: + dids = "%03d" % data_id + if type(data_id) is str: + dids = ("00" + data_id)[-3:] + if not dids in self.otd: + if do_print: + print("Data-id " + dids + " is unknown") + return "Unknown data-id", -3 + if self.otd[dids].t_dir == "RW": + r, c = self.describe_param_internal(dids + t_dir, t_dir, vsn, vrn, do_print) + return self.otd[dids].descr + ": " + r, c + return self.describe_param_internal(dids, t_dir, vsn, vrn, do_print) + + def describe_member(self, member_id): + if member_id in self.OT_MEMBERS: + return self.OT_MEMBERS[member_id] + else: + return "UNKNOWN" + + def parse_val(self, val): + vn = "" + if "+" in val: + vals = val.split('+') + sum = 0 + for v in vals: + sum = sum + self.parse_val(v) + return sum & 0xffff + cv = "?" # for exception reporting + try: + if "%" in val: + v = val.split('%') + vn = v[0] + if v[1] == "F8.8": # float %8.8f + cv = v[0] + if "~" == cv[0:1]: + nf = -float(cv[1:]) + else: + nf = float(cv) + nf = nf * 256 + n = int(nf) + return n & 0xffff + elif v[1][0:1] == "B": # 16bit word's bit %B or bitrange %B- + cv = vn + n = int(cv) + if "-" in v[1]: + br = v[1][1:].split('-') + vn = br[0] + cv = vn + bl = int(cv) + vn = br[1] + cv = vn + bh = int(cv) + mask = 0 + for i in range(0, (bh - bl) + 1): + mask = (mask << 1) | 1 + return (n & mask) << bl + else: + n = n & 1 + vn = v[1][1:] + cv = vn + b = int(cv) + return n << b + elif v[1][0:2] == "HB": # 8bit high byte or numbered bit %HB + if v[1][2:3].isdigit: + cv = v[1][2:3] + bn = int(cv) + cv = vn + return (int(cv) & 255) << (8 + bn) + cv = vn + return (int(cv) & 255) << 8 + elif v[1][0:2] == "LB": # 8bit low byte or numbered bit %LB + if v[1][2:3].isdigit: + cv = v[1][2:3] + bn = int(cv) + cv = vn + return (int(cv) & 255) << (bn) + cv = vn + return (int(cv) & 255) + else: + raise ValueError("Invalid opentherm number format \'" + v[1] + "\'") + cv = val # pure number + n = int(cv) + return n + except: + logging.error("Invalid numeric value \'" + cv + "\'") + raise + diff --git a/decoders/opentherm/pd.py b/decoders/opentherm/pd.py new file mode 100644 index 00000000..7cbedbfd --- /dev/null +++ b/decoders/opentherm/pd.py @@ -0,0 +1,424 @@ +## +## This file is part of the libsigrokdecode project. +## +## Copyright (C) 2024 MaxWolf b8e06912cff61c7fc1f5df01ba2f43de51b04ce33fd4d351ce86a40c0cbf9abb +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with this program; if not, see . +## + + +## +## Opentherm protocol decoder based on "The OpenTherm(TM) Communications Protocol. A Point-to-Point Communications System for HVAC Controls. Protocol Specification v2.2" +## OpenTherm+ Manchester/Bi-phase-L coding +## + +import sigrokdecode as srd +from .lists import * +from .otdecoder import * + +class SamplerateError(Exception): + pass + + +class AnnIdx: # index to Decoder::annotations + ann_bit = 0 + ann_startbit = 1 + ann_stopbit = 2 + ann_paritybit = 3 + ann_msgtype = 4 + ann_spare = 5 + ann_dataid = 6 + ann_datavalue = 7 + ann_master2slave = 8 + ann_slave2master = 9 + ann_frame = 10 + ann_timing = 11 + ann_warning = 12 + ann_descr = 13 + + +class Decoder(srd.Decoder): + api_version = 3 + id = 'opentherm' + name = 'OpenTherm' + longname = 'OpenTherm' + desc = 'OpenTherm protocol.' + license = 'gplv2+' + inputs = ['logic'] + outputs = [] + tags = ['OT'] + channels = ( + {'id': 'ot', 'name': 'OT', 'desc': 'OpenTherm line'}, + ) + + # configuration options + options = ( + {'id': 'polarity', 'desc': 'Polarity', 'default': 'active-low', + 'values': ('active-low', 'active-high')}, + # various timings according to https://ihormelnyk.com/Content/Pages/opentherm_library/Opentherm%20Protocol%20v2-2.pdf + {'id': 'bitlen', 'desc': 'Single bit period (us)', 'default': 1000 }, # duration of a bit + {'id': 'jitter_m', 'desc': 'Edge jitter minus (us)', 'default': 100 }, # maximum allowed time advance of bit transition + {'id': 'jitter_p', 'desc': 'Edge jitter plus (us)', 'default': 150 }, # maximum allowed time latency of bit transition + {'id': 'm2s_silence_min', 'desc': 'Master to Slave min silence (us)', 'default': 20000 }, # min period between master request and slave response + {'id': 'm2s_silence_max', 'desc': 'Master to Slave max silence (us)', 'default': 800000 }, # max period between master request and slave response + {'id': 's2m_silence_min', 'desc': 'Slave to Master min silence (us)', 'default': 100000 }, # min period between slave response and next master request + {'id': 'm2m_act_max', 'desc': 'Master req to req max period (us)', 'default': 1150000 }, # max period between master requests + {'id': 'ignore_glitches', 'desc': 'Ignore glitches up to (us)', 'default': 0 }, # ignore glitches/spikes of up to given us len + ) + + # predefined data annotation types (see also class AnnIdx) + annotations = ( + ('bit', 'Bit'), # 0 + ('startbit', 'Startbit'), # 1 + ('stopbit', 'Stopbit'), # 2 + ('paritybit', 'Paritybit'), # 3 + ('msgtype', 'MSG-TYPE'), # 4 + ('spare', 'Spare'), # 5 + ('dataid', 'DATA-ID'), # 6 + ('datavalue', 'DATA-VALUE'), # 7 + ('m2s', 'MasterToSlave'), # 8 + ('s2m', 'SlaveToMaster'), # 9 + ('frame', 'OpenThermFrame'), # 10 + ('timing', 'Timing error'), # 11 + ('warning', 'Warning'), # 12 + ('otx', 'OpenThermExchange'), # 13 + ) + + # grouping of annotation types by annotation rows (streams) visible within pulseview + annotation_rows = ( + ('bits', 'Bits', (0,)), + ('fields', 'Fields', (1, 2, 3, 4, 5, 6, 7)), + ('direction', 'Direction', (8, 9)), + ('frame', 'Frame', (10,)), + ('otx', 'Description', (13,)), + ('warnings', 'Warnings', (12,)), + ('timing', 'Timing errors', (11,)), + ) + + def __init__(self): + self.reset() + self.ot_decoder = OTDecoder() + + def reset_decoder_state(self): + self.edges = [] # list of samples for every edge in the packed being decoded + self.bits = [] # list of tuples for every decoded bit in a packet: [sample given bit is started from, bit value] + self.ss_es_bits = [] # list of tuples for every decoded bit in a packet: [start sample, end sample] + self.last_frame_edge = None + self.state = 'IDLE' # initial + + def reset(self): + self.samplerate = None + self.reset_decoder_state() + self.silence = None + self.glitchlen = 0 + self.prev_samplenum = None + self.prev_lvl = None + + def start(self): + self.out_ann = self.register(srd.OUTPUT_ANN) + + + # convert number of samples at current samplerate into time in us + def s2t(self, samples): + return int((1000000.0 * samples) / self.samplerate) + + # convert time in us into number of samples at current samplerate + def t2s(self, time): + return int((self.samplerate * time) / 1000000.0) + + def setup_calc(self): + for o in self.options: + pass + # halfbit - (duration of one bit)/2 in a number of samples + self.halfbit = self.t2s(self.options['bitlen'] / 2.0) + # short period + self.s_range = range(self.halfbit - self.t2s(self.options['jitter_m']), self.halfbit + self.t2s(self.options['jitter_p']) + 1) + # long period + self.l_range = range(self.halfbit*2 - self.t2s(self.options['jitter_m']), self.halfbit*2 + self.t2s(self.options['jitter_p']) + 1) + # min silence + s = min(self.options['m2s_silence_min'], self.options['s2m_silence_min']) + self.silence = self.t2s(s) + self.glitchlen = self.t2s(self.options['ignore_glitches']) + + def metadata(self, key, value): + if key == srd.SRD_CONF_SAMPLERATE: + self.samplerate = value # in Hz + + def putb(self, bit1, bit2, data): + ss, es = self.ss_es_bits[bit1][0], self.ss_es_bits[bit2][1] + self.put(ss, es, self.out_ann, data) + + def handle_bits(self): + a, c, d, v, p, b = 0, 0, 0, 0, 0, self.bits + wrn = "" + + if len(self.bits) < 1: + # nothing to annotate + return + + # Individual raw bits. + for i in range(len(self.bits)): + ss = self.bits[i][0] + es = self.bits[i + 1][0] if i < len(self.bits)-1 else self.bits[i][0] + self.halfbit*2 + self.ss_es_bits.append([ss, es]) + self.putb(i, i, [AnnIdx.ann_bit, ['%d' % self.bits[i][1]]]) + + # Bitfields + # + # Bits[0:0]: Start bit + s = ['Startbit: %d' % b[0][1], 'STRTB: %d' % b[0][1], 'STRB', 'S', 'S'] + self.putb(0, 0, [AnnIdx.ann_startbit, s]) + + if len(self.bits) < 2: + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_warning, ["Incomplete packet at %d-bit" % len(self.bits), "Pkt error", "I"]]) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_frame, ["Invalid frame", "Frame error", "F"]]) + return + + # Bits[1:1]: Parity bit + s = ['Paritybit: %d' % b[1][1], 'PB: %d' % b[1][1], 'PB', 'P', 'P'] + self.putb(1, 1, [AnnIdx.ann_paritybit, s]) + if len(self.bits) == 34: + for i in range(32): + p ^= b[i + 1][1] + if p == 1: + wrn = wrn + ("; " if len(wrn) > 0 else "") + "ParityError" + + if len(self.bits) < 5: + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_warning, ["Incomplete packet at %d-bit" % len(self.bits), "Pkt error", "I"]]) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_frame, ["Invalid frame", "Frame error", "F"]]) + return + + # Bits[2:4]: MSG-TYPE (MSB-first) + for i in range(3): + a |= (b[2 + i][1] << (2 - i)) + x = msg_type.get(a, ['UNK','Unknown', 'Unk']) + s = ['MSG-TYPE: %s (%d)' % (x[1], a), '%s (%d)' % (x[2], a), '%s' % x[2], '%s' % x[2], '%s' % x[2]] + self.putb(2, 4, [AnnIdx.ann_msgtype, s]) + if (a == 2) | (a == 3) | (a == 6) | (a == 7): + wrn = wrn + ("; " if len(wrn) > 0 else "") + "MSG_TYPE==%s"%x[1] + if x[0] == 'M2S': + self.putb(0, len(self.bits)-1, [AnnIdx.ann_master2slave, ['MasterToSlave', x[0]]]) + elif x[0] == 'S2M': + self.putb(0, len(self.bits)-1, [AnnIdx.ann_slave2master, ['SlaveToMaster', x[0]]]) + + if len(self.bits) < 9: + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_warning, ["Incomplete packet at %d-bit" % len(self.bits), "Pkt error", "I"]]) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_frame, ["Invalid frame %s(%d)" % (x[1], a), "Frame error", "FE"]]) + return + + # Bits[5:8]: spare + for i in range(4): + c |= (b[5 + i][1] << (3 - i)) + s = ['SPARE: %d' % (c), 'SP: %d' % c, 'SP', 'SP', 'SP'] + self.putb(5, 8, [AnnIdx.ann_spare, s]) + + if len(self.bits) < 17: + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_warning, ["Incomplete packet at %d-bit" % len(self.bits), "Pkt error", "I"]]) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_frame, ["Invalid frame %s(%d)" % (x[1], a), "Frame error", "FE"]]) + return + + # Bits[9:16]: DATA-ID + for i in range(8): + d |= (b[9 + i][1] << (7 - i)) + s = ['DATA-ID: %d / 0x%x' % (d, d), 'D-ID: %d' % d, 'ID', 'ID', 'ID'] + self.putb(9, 16, [AnnIdx.ann_dataid, s]) + + if len(self.bits) < 33: + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_warning, ["Incomplete packet at %d-bit" % len(self.bits), "Pkt error", "I"]]) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_frame, ["Invalid frame %s(%d) %d/0x%x" % (x[1], a, d, d), "Frame error", "FE"]]) + return + + # Bits[17:32]: DATA-VALUE + for i in range(16): + v |= (b[17 + i][1] << (15 - i)) + s = ['DATA-VALUE: %d / 0x%x' % (v, v), 'D-VAL: %d' % v, 'V', 'V', 'V'] + self.putb(17, 32, [AnnIdx.ann_datavalue, s]) + + if len(self.bits) < 34: + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_warning, ["Incomplete packet at %d-bit" % len(self.bits), "Pkt error", "I"]]) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_frame, ["Invalid frame %s(%d) %d/0x%x %d/0x%x" % (x[1], a, d, d, v, v), "Frame error", "FE"]]) + return + + # Bits[33:33]: Stop bit + s = ['Stopbit: %d' % b[33][1], 'STPB: %d' % b[33][1], 'STPB', 'S', 'S'] + self.putb(33, 33, [AnnIdx.ann_stopbit, s]) + if b[33][1] != 1: + wrn = wrn + ("; " if len(wrn) > 0 else "") + "Invalid stop bit" + + if len(wrn) > 0: + self.putb(0, 33, [AnnIdx.ann_warning, [wrn, "!"]]) + + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_frame, ["Frame %s %s(%d) %d/0x%x %d/0x%x" % (x[0], x[1], a, d, d, v, v), "Frame %s" % x[1], "F"]]) + + if a == 0: # READ-DATA + s, c = self.ot_decoder.describe_param(d, "R", v, -1) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_descr, ["Descr: " + s]]) + elif a == 1: # WRITE-DATA + s, c = self.ot_decoder.describe_param(d, "W", v, -1) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_descr, ["Descr: " + s]]) + elif a == 4: # READ-ACK + s, c = self.ot_decoder.describe_param(d, "R", -1, v) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_descr, ["Descr: " + s]]) + elif a == 1: # WRITE-ACK + s, c = self.ot_decoder.describe_param(d, "W", -1, v) + self.putb(0, len(self.bits) - 1, [AnnIdx.ann_descr, ["Descr: " + s]]) + + + def edge_type(self, ss, es): + # Categorize according to distance from last edge (short/long). + distance = es - ss + if distance in self.l_range: + ret = 'l' + elif distance in self.s_range: + ret = 's' + else: + ret = 'e' # Error, invalid edge distance. + return ret + + def handle_timing_error(self, type, tss, tes): + self.put(tss, tes, self.out_ann, [AnnIdx.ann_timing, ["Timing error (%d us)" % self.s2t(tes - tss), "Timing", "T"]]) + self.reset_decoder_state() + self.last_frame_edge = tes + + + def decode(self): + if self.samplerate is None: + raise SamplerateError('Cannot decode without samplerate.') + + bit = None + + while True: + if self.silence is None: + self.setup_calc() # it was not properly called in time + + (self.lvl,) = self.wait({0: 'e'}) # either raising or falling edge + + pass + + if self.prev_samplenum is None: + self.prev_samplenum = self.samplenum + self.prev_lvl = self.lvl + continue + + if self.glitchlen != 0: + if (self.samplenum - self.prev_samplenum <= self.glitchlen): + self.put(self.prev_samplenum, self.samplenum, self.out_ann, [AnnIdx.ann_warning, ["Glitch (%d us)" % self.s2t(self.samplenum - self.prev_samplenum), "Glitch", "G"]]) + self.prev_samplenum = None + self.prev_lvl = self.lvl + continue + + self.c_samplenum = self.prev_samplenum + self.c_lvl = self.prev_lvl + self.prev_samplenum = self.samplenum + self.prev_lvl = self.lvl + + if len(self.edges) > 0: + pass + else: + pass + + self.edges.append(self.c_samplenum) + # from now self.edges[-1] == self.c_samplenum and previous edge (if exists) is self.edges[-2] + + # FSM + + if self.state == 'IDLE': + if self.last_frame_edge is not None and (self.c_samplenum - self.last_frame_edge) < self.halfbit * 4: + self.put(self.last_frame_edge, self.c_samplenum, self.out_ann, [AnnIdx.ann_warning, ["Sync error: silence too short", "Sync err", "S"]]) + self.last_frame_edge= self.c_samplenum + continue + + if self.options['polarity'] == 'active-low': + if self.c_lvl == 1: # it is a rising edge -> possible active-low start bit + self.state = 'SYNC' + else: + if self.c_lvl == 0: # there was a rising edge -> possible active-high start bit + self.state = 'SYNC' + continue + + edge = self.edge_type(self.edges[-2], self.edges[-1]) + + + if self.state == 'SYNC': + if edge == 's': + self.state = 'MID1' + bit = 1 + bitpos = self.edges[-2] + else: + self.put(self.edges[-2], self.edges[-1], self.out_ann, [AnnIdx.ann_warning, ["Sync error: start bit len error", "Sync err", "S"]]) + self.handle_timing_error("SYNC/" + edge, self.edges[-2], self.edges[-1]) + bit = None + elif self.state == 'MID1': + if edge == 's': + self.state = 'START1' + bit = None + elif edge == 'l': + self.state = 'MID0' + bit = 0 + bitpos = self.c_samplenum - self.halfbit + else: + if self.edges[-1] - self.edges[-2] >= self.silence: + self.handle_bits() + self.handle_timing_error("MID1/" + edge + "/slnc+", self.edges[-2], self.edges[-1]) + self.edges.append(self.c_samplenum) + self.state = 'SYNC' + else: + self.handle_bits() + self.handle_timing_error("MID1/" + edge + "/slnc-", self.edges[-2], self.edges[-1]) + bit = None + elif self.state == 'MID0': + if edge == 's': + self.state = 'START0' + bit = None + elif edge == 'l': + self.state = 'MID1' + bit = 1 + bitpos = self.c_samplenum - self.halfbit + else: + self.handle_bits() + self.handle_timing_error("MID0/" + edge, self.edges[-2], self.edges[-1]) + bit = None + elif self.state == 'START1': + if edge == 's': + self.state = 'MID1' + bit = 1 + bitpos = self.edges[-2] + else: + self.handle_bits() + self.handle_timing_error("START1/" + edge, self.edges[-2], self.edges[-1]) + bit = None + elif self.state == 'START0': + if edge == 's': + self.state = 'MID0' + bit = 0 + bitpos = self.edges[-2] + else: + if self.edges[-1] - self.edges[-2] >= self.silence: + self.handle_bits() + self.handle_timing_error("START0/" + edge + "/slnc+", self.edges[-2], self.edges[-1]) + self.edges.append(self.c_samplenum) + self.state = 'SYNC' + else: + self.handle_bits() + self.handle_timing_error("START0/" + edge + "/slnc-", self.edges[-2], self.edges[-1]) + bit = None + + + if bit is not None: + self.bits.append([bitpos, bit]) + + if len(self.bits) == 34: + self.handle_bits() + self.reset_decoder_state() + self.last_frame_edge = self.c_samplenum