From fb248c04c3a96c90aab6472d8641683281f46f69 Mon Sep 17 00:00:00 2001 From: Jorge Solla Date: Sun, 2 Sep 2018 22:01:33 +0200 Subject: [PATCH] Add HDMI CEC protocol decoder. --- decoders/cec/__init__.py | 25 +++ decoders/cec/pd.py | 322 +++++++++++++++++++++++++++++++++++ decoders/cec/protocoldata.py | 126 ++++++++++++++ 3 files changed, 473 insertions(+) create mode 100644 decoders/cec/__init__.py create mode 100644 decoders/cec/pd.py create mode 100644 decoders/cec/protocoldata.py diff --git a/decoders/cec/__init__.py b/decoders/cec/__init__.py new file mode 100644 index 0000000..db288ab --- /dev/null +++ b/decoders/cec/__init__.py @@ -0,0 +1,25 @@ +## +## This file is part of the libsigrokdecode project. +## +## Copyright (C) 2018 Jorge Solla Rubiales +## +## 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 . +## + +''' +Consumer Electronics Control (CEC) protocol allows users to command and +control devices connected through HDMI. +''' + +from .pd import Decoder diff --git a/decoders/cec/pd.py b/decoders/cec/pd.py new file mode 100644 index 0000000..6c84a4b --- /dev/null +++ b/decoders/cec/pd.py @@ -0,0 +1,322 @@ +## +## This file is part of the libsigrokdecode project. +## +## Copyright (C) 2018 Jorge Solla Rubiales +## +## 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 . +## + +import sigrokdecode as srd +from .protocoldata import * + +# Pulse types +class Pulse: + INVALID, START, ZERO, ONE = range(4) + +# Protocol stats +class Stat: + WAIT_START, GET_BITS, WAIT_EOM, WAIT_ACK = range(4) + +# Pulse times in milliseconds +timing = { + Pulse.START: { + 'low': { 'min': 3.5, 'max': 3.9 }, + 'total': { 'min': 4.3, 'max': 4.7 } + }, + Pulse.ZERO: { + 'low': { 'min': 1.3, 'max': 1.7 }, + 'total': { 'min': 2.05, 'max': 2.75 } + }, + Pulse.ONE: { + 'low': { 'min': 0.4, 'max': 0.8 }, + 'total': { 'min': 2.05, 'max': 2.75 } + } +} + +class ChannelError(Exception): + pass + +class Decoder(srd.Decoder): + api_version = 3 + id = 'cec' + name = 'CEC' + longname = 'HDMI-CEC' + desc = 'HDMI Consumer Electronics Control (CEC) protocol.' + license = 'gplv2+' + inputs = ['logic'] + outputs = ['cec'] + channels = ( + {'id': 'cec', 'name': 'CEC', 'desc': 'CEC bus data'}, + ) + annotations = ( + ('st', 'Start'), + ('eom-0', 'End of message'), + ('eom-1', 'Message continued'), + ('nack', 'ACK not set'), + ('ack', 'ACK set'), + ('bits', 'Bits'), + ('bytes', 'Bytes'), + ('frames', 'Frames'), + ('sections', 'Sections'), + ('warnings', 'Warnings') + ) + annotation_rows = ( + ('bits', 'Bits', (0, 1, 2, 3, 4, 5)), + ('bytes', 'Bytes', (6,)), + ('frames', 'Frames', (7,)), + ('sections', 'Sections', (8,)), + ('warnings', 'Warnings', (9,)) + ) + + def __init__(self): + self.reset() + + def precalculate(self): + # Restrict max length of ACK/NACK labels to 2 BIT pulses. + bit_time = timing[Pulse.ZERO]['total']['min'] + bit_time = bit_time * 2 + self.max_ack_len_samples = round((bit_time / 1000) * self.samplerate) + + def reset(self): + self.stat = Stat.WAIT_START + self.samplerate = None + self.fall_start = None + self.fall_end = None + self.rise = None + self.reset_frame_vars() + + def reset_frame_vars(self): + self.eom = None + self.bit_count = 0 + self.byte_count = 0 + self.byte = 0 + self.byte_start = None + self.frame_start = None + self.frame_end = None + self.is_nack = 0 + self.cmd_bytes = [] + + def metadata(self, key, value): + if key == srd.SRD_CONF_SAMPLERATE: + self.samplerate = value + self.precalculate() + + def set_stat(self, stat): + self.stat = stat + + def handle_frame(self, is_nack): + if self.fall_start is None or self.fall_end is None: + return + + i = 0 + str = '' + while i < len(self.cmd_bytes): + str += '{:02x}'.format(self.cmd_bytes[i]['val']) + if i != (len(self.cmd_bytes) - 1): + str += ':' + i += 1 + + self.put(self.frame_start, self.frame_end, self.out_ann, [7, [str]]) + + i = 0 + operands = 0 + str = '' + while i < len(self.cmd_bytes): + if i == 0: # Parse header + (src, dst) = decode_header(self.cmd_bytes[i]['val']) + str = 'HDR: ' + src + ', ' + dst + elif i == 1: # Parse opcode + str += ' | OPC: ' + decode_opcode(self.cmd_bytes[i]['val']) + else: # Parse operands + if operands == 0: + str += ' | OPS: ' + operands += 1 + str += '0x{:02x}'.format(self.cmd_bytes[i]['val']) + if i != len(self.cmd_bytes) - 1: + str += ', ' + i += 1 + + # Header only commands are PINGS + if i == 1: + if self.eom: + str += ' | OPC: PING' + else: + str += ' | OPC: NONE. Aborted cmd' + + # Add extra information (ack of the command from the destination) + if is_nack: + str += ' | R: NACK' + else: + str += ' | R: ACK' + + self.put(self.frame_start, self.frame_end, self.out_ann, [8, [str]]) + + def process(self): + zero_time = ((self.rise - self.fall_start) / self.samplerate) * 1000.0 + total_time = ((self.fall_end - self.fall_start) / self.samplerate) * 1000.0 + pulse = Pulse.INVALID + + # VALIDATION: Identify pulse based on length of the low period + for key in timing: + if zero_time >= timing[key]['low']['min'] and zero_time <= timing[key]['low']['max']: + pulse = key + break + + # VALIDATION: Invalid pulse + if pulse == Pulse.INVALID: + self.set_stat(Stat.WAIT_START) + self.put(self.fall_start, self.fall_end, self.out_ann, [9, ['Invalid pulse: Wrong timing']]) + return + + # VALIDATION: If waiting for start, discard everything else + if self.stat == Stat.WAIT_START and pulse != Pulse.START: + self.put(self.fall_start, self.fall_end, self.out_ann, [9, ['Expected START: BIT found']]) + return + + # VALIDATION: If waiting for ACK or EOM, only BIT pulses (0/1) are expected + if (self.stat == Stat.WAIT_ACK or self.stat == Stat.WAIT_EOM) and pulse == Pulse.START: + self.put(self.fall_start, self.fall_end, self.out_ann, [9, ['Expected BIT: START received)']]) + self.set_stat(Stat.WAIT_START) + + # VALIDATION: ACK bit pulse remains high till the next frame (if any): Validate only min time of the low period + if self.stat == Stat.WAIT_ACK and pulse != Pulse.START: + if total_time < timing[pulse]['total']['min']: + pulse = Pulse.INVALID + self.put(self.fall_start, self.fall_end, self.out_ann, [9, ['ACK pulse below minimun time']]) + self.set_stat(Stat.WAIT_START) + return + + # VALIDATION / PING FRAME DETECTION: Initiator doesn't sets the EOM = 1 but stops sending when ack doesn't arrive + if self.stat == Stat.GET_BITS and pulse == Pulse.START: + # Make sure we received a complete byte to consider it a valid ping + if self.bit_count == 0: + self.handle_frame(self.is_nack) + else: + self.put(self.frame_start, self.samplenum, self.out_ann, [9, ['ERROR: Incomplete byte received']]) + + # Set wait start so we receive next frame + self.set_stat(Stat.WAIT_START) + + # VALIDATION: Check timing of the BIT (0/1) pulse in any other case (not waiting for ACK) + if self.stat != Stat.WAIT_ACK and pulse != Pulse.START: + if total_time < timing[pulse]['total']['min'] or total_time > timing[pulse]['total']['max']: + self.put(self.fall_start, self.fall_end, self.out_ann, [9, ['Bit pulse exceeds total pulse timespan']]) + pulse = Pulse.INVALID + self.set_stat(Stat.WAIT_START) + return + + if pulse == Pulse.ZERO: + bit = 0 + elif pulse == Pulse.ONE: + bit = 1 + + # STATE: WAIT START + if self.stat == Stat.WAIT_START: + self.set_stat(Stat.GET_BITS) + self.reset_frame_vars() + self.put(self.fall_start, self.fall_end, self.out_ann, [0, ['ST']]) + + # STATE: GET BITS + elif self.stat == Stat.GET_BITS: + # Reset stats on first bit + if self.bit_count == 0: + self.byte_start = self.fall_start + self.byte = 0 + + # If 1st byte of the datagram save its sample num + if len(self.cmd_bytes) == 0: + self.frame_start = self.fall_start + + self.byte += (bit << (7 - self.bit_count)) + self.bit_count += 1 + self.put(self.fall_start, self.fall_end, self.out_ann, [5, [str(bit)]]) + + if self.bit_count == 8: + self.bit_count = 0 + self.byte_count += 1 + self.set_stat(Stat.WAIT_EOM) + self.put(self.byte_start, self.samplenum, self.out_ann, [6, ['0x{:02x}'.format(self.byte)]]) + self.cmd_bytes.append({'st': self.byte_start, 'ed': self.samplenum, 'val': self.byte}) + + # STATE: WAIT EOM + elif self.stat == Stat.WAIT_EOM: + self.eom = bit + self.frame_end = self.fall_end + + if self.eom: + self.put(self.fall_start, self.fall_end, self.out_ann, [2, ['EOM=Y']]) + else: + self.put(self.fall_start, self.fall_end, self.out_ann, [1, ['EOM=N']]) + + self.set_stat(Stat.WAIT_ACK) + + # STATE: WAIT ACK + elif self.stat == Stat.WAIT_ACK: + # If a frame with broadcast destination is being sent, the ACK is + # inverted: a 0 is considered a NACK, therefore we invert the value + # of the bit here, so we match the real meaning of it. + if (self.cmd_bytes[0]['val'] & 0x0F) == 0x0F: + bit = ~bit & 0x01 + + if (self.fall_end - self.fall_start) > self.max_ack_len_samples: + ann_end = self.fall_start + self.max_ack_len_samples + else: + ann_end = self.fall_end + + if bit: + # Any NACK detected in the frame is enough to consider the + # whole frame NACK'd. + self.is_nack = 1 + self.put(self.fall_start, ann_end, self.out_ann, [3, ['NACK']]) + else: + self.put(self.fall_start, ann_end, self.out_ann, [4, ['ACK']]) + + # After ACK bit, wait for new datagram or continue reading current + # one based on EOM value. + if self.eom or self.is_nack: + self.set_stat(Stat.WAIT_START) + self.handle_frame(self.is_nack) + else: + self.set_stat(Stat.GET_BITS) + + def start(self): + self.out_ann = self.register(srd.OUTPUT_ANN) + + def decode(self): + if not self.samplerate: + raise SamplerateError('Cannot decode without samplerate.') + + # Wait for first falling edge. + self.wait({0: 'f'}) + self.fall_end = self.samplenum + + while True: + self.wait({0: 'r'}) + self.rise = self.samplenum + + if self.stat == Stat.WAIT_ACK: + self.wait([{0: 'f'}, {'skip': self.max_ack_len_samples}]) + else: + self.wait([{0: 'f'}]) + + self.fall_start = self.fall_end + self.fall_end = self.samplenum + self.process() + + # If there was a timeout while waiting for ACK: RESYNC. + # Note: This is an expected situation as no new falling edge will + # happen until next frame is transmitted. + if self.matched == (False, True): + self.wait({0: 'f'}) + self.fall_end = self.samplenum diff --git a/decoders/cec/protocoldata.py b/decoders/cec/protocoldata.py new file mode 100644 index 0000000..dd867e0 --- /dev/null +++ b/decoders/cec/protocoldata.py @@ -0,0 +1,126 @@ +## +## This file is part of the libsigrokdecode project. +## +## Copyright (C) 2018 Jorge Solla Rubiales +## +## 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 . +## + +logical_adresses = [ + 'TV', + 'Recording_1', + 'Recording_2', + 'Tuner_1', + 'Playback_1', + 'AudioSystem', + 'Tuner2', + 'Tuner3', + 'Playback_2', + 'Recording_3', + 'Tuner_4', + 'Playback_3', + 'Backup_1', + 'Backup_2', + 'FreeUse', +] + +# List taken from LibCEC. +opcodes = { + 0x82: 'ACTIVE_SOURCE', + 0x04: 'IMAGE_VIEW_ON', + 0x0D: 'TEXT_VIEW_ON', + 0x9D: 'INACTIVE_SOURCE', + 0x85: 'REQUEST_ACTIVE_SOURCE', + 0x80: 'ROUTING_CHANGE', + 0x81: 'ROUTING_INFORMATION', + 0x86: 'SET_STREAM_PATH', + 0x36: 'STANDBY', + 0x0B: 'RECORD_OFF', + 0x09: 'RECORD_ON', + 0x0A: 'RECORD_STATUS', + 0x0F: 'RECORD_TV_SCREEN', + 0x33: 'CLEAR_ANALOGUE_TIMER', + 0x99: 'CLEAR_DIGITAL_TIMER', + 0xA1: 'CLEAR_EXTERNAL_TIMER', + 0x34: 'SET_ANALOGUE_TIMER', + 0x97: 'SET_DIGITAL_TIMER', + 0xA2: 'SET_EXTERNAL_TIMER', + 0x67: 'SET_TIMER_PROGRAM_TITLE', + 0x43: 'TIMER_CLEARED_STATUS', + 0x35: 'TIMER_STATUS', + 0x9E: 'CEC_VERSION', + 0x9F: 'GET_CEC_VERSION', + 0x83: 'GIVE_PHYSICAL_ADDRESS', + 0x91: 'GET_MENU_LANGUAGE', + 0x84: 'REPORT_PHYSICAL_ADDRESS', + 0x32: 'SET_MENU_LANGUAGE', + 0x42: 'DECK_CONTROL', + 0x1B: 'DECK_STATUS', + 0x1A: 'GIVE_DECK_STATUS', + 0x41: 'PLAY', + 0x08: 'GIVE_TUNER_DEVICE_STATUS', + 0x92: 'SELECT_ANALOGUE_SERVICE', + 0x93: 'SELECT_DIGITAL_SERVICE', + 0x07: 'TUNER_DEVICE_STATUS', + 0x06: 'TUNER_STEP_DECREMENT', + 0x05: 'TUNER_STEP_INCREMENT', + 0x87: 'DEVICE_VENDOR_ID', + 0x8C: 'GIVE_DEVICE_VENDOR_ID', + 0x89: 'VENDOR_COMMAND', + 0xA0: 'VENDOR_COMMAND_WITH_ID', + 0x8A: 'VENDOR_REMOTE_BUTTON_DOWN', + 0x8B: 'VENDOR_REMOTE_BUTTON_UP', + 0x64: 'SET_OSD_STRING', + 0x46: 'GIVE_OSD_NAME', + 0x47: 'SET_OSD_NAME', + 0x8D: 'MENU_REQUEST', + 0x8E: 'MENU_STATUS', + 0x44: 'USER_CONTROL_PRESSED', + 0x45: 'USER_CONTROL_RELEASE', + 0x8F: 'GIVE_DEVICE_POWER_STATUS', + 0x90: 'REPORT_POWER_STATUS', + 0x00: 'FEATURE_ABORT', + 0xFF: 'ABORT', + 0x71: 'GIVE_AUDIO_STATUS', + 0x7D: 'GIVE_SYSTEM_AUDIO_MODE_STATUS', + 0x7A: 'REPORT_AUDIO_STATUS', + 0x72: 'SET_SYSTEM_AUDIO_MODE', + 0x70: 'SYSTEM_AUDIO_MODE_REQUEST', + 0x7E: 'SYSTEM_AUDIO_MODE_STATUS', + 0x9A: 'SET_AUDIO_RATE', +} + +def resolve_logical_address(id, is_initiator): + if id < 0 or id > 0x0F: + return 'Invalid' + + # Special handling of 0x0F. + if id == 0x0F: + if is_initiator: + return 'Unregistered' + else: + return 'Broadcast' + + return logical_adresses[id] + +def decode_header(header): + src = (header & 0xF0) >> 4 + dst = (header & 0x0F) + return (resolve_logical_address(src, 1), resolve_logical_address(dst, 0)) + +def decode_opcode(opcode): + if opcode in opcodes: + return opcodes[opcode] + else: + return 'Invalid' -- 2.30.2