diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_generic.py b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_generic.py new file mode 100644 index 0000000000..d47130af7c --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_generic.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2026 Canonical Ltd. +# Written by: +# Isaac Yang +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox 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 Checkbox. If not, see . +"""Generic mainline V4L2 HDMI RX backend. + +For any platform whose HDMI RX exposes a mainline V4L2 sub-device +(e.g. the Synopsys DesignWare ``snps-hdmirx`` driver) this backend +reads the received resolution/refresh through the *stable* V4L2 UAPI +``VIDIOC_QUERY_DV_TIMINGS`` -- no vendor shim, no fragile private ABI. + +Scope note: video and device presence are implemented and unit +tested against recorded ``v4l2_dv_timings`` buffers. Audio, RX +enable/disable and source-change events need a real V4L2 HDMI-RX +target to validate, so they raise ``NotImplementedError`` with the +exact UAPI to wire up, rather than ship unverified ctypes. The Genio +target uses ``hdmirx_genio`` and is unaffected. +""" + +import ctypes +import fcntl +import glob +import os + +from hdmirx_utils import ( + DeviceInfo, + HdmiRxBackend, + VideoInfo, + _IOR, +) + +# Driver ``name`` substrings that identify an HDMI RX capture node. +_HDMIRX_DRIVER_HINTS = ("hdmirx", "hdmi-rx", "hdmi rx") +_V4L2_DV_BT_656_1120 = 0 # struct v4l2_dv_timings.type for BT timings + + +class v4l2_fract(ctypes.Structure): + _pack_ = 1 + _fields_ = [ + ("numerator", ctypes.c_uint32), + ("denominator", ctypes.c_uint32), + ] + + +class v4l2_bt_timings(ctypes.Structure): + _pack_ = 1 + _fields_ = [ + ("width", ctypes.c_uint32), + ("height", ctypes.c_uint32), + ("interlaced", ctypes.c_uint32), + ("polarities", ctypes.c_uint32), + ("pixelclock", ctypes.c_uint64), + ("hfrontporch", ctypes.c_uint32), + ("hsync", ctypes.c_uint32), + ("hbackporch", ctypes.c_uint32), + ("vfrontporch", ctypes.c_uint32), + ("vsync", ctypes.c_uint32), + ("vbackporch", ctypes.c_uint32), + ("il_vfrontporch", ctypes.c_uint32), + ("il_vsync", ctypes.c_uint32), + ("il_vbackporch", ctypes.c_uint32), + ("standards", ctypes.c_uint32), + ("flags", ctypes.c_uint32), + ("picture_aspect", v4l2_fract), + ("cea861_vic", ctypes.c_uint8), + ("hdmi_vic", ctypes.c_uint8), + ("reserved", ctypes.c_uint8 * 46), + ] # sizeof == 124 (packed) + + +class _v4l2_dv_union(ctypes.Union): + _pack_ = 1 + _fields_ = [("bt", v4l2_bt_timings), ("reserved", ctypes.c_uint32 * 32)] + + +class v4l2_dv_timings(ctypes.Structure): + _pack_ = 1 + _fields_ = [("type", ctypes.c_uint32), ("u", _v4l2_dv_union)] + # union deliberately kept named ('u') rather than anonymous + + +# VIDIOC_QUERY_DV_TIMINGS = _IOR('V', 63, struct v4l2_dv_timings) +def _vidioc_query_dv_timings(): + return _IOR("V", 63, ctypes.sizeof(v4l2_dv_timings)) + + +def _frame_rate_hz(bt): + """Compute the refresh rate (rounded Hz) from BT timings.""" + htotal = bt.width + bt.hfrontporch + bt.hsync + bt.hbackporch + vtotal = bt.height + bt.vfrontporch + bt.vsync + bt.vbackporch + if bt.interlaced: + vtotal += bt.il_vfrontporch + bt.il_vsync + bt.il_vbackporch + denom = htotal * vtotal + if denom == 0: + return 0 + return int(round(bt.pixelclock / float(denom))) + + +def _find_hdmirx_video_node(): + """Return the /dev/videoN of the first HDMI RX node, or None.""" + for name_path in sorted(glob.glob("/sys/class/video4linux/video*/name")): + try: + with open(name_path) as handle: + name = handle.read().strip().lower() + except OSError: + continue + if any(hint in name for hint in _HDMIRX_DRIVER_HINTS): + node = os.path.join( + "/dev", os.path.basename(os.path.dirname(name_path)) + ) + if os.path.exists(node): + return node + return None + + +class V4L2Backend(HdmiRxBackend): + """Mainline V4L2 HDMI RX backend (video + presence).""" + + name = "v4l2" + + def __init__(self, device_path=None): + self._device_path = device_path or _find_hdmirx_video_node() + + def is_available(self): + return bool(self._device_path) and os.path.exists(self._device_path) + + def module_present(self): + return self.is_available() + + def _query_timings(self): + timings = v4l2_dv_timings() + fd = os.open(self._device_path, os.O_RDWR) + try: + ctypes.memset(ctypes.byref(timings), 0, ctypes.sizeof(timings)) + fcntl.ioctl(fd, _vidioc_query_dv_timings(), timings) + finally: + os.close(fd) + return timings + + def get_video_info(self): + bt = self._query_timings().u.bt + return VideoInfo( + hactive=bt.width, + vactive=bt.height, + frame_rate=_frame_rate_hz(bt), + interlaced=bool(bt.interlaced), + ) + + def get_device_info(self): + # A successful timings query with a non-zero width means the + # RX is locked to an incoming signal (i.e. a cable is present). + try: + locked = self._query_timings().u.bt.width > 0 + except OSError: + locked = False + return DeviceInfo( + connected=locked, + power_5v=locked, + hpd=locked, + video_locked=locked, + audio_locked=locked, + hdcp_version=0, + ) + + def get_audio_info(self): + raise NotImplementedError( + "audio not available via the V4L2 video node; wire up the " + "paired ALSA capture card (snd HDMI-RX) when a V4L2 target " + "is available to validate" + ) + + def set_enabled(self, on): + raise NotImplementedError( + "enable/disable has no V4L2 equivalent for HDMI RX; add " + "VIDIOC_STREAMON/OFF semantics only if a target requires it" + ) + + def wait_for_events(self, kind, timeout): + raise NotImplementedError( + "V4L2 source-change events (VIDIOC_SUBSCRIBE_EVENT + " + "V4L2_EVENT_SOURCE_CHANGE + VIDIOC_DQEVENT) are not yet " + "implemented; needs a V4L2 HDMI-RX target to validate" + ) diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_genio.py b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_genio.py new file mode 100644 index 0000000000..569c926f3f --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_genio.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2026 Canonical Ltd. +# Written by: +# Isaac Yang +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox 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 Checkbox. If not, see . +"""Genio HDMI RX backend (``/dev/hdmirx``, magic 'H'). + +This is a pure-Python reimplementation of the ioctl surface of +MediaTek's ``mtk-hdmirx-tool`` C++ utility for the Genio platform -- +written from the ABI spec (ioctl numbers, struct fields, decode +tables), not from its source. The kernel module is ``mtk_hdmirx``. +The reusable plumbing lives in ``hdmirx_utils``; this file declares +only the structs, the command table and the field mappers. + +Struct sizes are natural-aligned (NO ``_pack_``) and verified: +``HDMIRX_VID_PARA`` = 40, ``HDMIRX_DEV_INFO`` = 12, +``HDMIRX_AUD_INFO`` = 28 (AUDIO_INFOFRAME_LEN = 10). +""" + +import ctypes + +from hdmirx_utils import ( + AudioInfo, + Colorspace, + DeviceInfo, + Event, + IoctlCharBackend, + IoctlCmd, + VideoInfo, +) + + +# -------------------------------------------------------------------------- +# Video / device structs +# -------------------------------------------------------------------------- +class HDMIRX_VID_PARA(ctypes.Structure): + _fields_ = [ + ("cs", ctypes.c_int), # enum HDMIRX_CS + ("dp", ctypes.c_int), # enum HdmiRxDP + ("htotal", ctypes.c_uint32), + ("vtotal", ctypes.c_uint32), + ("hactive", ctypes.c_uint32), + ("vactive", ctypes.c_uint32), + ("is_pscan", ctypes.c_uint32), + ("hdmi_mode", ctypes.c_bool), + ("frame_rate", ctypes.c_uint32), + ("pixclk", ctypes.c_uint32), + ] # sizeof == 40 + + +class HDMIRX_DEV_INFO(ctypes.Structure): + _fields_ = [ + ("hdmirx5v", ctypes.c_uint8), + ("hpd", ctypes.c_bool), + ("power_on", ctypes.c_uint32), + ("vid_locked", ctypes.c_uint8), + ("aud_locked", ctypes.c_uint8), + ("hdcp_version", ctypes.c_uint8), + ] # sizeof == 12 + + +# -------------------------------------------------------------------------- +# Audio structs (AUDIO_INFOFRAME_LEN = 10 -> HDMIRX_AUD_INFO sizeof 28) +# All members are u8 / bitfields, so alignment is 1. +# -------------------------------------------------------------------------- +class _AudInfoFrameInfo(ctypes.Structure): + _fields_ = [ + ("Type", ctypes.c_uint8), + ("Ver", ctypes.c_uint8), + ("Len", ctypes.c_uint8), + ("AudioChannelCount", ctypes.c_uint8, 3), + ("RSVD1", ctypes.c_uint8, 1), + ("AudioCodingType", ctypes.c_uint8, 4), + ("SampleSize", ctypes.c_uint8, 2), + ("SampleFreq", ctypes.c_uint8, 3), + ("Rsvd2", ctypes.c_uint8, 3), + ("FmtCoding", ctypes.c_uint8), + ("SpeakerPlacement", ctypes.c_uint8), + ("Rsvd3", ctypes.c_uint8, 3), + ("LevelShiftValue", ctypes.c_uint8, 4), + ("DM_INH", ctypes.c_uint8, 1), + ] + + +class _AudInfoFramePkt(ctypes.Structure): + _fields_ = [ + ("AUD_HB", ctypes.c_uint8 * 3), + ("AUD_DB", ctypes.c_uint8 * 10), # AUDIO_INFOFRAME_LEN + ] + + +class _AudInfoFrame(ctypes.Union): + _fields_ = [("info", _AudInfoFrameInfo), ("pktbyte", _AudInfoFramePkt)] + + +class _AudChSts(ctypes.Structure): + _fields_ = [ + ("rev", ctypes.c_uint8, 1), + ("IsLPCM", ctypes.c_uint8, 1), + ("CopyRight", ctypes.c_uint8, 1), + ("AdditionFormatInfo", ctypes.c_uint8, 3), + ("ChannelStatusMode", ctypes.c_uint8, 2), + ("CategoryCode", ctypes.c_uint8), + ("SourceNumber", ctypes.c_uint8, 4), + ("ChannelNumber", ctypes.c_uint8, 4), + ("SamplingFreq", ctypes.c_uint8, 4), + ("ClockAccuary", ctypes.c_uint8, 2), + ("rev2", ctypes.c_uint8, 2), + ("WordLen", ctypes.c_uint8, 4), + ("OriginalSamplingFreq", ctypes.c_uint8, 4), + ] + + +class _AudCaps(ctypes.Structure): + _fields_ = [ + ("SampleFreq", ctypes.c_uint8), + ("AudInf", _AudInfoFrame), + ("CHStatusData", ctypes.c_uint8 * 5), + ("AudChStat", _AudChSts), + ] + + +class _AudExtraInfo(ctypes.Structure): + _fields_ = [ + ("is_HBRAudio", ctypes.c_bool), + ("is_DSDAudio", ctypes.c_bool), + ("is_RawSDAudio", ctypes.c_bool), + ("is_PCMMultiCh", ctypes.c_bool), + ] + + +class HDMIRX_AUD_INFO(ctypes.Structure): + _fields_ = [("caps", _AudCaps), ("info", _AudExtraInfo)] # sizeof == 28 + + +# -------------------------------------------------------------------------- +# Decode tables (verbatim from inc/hdmi_if.h + src/hdmirx_tool.cpp) +# -------------------------------------------------------------------------- +_COLORSPACE = { + 0: Colorspace.RGB, + 1: Colorspace.YUV444, + 2: Colorspace.YUV422, + 3: Colorspace.YUV420, +} +_BIT_DEPTH = {0: 8, 1: 10, 2: 12, 3: 16} # enum HdmiRxDP +_SAMPLE_FREQ_KHZ = { # caps.SampleFreq code + 0x0: 44.1, + 0x2: 48.0, + 0x3: 32.0, + 0x8: 88.2, + 0xA: 96.0, + 0xC: 176.4, + 0xE: 192.0, +} + + +def _decode_word_len_bits(word_len): + """Map the IEC 60958 WordLen nibble to a bit depth (0 = unknown). + + bit0 selects the max-word-length mode (0 -> base 16, 1 -> base 20) + and bits 3:1 (1..5) add 0..4; anything else is 'not indicated'. + """ + base = 20 if (word_len & 0x1) else 16 + index = (word_len & 0xE) >> 1 + if 1 <= index <= 5: + return base + (index - 1) + return 0 + + +class GenioIoctlBackend(IoctlCharBackend): + """Genio backend (``mtk_hdmirx`` kernel module).""" + + name = "genio" + MAGIC = "H" + DEVICE_PATH = "/dev/hdmirx" + MODULE_NAME = "mtk_hdmirx" + DEVPATH_FILTER = "hdmirx" + EVENT_MAP = { + 0: Event.PWR_5V_CHANGE, + 1: Event.TIMING_LOCK, + 2: Event.TIMING_UNLOCK, + 3: Event.AUD_LOCK, + 4: Event.AUD_UNLOCK, + 11: Event.PLUG_IN, + 12: Event.PLUG_OUT, + } + COMMANDS = { + "vid_info": IoctlCmd(1, "wr", HDMIRX_VID_PARA), + "aud_info": IoctlCmd(2, "wr", HDMIRX_AUD_INFO), + "enable": IoctlCmd(3, "w", ctypes.c_uint), + "dev_info": IoctlCmd(4, "wr", HDMIRX_DEV_INFO), + } + EXPECTED_SIZES = {"vid_info": 40, "dev_info": 12, "aud_info": 28} + + def _map_device_info(self, raw): + return DeviceInfo( + connected=bool(raw.hpd and raw.hdmirx5v), + power_5v=bool(raw.hdmirx5v), + hpd=bool(raw.hpd), + video_locked=bool(raw.vid_locked), + audio_locked=bool(raw.aud_locked), + hdcp_version=raw.hdcp_version, + ) + + def _map_video_info(self, raw): + return VideoInfo( + hactive=raw.hactive, + vactive=raw.vactive, + frame_rate=raw.frame_rate, + colorspace=_COLORSPACE.get(raw.cs), + bit_depth=_BIT_DEPTH.get(raw.dp), + interlaced=not raw.is_pscan, + ) + + def _map_audio_info(self, raw): + acc = raw.caps.AudInf.info.AudioChannelCount + return AudioInfo( + bit_depth=_decode_word_len_bits(raw.caps.AudChStat.WordLen), + channels=(acc + 1) if acc else 0, + sample_freq_khz=_SAMPLE_FREQ_KHZ.get(raw.caps.SampleFreq, 0.0), + ) diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_tool.py b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_tool.py new file mode 100644 index 0000000000..c16fc6439a --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_tool.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2026 Canonical Ltd. +# Written by: +# Isaac Yang +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox 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 Checkbox. If not, see . +"""CLI entry point for the pure-Python HDMI RX (input) test. + +This is the only script the checkbox jobs invoke. It selects a +platform backend (auto-detect or ``--backend``) and runs one +self-verifying sub-command per job. Exit status is 0 on pass and 1 +on failure; ``--json`` prints a machine-readable result. +""" + +import argparse +import json +import sys +import time + +from hdmirx_utils import ( + AbiMismatch, + verify_audio, + verify_events, + verify_video, +) +from hdmirx_generic import V4L2Backend +from hdmirx_genio import GenioIoctlBackend + +# Registry order: prefer the platform-specific ioctl backend, then the +# generic mainline V4L2 one. +BACKENDS = [GenioIoctlBackend, V4L2Backend] + + +class NoBackendError(RuntimeError): + """No HDMI RX backend is available on this system.""" + + +def detect(preferred=None): + """Return an available backend instance. + + With ``preferred`` (a backend ``name``) that backend is forced even + if it does not detect itself, so failures surface with a clear + error. Otherwise the first backend whose ``is_available()`` is true + wins. Raises ``NoBackendError`` if none is available. + """ + if preferred: + for cls in BACKENDS: + if cls.name == preferred: + return cls() + raise NoBackendError( + "unknown backend {!r}; known: {}".format( + preferred, ", ".join(c.name for c in BACKENDS) + ) + ) + for cls in BACKENDS: + backend = cls() + if backend.is_available(): + return backend + raise NoBackendError( + "no HDMI RX backend available (tried: {})".format( + ", ".join(c.name for c in BACKENDS) + ) + ) + + +def _poll_until(predicate, timeout=5.0, interval=0.5): + """Poll ``predicate`` until true or ``timeout`` (seconds) elapses.""" + deadline = time.monotonic() + timeout + while True: + if predicate(): + return True + if time.monotonic() >= deadline: + return False + time.sleep(interval) + + +# -------------------------------------------------------------------------- +# Sub-command handlers: each returns (reasons, data). reasons empty == pass. +# -------------------------------------------------------------------------- +def _cmd_module_check(backend, args): + if backend.module_present(): + return [], {"module_present": True} + return ["HDMI RX kernel driver is not loaded"], {"module_present": False} + + +def _cmd_device_info(backend, args): + info = backend.get_device_info() + return [], info._asdict() + + +def _cmd_cable(backend, args): + info = backend.get_device_info() + data = { + "connected": info.connected, + "state": "hdmi connected" if info.connected else "hdmi disconnected", + } + if info.connected: + return [], data + return ["HDMI RX cable not connected (hpd/5v low)"], data + + +def _cmd_video_info(backend, args): + info = backend.get_video_info() + data = info._asdict() + if None in (args.rh, args.rv, args.rr): + return [], data + return verify_video(info, args.rh, args.rv, args.rr), data + + +def _cmd_audio_info(backend, args): + info = backend.get_audio_info() + data = info._asdict() + if None in (args.ab, args.ac, args.asf): + return [], data + return verify_audio(info, args.ab, args.ac, args.asf), data + + +def _cmd_enable(backend, args): + backend.set_enabled(True) + return [], {"enabled": True} + + +def _cmd_disable(backend, args): + backend.set_enabled(False) + return [], {"enabled": False} + + +def _cmd_disable_then_enable(backend, args): + reasons = [] + backend.set_enabled(False) + disconnected = _poll_until( + lambda: not backend.get_device_info().connected, args.timeout + ) + if not disconnected: + reasons.append("cable still reported connected after disable") + backend.set_enabled(True) + reconnected = _poll_until( + lambda: backend.get_device_info().connected, args.timeout + ) + if not reconnected: + reasons.append("cable not reported connected after re-enable") + return reasons, { + "disconnected_after_disable": disconnected, + "reconnected_after_enable": reconnected, + } + + +def _cmd_wait_event(backend, args): + got = backend.wait_for_events(args.kind, args.timeout) + data = {"observed": sorted(e.value for e in got)} + return verify_events(got, args.kind, args.with_zapper), data + + +def _cmd_abi_selfcheck(backend, args): + selfcheck = getattr(backend, "abi_selfcheck", None) + if selfcheck is None: + return [], {"skipped": "backend has no fixed ABI"} + try: + selfcheck() + except AbiMismatch as exc: + return [str(exc)], {"ok": False} + return [], {"ok": True} + + +HANDLERS = { + "module-check": _cmd_module_check, + "device-info": _cmd_device_info, + "cable": _cmd_cable, + "video-info": _cmd_video_info, + "audio-info": _cmd_audio_info, + "enable": _cmd_enable, + "disable": _cmd_disable, + "disable-then-enable": _cmd_disable_then_enable, + "wait-event": _cmd_wait_event, + "abi-selfcheck": _cmd_abi_selfcheck, +} + + +def build_parser(): + parser = argparse.ArgumentParser( + description="Pure-Python HDMI RX (input) test tool." + ) + parser.add_argument( + "--backend", + choices=[c.name for c in BACKENDS], + help="force a backend instead of auto-detecting", + ) + parser.add_argument( + "--json", action="store_true", help="print a JSON result" + ) + sub = parser.add_subparsers(dest="command") + sub.required = True + + for name in ( + "module-check", + "device-info", + "cable", + "enable", + "disable", + "abi-selfcheck", + ): + sub.add_parser(name) + + p_video = sub.add_parser("video-info") + p_video.add_argument("-rh", type=int, dest="rh", help="expected width") + p_video.add_argument("-rv", type=int, dest="rv", help="expected height") + p_video.add_argument("-rr", type=int, dest="rr", help="expected rate") + + p_audio = sub.add_parser("audio-info") + p_audio.add_argument("-ab", type=int, dest="ab", help="expected bits") + p_audio.add_argument("-ac", type=int, dest="ac", help="expected chans") + p_audio.add_argument("-asf", type=float, dest="asf", help="expected kHz") + + p_dte = sub.add_parser("disable-then-enable") + p_dte.add_argument("--timeout", type=float, default=5.0) + + p_evt = sub.add_parser("wait-event") + p_evt.add_argument("kind", choices=["plug", "unplug"]) + p_evt.add_argument("--timeout", type=float, default=15.0) + p_evt.add_argument("--with-zapper", action="store_true") + return parser + + +def main(argv=None): + args = build_parser().parse_args(argv) + try: + backend = detect(args.backend) + except NoBackendError as exc: + _report(args, "detect", ["{}".format(exc)], {}, backend_name="none") + return 1 + + try: + reasons, data = HANDLERS[args.command](backend, args) + except NotImplementedError as exc: + reasons, data = ["not supported on this backend: {}".format(exc)], {} + except (OSError, ValueError) as exc: + reasons, data = ["{}: {}".format(type(exc).__name__, exc)], {} + + _report(args, args.command, reasons, data, backend.name) + return 0 if not reasons else 1 + + +def _report(args, command, reasons, data, backend_name): + passed = not reasons + if args.json: + print( + json.dumps( + { + "backend": backend_name, + "command": command, + "passed": passed, + "reasons": reasons, + "data": data, + }, + sort_keys=True, + ) + ) + return + print("backend: {}".format(backend_name)) + for key, value in sorted(data.items()): + print(" {} = {}".format(key, value)) + if passed: + print("PASS") + else: + for reason in reasons: + print("FAIL: {}".format(reason)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_utils.py b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_utils.py new file mode 100644 index 0000000000..dea3b88792 --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/bin/hdmirx_utils.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2026 Canonical Ltd. +# Written by: +# Isaac Yang +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox 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 Checkbox. If not, see . +"""Shared core for the pure-Python HDMI RX (input) test. + +This module carries everything that is platform independent: + +* the data models (`DeviceInfo`, `VideoInfo`, `AudioInfo`) and the + `Event` enum used by the checkbox jobs; +* the `HdmiRxBackend` contract every platform backend implements; +* the Linux `_IOC` ioctl-number helpers; +* `IoctlCharBackend`, a reusable base for any driver that is a custom + ioctl character device (a new vendor is data + mappers only); +* `uevent_wait`, a stdlib netlink KOBJECT_UEVENT listener; and +* the `verify_*` helpers that turn readings into pass/fail reasons. + +Syntax is kept Python 3.5-safe because the provider tox gate still +runs py35/py36 (namedtuples instead of dataclasses, ``.format()`` +instead of f-strings, no variable annotations). +""" + +import collections +import ctypes +import fcntl +import os +import select +import socket +import time +from abc import ABC, abstractmethod +from enum import Enum + + +# -------------------------------------------------------------------------- +# Models +# -------------------------------------------------------------------------- +class Colorspace(Enum): + RGB = "RGB" + YUV444 = "YUV444" + YUV422 = "YUV422" + YUV420 = "YUV420" + + +class Event(Enum): + """HDMI RX notifications. + + The *value* is the ``HDMI_RX_*`` string the checkbox job text + asserts on. The numeric driver code is NOT implied by member + order -- each backend declares its own mapping in ``EVENT_MAP``. + """ + + PWR_5V_CHANGE = "HDMI_RX_PWR_5V_CHANGE" + TIMING_LOCK = "HDMI_RX_TIMING_LOCK" + TIMING_UNLOCK = "HDMI_RX_TIMING_UNLOCK" + AUD_LOCK = "HDMI_RX_AUD_LOCK" + AUD_UNLOCK = "HDMI_RX_AUD_UNLOCK" + PLUG_IN = "HDMI_RX_PLUG_IN" + PLUG_OUT = "HDMI_RX_PLUG_OUT" + + +DeviceInfo = collections.namedtuple( + "DeviceInfo", + [ + "connected", + "power_5v", + "hpd", + "video_locked", + "audio_locked", + "hdcp_version", + ], +) + +VideoInfo = collections.namedtuple( + "VideoInfo", + [ + "hactive", + "vactive", + "frame_rate", + "colorspace", + "bit_depth", + "interlaced", + ], +) +VideoInfo.__new__.__defaults__ = (None, None, None) # last 3 optional + +AudioInfo = collections.namedtuple( + "AudioInfo", ["bit_depth", "channels", "sample_freq_khz"] +) + +# One custom-ioctl command. direction is 'wr' | 'w' | 'r'; ctype is a +# ctypes.Structure subclass (or a ctypes scalar such as c_uint). +IoctlCmd = collections.namedtuple("IoctlCmd", ["nr", "direction", "ctype"]) + + +class HdmiRxBackend(ABC): + """Contract every platform backend implements. + + Concrete backends are registered in ``hdmirx_tool`` and selected + by auto-detection (or ``--backend``). + """ + + name = "" # short id shown by --backend and used by the registry + + @abstractmethod + def is_available(self): + """True when this platform's RX device is present.""" + + @abstractmethod + def module_present(self): + """True when the kernel driver is loaded (module-detect).""" + + @abstractmethod + def get_device_info(self): + """Return a DeviceInfo.""" + + @abstractmethod + def get_video_info(self): + """Return a VideoInfo.""" + + @abstractmethod + def get_audio_info(self): + """Return an AudioInfo.""" + + @abstractmethod + def set_enabled(self, on): + """Enable (True) or disable (False) the RX path.""" + + @abstractmethod + def wait_for_events(self, kind, timeout): + """kind in {'plug','unplug'}; return the set of Event seen.""" + + +# -------------------------------------------------------------------------- +# Linux ioctl number helpers (asm-generic encoding) +# -------------------------------------------------------------------------- +_IOC_TYPESHIFT = 8 +_IOC_SIZESHIFT = 16 +_IOC_DIRSHIFT = 30 +_IOC_WRITE = 1 +_IOC_READ = 2 + + +def _IOC(direction, magic, nr, size): + return ( + (direction << _IOC_DIRSHIFT) + | (ord(magic) << _IOC_TYPESHIFT) + | nr + | (size << _IOC_SIZESHIFT) + ) + + +def _IOW(magic, nr, size): + return _IOC(_IOC_WRITE, magic, nr, size) + + +def _IOR(magic, nr, size): + return _IOC(_IOC_READ, magic, nr, size) + + +def _IOWR(magic, nr, size): + return _IOC(_IOC_READ | _IOC_WRITE, magic, nr, size) + + +# -------------------------------------------------------------------------- +# Netlink uevent listener (pure stdlib, no ABI surface) +# -------------------------------------------------------------------------- +NETLINK_KOBJECT_UEVENT = 15 + + +def uevent_wait(key, event_map, timeout, devpath_filter=None): + """Collect Events from kernel uevents for up to ``timeout`` seconds. + + key: the payload key to read, e.g. ``"SWITCH_NOTIFY"``. + event_map: {driver code (int): Event}. + devpath_filter: optional substring a datagram must contain (e.g. + ``"hdmirx"``) so unrelated subsystems are ignored. + + Returns the set of Event seen before the deadline (possibly empty). + """ + sock = socket.socket( + socket.AF_NETLINK, + socket.SOCK_DGRAM | socket.SOCK_CLOEXEC, + NETLINK_KOBJECT_UEVENT, + ) + got = set() + try: + sock.bind((0, 1)) # nl_pid=0 (kernel assigns), group 1 = uevents + poller = select.poll() + poller.register(sock, select.POLLIN) + prefix = key.encode() + b"=" + prefix_len = len(prefix) + needle = devpath_filter.encode() if devpath_filter else None + deadline = time.monotonic() + timeout + while True: + remaining_ms = int((deadline - time.monotonic()) * 1000) + if remaining_ms <= 0 or not poller.poll(remaining_ms): + break + fields = sock.recv(8192).split(b"\0") + if needle is not None and not any(needle in f for f in fields): + continue + for field in fields: + if field.startswith(prefix): + got.add(_decode_event(field[prefix_len:], event_map)) + got.discard(None) + finally: + sock.close() + return got + + +def _decode_event(raw_value, event_map): + try: + return event_map.get(int(raw_value)) + except (ValueError, TypeError): + return None + + +# -------------------------------------------------------------------------- +# Reusable ioctl-char-device backend base +# -------------------------------------------------------------------------- +class IoctlError(OSError): + """An ioctl call failed; carries the command key and device path.""" + + +class AbiMismatch(RuntimeError): + """A ctypes struct size differs from the expected kernel size.""" + + +class IoctlCharBackend(HdmiRxBackend): + """Base for a driver exposed as a custom ioctl character device. + + A platform supplies DATA only: ``MAGIC``, ``DEVICE_PATH``, + ``MODULE_NAME``, ``DEVPATH_FILTER``, ``COMMANDS`` (keys + ``dev_info`` / ``vid_info`` / ``aud_info`` / ``enable``), + ``UEVENT_KEY``, ``EVENT_MAP``, ``EXPECTED_SIZES`` -- plus the + three pure ``_map_*`` methods. The open/ioctl/errno/``_IOC``/ + self-check/uevent plumbing lives here once. + """ + + MAGIC = "" + DEVICE_PATH = "" + MODULE_NAME = None + DEVPATH_FILTER = None + COMMANDS = {} + UEVENT_KEY = "SWITCH_NOTIFY" + EVENT_MAP = {} + EXPECTED_SIZES = {} + + def is_available(self): + return bool(self.DEVICE_PATH) and os.path.exists(self.DEVICE_PATH) + + def module_present(self): + if not self.MODULE_NAME: + return self.is_available() + return os.path.isdir("/sys/module/{}".format(self.MODULE_NAME)) + + def _request(self, cmd_key): + cmd = self.COMMANDS[cmd_key] + encode = {"wr": _IOWR, "w": _IOW, "r": _IOR}[cmd.direction] + return encode(self.MAGIC, cmd.nr, ctypes.sizeof(cmd.ctype)) + + def _ioctl(self, cmd_key, arg): + fd = os.open(self.DEVICE_PATH, os.O_RDWR) + try: + fcntl.ioctl(fd, self._request(cmd_key), arg) + except OSError as exc: + raise IoctlError( + "ioctl {} on {} failed: {}".format( + cmd_key, self.DEVICE_PATH, exc + ) + ) + finally: + os.close(fd) + return arg + + def abi_selfcheck(self): + """Raise AbiMismatch if any struct size differs from the ABI.""" + for cmd_key, want in sorted(self.EXPECTED_SIZES.items()): + got = ctypes.sizeof(self.COMMANDS[cmd_key].ctype) + if got != want: + raise AbiMismatch( + "{}: ctypes sizeof {} != expected kernel size {} " + "-- regenerate structs".format(cmd_key, got, want) + ) + + def set_enabled(self, on): + self._ioctl("enable", ctypes.c_uint(1 if on else 0)) + + def get_device_info(self): + raw = self._ioctl("dev_info", self.COMMANDS["dev_info"].ctype()) + return self._map_device_info(raw) + + def get_video_info(self): + raw = self._ioctl("vid_info", self.COMMANDS["vid_info"].ctype()) + return self._map_video_info(raw) + + def get_audio_info(self): + raw = self._ioctl("aud_info", self.COMMANDS["aud_info"].ctype()) + return self._map_audio_info(raw) + + def wait_for_events(self, kind, timeout): + return uevent_wait( + self.UEVENT_KEY, + self.EVENT_MAP, + timeout, + devpath_filter=self.DEVPATH_FILTER, + ) + + @abstractmethod + def _map_device_info(self, raw): + """Map a raw ctypes struct to a DeviceInfo.""" + + @abstractmethod + def _map_video_info(self, raw): + """Map a raw ctypes struct to a VideoInfo.""" + + @abstractmethod + def _map_audio_info(self, raw): + """Map a raw ctypes struct to an AudioInfo.""" + + +# -------------------------------------------------------------------------- +# Verification helpers (expected vs actual -> list of reason strings) +# -------------------------------------------------------------------------- +_PLUG_EVENTS = frozenset( + [Event.PWR_5V_CHANGE, Event.PLUG_IN, Event.TIMING_LOCK, Event.AUD_LOCK] +) +_UNPLUG_EVENTS = frozenset( + [ + Event.AUD_UNLOCK, + Event.TIMING_UNLOCK, + Event.PWR_5V_CHANGE, + Event.PLUG_OUT, + ] +) +# With a zapper the cable stays physically connected, so the physical +# hot-plug events never fire -- only the signal lock/unlock ones do. +_ZAPPER_DISCARD = frozenset( + [Event.PWR_5V_CHANGE, Event.PLUG_IN, Event.PLUG_OUT] +) + + +def expected_event_set(kind, with_zapper=False): + """Return the set of Event a plug/unplug action should produce.""" + if kind == "plug": + expected = set(_PLUG_EVENTS) + elif kind == "unplug": + expected = set(_UNPLUG_EVENTS) + else: + raise ValueError("kind must be 'plug' or 'unplug', got %r" % kind) + if with_zapper: + expected -= _ZAPPER_DISCARD + return expected + + +def verify_events(got, kind, with_zapper=False): + """Return reasons the observed events do not match expectations.""" + expected = expected_event_set(kind, with_zapper) + missing = expected - set(got) + if not missing: + return [] + names = ", ".join(sorted(e.value for e in missing)) + return ["missing {} event(s): {}".format(kind, names)] + + +def verify_video(actual, exp_h, exp_v, exp_rate): + """Return reasons the video reading differs from expectations.""" + reasons = [] + checks = ( + ("hactive", actual.hactive, int(exp_h)), + ("vactive", actual.vactive, int(exp_v)), + ("frame_rate", actual.frame_rate, int(exp_rate)), + ) + for field, got, want in checks: + if got != want: + reasons.append("{} {} != expected {}".format(field, got, want)) + return reasons + + +def verify_audio(actual, exp_bits, exp_channels, exp_freq_khz): + """Return reasons the audio reading differs from expectations.""" + reasons = [] + if actual.bit_depth != int(exp_bits): + reasons.append( + "bit_depth {} != expected {}".format( + actual.bit_depth, int(exp_bits) + ) + ) + if actual.channels != int(exp_channels): + reasons.append( + "channels {} != expected {}".format( + actual.channels, int(exp_channels) + ) + ) + if abs(actual.sample_freq_khz - float(exp_freq_khz)) > 0.01: + reasons.append( + "sample_freq_khz {} != expected {}".format( + actual.sample_freq_khz, float(exp_freq_khz) + ) + ) + return reasons diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_generic.py b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_generic.py new file mode 100644 index 0000000000..3efa677672 --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_generic.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2026 Canonical Ltd. +# Written by: +# Isaac Yang +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox 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 Checkbox. If not, see . +"""Hardware-free tests for the mainline V4L2 HDMI RX backend.""" + +import ctypes +import unittest +from unittest.mock import mock_open, patch + +import hdmirx_generic as G +from hdmirx_generic import ( + V4L2Backend, + _frame_rate_hz, + v4l2_bt_timings, + v4l2_dv_timings, +) + + +class TestStructSizes(unittest.TestCase): + def test_uapi_struct_sizes(self): + self.assertEqual(ctypes.sizeof(v4l2_bt_timings), 124) + self.assertEqual(ctypes.sizeof(v4l2_dv_timings), 132) + + +class TestFrameRate(unittest.TestCase): + def _bt_1080p60(self): + bt = v4l2_bt_timings() + bt.width = 1920 + bt.height = 1080 + bt.hfrontporch, bt.hsync, bt.hbackporch = 88, 44, 148 + bt.vfrontporch, bt.vsync, bt.vbackporch = 4, 5, 36 + bt.pixelclock = 148500000 + return bt + + def test_computes_60hz(self): + # htotal=2200, vtotal=1125 -> 148.5MHz / 2475000 = 60 + self.assertEqual(_frame_rate_hz(self._bt_1080p60()), 60) + + def test_zero_totals_do_not_divide_by_zero(self): + self.assertEqual(_frame_rate_hz(v4l2_bt_timings()), 0) + + def test_interlaced_adds_field_blanking(self): + bt = self._bt_1080p60() + bt.interlaced = 1 + bt.il_vfrontporch, bt.il_vsync, bt.il_vbackporch = 2, 5, 15 + # vtotal grows by 22 -> 1147; 148.5MHz / (2200*1147) rounds to 59. + self.assertEqual(_frame_rate_hz(bt), 59) + + +class TestGetVideoInfo(unittest.TestCase): + def test_reads_dv_timings(self): + backend = V4L2Backend(device_path="/dev/video0") + + def fake_ioctl(fd, request, timings): + timings.u.bt.width = 1920 + timings.u.bt.height = 1080 + timings.u.bt.hfrontporch = 88 + timings.u.bt.hsync = 44 + timings.u.bt.hbackporch = 148 + timings.u.bt.vfrontporch = 4 + timings.u.bt.vsync = 5 + timings.u.bt.vbackporch = 36 + timings.u.bt.pixelclock = 148500000 + + with patch("hdmirx_generic.os.open", return_value=5), patch( + "hdmirx_generic.os.close" + ), patch("hdmirx_generic.fcntl.ioctl", side_effect=fake_ioctl): + info = backend.get_video_info() + self.assertEqual(info.hactive, 1920) + self.assertEqual(info.vactive, 1080) + self.assertEqual(info.frame_rate, 60) + self.assertFalse(info.interlaced) + + +class TestNodeDiscovery(unittest.TestCase): + def test_matches_hdmirx_name(self): + with patch( + "hdmirx_generic.glob.glob", + return_value=["/sys/class/video4linux/video3/name"], + ), patch( + "hdmirx_generic.open", + mock_open(read_data="snps-hdmirx\n"), + create=True, + ), patch( + "hdmirx_generic.os.path.exists", return_value=True + ): + self.assertEqual(G._find_hdmirx_video_node(), "/dev/video3") + + def test_ignores_non_hdmirx_name(self): + with patch( + "hdmirx_generic.glob.glob", + return_value=["/sys/class/video4linux/video0/name"], + ), patch( + "hdmirx_generic.open", + mock_open(read_data="some-camera\n"), + create=True, + ), patch( + "hdmirx_generic.os.path.exists", return_value=True + ): + self.assertIsNone(G._find_hdmirx_video_node()) + + +class TestUnsupportedOperations(unittest.TestCase): + def setUp(self): + self.backend = V4L2Backend(device_path="/dev/video0") + + def test_audio_not_implemented(self): + with self.assertRaises(NotImplementedError): + self.backend.get_audio_info() + + def test_enable_not_implemented(self): + with self.assertRaises(NotImplementedError): + self.backend.set_enabled(True) + + def test_events_not_implemented(self): + with self.assertRaises(NotImplementedError): + self.backend.wait_for_events("plug", 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_genio.py b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_genio.py new file mode 100644 index 0000000000..c073744a16 --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_genio.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2026 Canonical Ltd. +# Written by: +# Isaac Yang +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox 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 Checkbox. If not, see . +"""Hardware-free tests for the Genio ioctl backend. + +The struct-size and ioctl-number assertions pin the ABI; the mapper +tests read a hand-filled ctypes struct and check the decoded field -- +each with a second value so a hard-coded return would fail. +""" + +import ctypes +import unittest +from unittest.mock import patch + +from hdmirx_utils import Colorspace +from hdmirx_genio import ( + GenioIoctlBackend, + HDMIRX_AUD_INFO, + HDMIRX_DEV_INFO, + HDMIRX_VID_PARA, + _decode_word_len_bits, +) + + +class TestStructSizes(unittest.TestCase): + def test_abi_struct_sizes(self): + self.assertEqual(ctypes.sizeof(HDMIRX_VID_PARA), 40) + self.assertEqual(ctypes.sizeof(HDMIRX_DEV_INFO), 12) + self.assertEqual(ctypes.sizeof(HDMIRX_AUD_INFO), 28) + + +class TestIoctlNumbers(unittest.TestCase): + def setUp(self): + self.backend = GenioIoctlBackend() + + def test_request_numbers(self): + self.assertEqual(self.backend._request("vid_info"), 0xC0284801) + self.assertEqual(self.backend._request("aud_info"), 0xC01C4802) + self.assertEqual(self.backend._request("enable"), 0x40044803) + self.assertEqual(self.backend._request("dev_info"), 0xC00C4804) + + def test_abi_selfcheck_passes(self): + self.assertIsNone(self.backend.abi_selfcheck()) + + def test_identity(self): + self.assertEqual(self.backend.name, "genio") + self.assertEqual(self.backend.MODULE_NAME, "mtk_hdmirx") + self.assertEqual(self.backend.DEVICE_PATH, "/dev/hdmirx") + + +class TestVideoMapper(unittest.TestCase): + def setUp(self): + self.backend = GenioIoctlBackend() + + def _raw( + self, hactive=1920, vactive=1080, frame_rate=60, cs=0, dp=1, is_pscan=1 + ): + raw = HDMIRX_VID_PARA() + raw.hactive = hactive + raw.vactive = vactive + raw.frame_rate = frame_rate + raw.cs = cs + raw.dp = dp + raw.is_pscan = is_pscan + return raw + + def test_decodes_1080p60(self): + info = self.backend._map_video_info(self._raw()) + self.assertEqual(info.hactive, 1920) + self.assertEqual(info.vactive, 1080) + self.assertEqual(info.frame_rate, 60) + self.assertEqual(info.colorspace, Colorspace.RGB) + self.assertEqual(info.bit_depth, 10) + self.assertFalse(info.interlaced) + + def test_fields_are_read_not_hardcoded(self): + info = self.backend._map_video_info( + self._raw(hactive=1280, vactive=720, frame_rate=50, cs=2) + ) + self.assertEqual(info.hactive, 1280) + self.assertEqual(info.vactive, 720) + self.assertEqual(info.frame_rate, 50) + self.assertEqual(info.colorspace, Colorspace.YUV422) + + def test_interlaced_flag(self): + info = self.backend._map_video_info(self._raw(is_pscan=0)) + self.assertTrue(info.interlaced) + + +class TestDeviceMapper(unittest.TestCase): + def setUp(self): + self.backend = GenioIoctlBackend() + + def _raw(self, hpd=1, hdmirx5v=1, vid=1, aud=1, hdcp=2): + raw = HDMIRX_DEV_INFO() + raw.hpd = hpd + raw.hdmirx5v = hdmirx5v + raw.vid_locked = vid + raw.aud_locked = aud + raw.hdcp_version = hdcp + return raw + + def test_connected_when_hpd_and_5v(self): + info = self.backend._map_device_info(self._raw()) + self.assertTrue(info.connected) + self.assertTrue(info.power_5v) + self.assertTrue(info.hpd) + self.assertEqual(info.hdcp_version, 2) + + def test_not_connected_without_5v(self): + info = self.backend._map_device_info(self._raw(hdmirx5v=0)) + self.assertFalse(info.connected) + self.assertFalse(info.power_5v) + + +class TestAudioMapper(unittest.TestCase): + def setUp(self): + self.backend = GenioIoctlBackend() + + def _raw(self, sample_freq=0x2, channel_count=1, word_len=0x0B): + raw = HDMIRX_AUD_INFO() + raw.caps.SampleFreq = sample_freq + raw.caps.AudInf.info.AudioChannelCount = channel_count + raw.caps.AudChStat.WordLen = word_len + return raw + + def test_decodes_24bit_2ch_48khz(self): + info = self.backend._map_audio_info(self._raw()) + self.assertEqual(info.bit_depth, 24) + self.assertEqual(info.channels, 2) + self.assertEqual(info.sample_freq_khz, 48.0) + + def test_fields_are_read_not_hardcoded(self): + info = self.backend._map_audio_info( + self._raw(sample_freq=0x3, channel_count=5, word_len=0x02) + ) + self.assertEqual(info.channels, 6) + self.assertEqual(info.sample_freq_khz, 32.0) + self.assertEqual(info.bit_depth, 16) + + def test_unknown_sample_freq_is_zero(self): + info = self.backend._map_audio_info(self._raw(sample_freq=0x7)) + self.assertEqual(info.sample_freq_khz, 0.0) + + +class TestWordLenDecode(unittest.TestCase): + def test_known_and_unknown(self): + self.assertEqual(_decode_word_len_bits(0x0B), 24) + self.assertEqual(_decode_word_len_bits(0x02), 16) + self.assertEqual(_decode_word_len_bits(0x00), 0) + + +class TestGetVideoInfoIntegration(unittest.TestCase): + """Exercise the base-class _ioctl path with a mocked device.""" + + def test_get_video_info_reads_through_ioctl(self): + backend = GenioIoctlBackend() + + def fake_ioctl(fd, request, arg): + self.assertEqual(request, 0xC0284801) + arg.hactive = 3840 + arg.vactive = 2160 + arg.frame_rate = 30 + arg.cs = 1 + arg.dp = 0 + arg.is_pscan = 1 + + with patch("hdmirx_utils.os.open", return_value=9), patch( + "hdmirx_utils.os.close" + ), patch("hdmirx_utils.fcntl.ioctl", side_effect=fake_ioctl): + info = backend.get_video_info() + self.assertEqual(info.hactive, 3840) + self.assertEqual(info.vactive, 2160) + self.assertEqual(info.frame_rate, 30) + self.assertEqual(info.colorspace, Colorspace.YUV444) + self.assertEqual(info.bit_depth, 8) + + +if __name__ == "__main__": + unittest.main() diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_tool.py b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_tool.py new file mode 100644 index 0000000000..b5099299f0 --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_tool.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2026 Canonical Ltd. +# Written by: +# Isaac Yang +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox 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 Checkbox. If not, see . +"""Hardware-free tests for the hdmirx_tool CLI orchestration. + +The backend is faked, so these check the CLI contract: exit status 0 +on pass / 1 on fail, the disable-then-enable poll loop, and JSON shape. +""" + +import io +import json +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +import hdmirx_tool +from hdmirx_tool import NoBackendError, detect +from hdmirx_utils import ( + AbiMismatch, + AudioInfo, + DeviceInfo, + VideoInfo, + expected_event_set, +) + + +class FakeBackend(object): + name = "fake" + + def __init__( + self, module=True, device=None, video=None, audio=None, events=None + ): + self._module = module + self._device = device + self._video = video + self._audio = audio + self._events = events if events is not None else set() + self.enabled = None + + def is_available(self): + return True + + def module_present(self): + return self._module + + def get_device_info(self): + return self._device + + def get_video_info(self): + return self._video + + def get_audio_info(self): + return self._audio + + def set_enabled(self, on): + self.enabled = on + + def wait_for_events(self, kind, timeout): + return self._events + + +class FlipBackend(FakeBackend): + """get_device_info tracks the last set_enabled call.""" + + def __init__(self): + super(FlipBackend, self).__init__() + self.enabled = True + + def get_device_info(self): + state = bool(self.enabled) + return DeviceInfo( + connected=state, + power_5v=state, + hpd=state, + video_locked=state, + audio_locked=state, + hdcp_version=0, + ) + + +def _connected(state=True): + return DeviceInfo( + connected=state, + power_5v=state, + hpd=state, + video_locked=state, + audio_locked=state, + hdcp_version=1, + ) + + +def _run(argv, backend): + buf = io.StringIO() + with patch("hdmirx_tool.detect", return_value=backend), redirect_stdout( + buf + ): + rc = hdmirx_tool.main(argv) + return rc, buf.getvalue() + + +class TestDetect(unittest.TestCase): + def test_first_available_wins(self): + class Avail(object): + name = "avail" + + def is_available(self): + return True + + class Unavail(object): + name = "unavail" + + def is_available(self): + return False + + with patch("hdmirx_tool.BACKENDS", [Unavail, Avail]): + self.assertIsInstance(detect(), Avail) + + def test_preferred_forces_backend(self): + class Avail(object): + name = "avail" + + def is_available(self): + return False + + with patch("hdmirx_tool.BACKENDS", [Avail]): + self.assertIsInstance(detect("avail"), Avail) + + def test_unknown_preferred_raises(self): + class Avail(object): + name = "avail" + + def is_available(self): + return True + + with patch("hdmirx_tool.BACKENDS", [Avail]): + with self.assertRaises(NoBackendError): + detect("nope") + + def test_none_available_raises(self): + class Unavail(object): + name = "unavail" + + def is_available(self): + return False + + with patch("hdmirx_tool.BACKENDS", [Unavail]): + with self.assertRaises(NoBackendError): + detect() + + +class TestModuleCheck(unittest.TestCase): + def test_pass(self): + rc, _ = _run(["module-check"], FakeBackend(module=True)) + self.assertEqual(rc, 0) + + def test_fail(self): + rc, out = _run(["module-check"], FakeBackend(module=False)) + self.assertEqual(rc, 1) + self.assertIn("FAIL", out) + + +class TestCable(unittest.TestCase): + def test_connected_passes(self): + rc, _ = _run(["cable"], FakeBackend(device=_connected(True))) + self.assertEqual(rc, 0) + + def test_disconnected_fails(self): + rc, _ = _run(["cable"], FakeBackend(device=_connected(False))) + self.assertEqual(rc, 1) + + +class TestVideoInfo(unittest.TestCase): + def _backend(self, h=1920, v=1080, r=60): + return FakeBackend(video=VideoInfo(hactive=h, vactive=v, frame_rate=r)) + + def test_match_passes(self): + rc, _ = _run( + ["video-info", "-rh", "1920", "-rv", "1080", "-rr", "60"], + self._backend(), + ) + self.assertEqual(rc, 0) + + def test_mismatch_fails(self): + rc, _ = _run( + ["video-info", "-rh", "1920", "-rv", "1080", "-rr", "60"], + self._backend(h=1280), + ) + self.assertEqual(rc, 1) + + def test_no_expectation_just_reports(self): + rc, _ = _run(["video-info"], self._backend()) + self.assertEqual(rc, 0) + + +class TestAudioInfo(unittest.TestCase): + def _backend(self, b=24, c=2, f=48.0): + return FakeBackend( + audio=AudioInfo(bit_depth=b, channels=c, sample_freq_khz=f) + ) + + def test_match_passes(self): + rc, _ = _run( + ["audio-info", "-ab", "24", "-ac", "2", "-asf", "48.0"], + self._backend(), + ) + self.assertEqual(rc, 0) + + def test_mismatch_fails(self): + rc, _ = _run( + ["audio-info", "-ab", "24", "-ac", "2", "-asf", "48.0"], + self._backend(c=8), + ) + self.assertEqual(rc, 1) + + +class TestWaitEvent(unittest.TestCase): + def test_full_set_passes(self): + rc, _ = _run( + ["wait-event", "plug"], + FakeBackend(events=expected_event_set("plug")), + ) + self.assertEqual(rc, 0) + + def test_partial_set_fails(self): + rc, out = _run( + ["wait-event", "plug"], + FakeBackend(events=set(list(expected_event_set("plug"))[:1])), + ) + self.assertEqual(rc, 1) + self.assertIn("missing", out) + + +class TestDisableThenEnable(unittest.TestCase): + def test_flip_passes_and_reenables(self): + backend = FlipBackend() + rc, _ = _run(["disable-then-enable"], backend) + self.assertEqual(rc, 0) + self.assertTrue(backend.enabled) + + +class TestAbiSelfcheck(unittest.TestCase): + def test_backend_without_selfcheck_skips(self): + rc, _ = _run(["abi-selfcheck"], FakeBackend()) + self.assertEqual(rc, 0) + + def test_selfcheck_failure_fails(self): + backend = FakeBackend() + + def boom(): + raise AbiMismatch("size drift") + + backend.abi_selfcheck = boom + rc, out = _run(["abi-selfcheck"], backend) + self.assertEqual(rc, 1) + self.assertIn("size drift", out) + + +class TestErrorHandling(unittest.TestCase): + def test_not_implemented_is_reported(self): + backend = FakeBackend() + + def raise_ni(): + raise NotImplementedError("no audio here") + + backend.get_audio_info = raise_ni + rc, out = _run( + ["audio-info", "-ab", "24", "-ac", "2", "-asf", "48.0"], backend + ) + self.assertEqual(rc, 1) + self.assertIn("not supported", out) + + def test_no_backend_exits_nonzero(self): + buf = io.StringIO() + with patch( + "hdmirx_tool.detect", side_effect=NoBackendError("none") + ), redirect_stdout(buf): + rc = hdmirx_tool.main(["module-check"]) + self.assertEqual(rc, 1) + + +class TestJsonOutput(unittest.TestCase): + def test_json_shape(self): + rc, out = _run(["--json", "module-check"], FakeBackend(module=True)) + payload = json.loads(out) + self.assertEqual(rc, 0) + self.assertTrue(payload["passed"]) + self.assertEqual(payload["command"], "module-check") + self.assertEqual(payload["backend"], "fake") + self.assertEqual(payload["reasons"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_utils.py b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_utils.py new file mode 100644 index 0000000000..681bb8ea58 --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/tests/test_hdmirx_utils.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2026 Canonical Ltd. +# Written by: +# Isaac Yang +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox 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 Checkbox. If not, see . +"""Hardware-free tests for hdmirx_utils (the platform-independent core). + +Every test is designed to be able to fail: the ioctl-number and +verify_* cases assert both the pass and the mismatch paths, so a +hard-coded implementation would be caught. +""" + +import ctypes +import unittest +from unittest.mock import patch + +import hdmirx_utils as U +from hdmirx_utils import ( + AbiMismatch, + AudioInfo, + Event, + IoctlCharBackend, + IoctlCmd, + VideoInfo, +) + + +class _FakeStruct(ctypes.Structure): + _fields_ = [("x", ctypes.c_uint32)] # sizeof == 4 + + +class _FakeBackend(IoctlCharBackend): + name = "fake" + MAGIC = "H" + DEVICE_PATH = "/dev/fake" + MODULE_NAME = "fake_mod" + UEVENT_KEY = "SWITCH_NOTIFY" + DEVPATH_FILTER = "fake" + EVENT_MAP = {0: Event.PWR_5V_CHANGE, 11: Event.PLUG_IN} + COMMANDS = { + "vid_info": IoctlCmd(1, "wr", _FakeStruct), + "aud_info": IoctlCmd(2, "wr", _FakeStruct), + "enable": IoctlCmd(3, "w", ctypes.c_uint), + "dev_info": IoctlCmd(4, "wr", _FakeStruct), + } + EXPECTED_SIZES = {"vid_info": 4, "dev_info": 4, "aud_info": 4} + + def _map_device_info(self, raw): + return raw + + def _map_video_info(self, raw): + return raw + + def _map_audio_info(self, raw): + return raw + + +class TestIocNumbers(unittest.TestCase): + def test_iowr_matches_asm_generic(self): + # dir=3<<30 | size=4<<16 | type='H'<<8 | nr=1 + self.assertEqual(U._IOWR("H", 1, 4), 0xC0044801) + + def test_iow_and_ior(self): + self.assertEqual(U._IOW("H", 3, 4), 0x40044803) + self.assertEqual(U._IOR("V", 63, 132), 0x8084563F) + + def test_direction_bits_differ(self): + # A read/write command must not encode the same as write-only. + self.assertNotEqual(U._IOWR("H", 1, 4), U._IOW("H", 1, 4)) + + +class TestRequestEncoding(unittest.TestCase): + def setUp(self): + self.backend = _FakeBackend() + + def test_request_uses_command_direction_and_size(self): + self.assertEqual(self.backend._request("vid_info"), 0xC0044801) + self.assertEqual(self.backend._request("enable"), 0x40044803) + + def test_abi_selfcheck_passes_when_sizes_match(self): + self.assertIsNone(self.backend.abi_selfcheck()) + + def test_abi_selfcheck_raises_on_size_drift(self): + self.backend.EXPECTED_SIZES = {"vid_info": 999} + with self.assertRaises(AbiMismatch): + self.backend.abi_selfcheck() + + +class TestBackendPlumbing(unittest.TestCase): + def setUp(self): + self.backend = _FakeBackend() + + def test_module_present_checks_sysfs(self): + with patch("hdmirx_utils.os.path.isdir", return_value=True) as m: + self.assertTrue(self.backend.module_present()) + m.assert_called_once_with("/sys/module/fake_mod") + with patch("hdmirx_utils.os.path.isdir", return_value=False): + self.assertFalse(self.backend.module_present()) + + def test_is_available_checks_device_node(self): + with patch("hdmirx_utils.os.path.exists", return_value=True): + self.assertTrue(self.backend.is_available()) + with patch("hdmirx_utils.os.path.exists", return_value=False): + self.assertFalse(self.backend.is_available()) + + def test_set_enabled_issues_enable_ioctl(self): + captured = {} + + def fake_ioctl(fd, request, arg): + captured["request"] = request + captured["value"] = arg.value + + with patch("hdmirx_utils.os.open", return_value=7), patch( + "hdmirx_utils.os.close" + ), patch("hdmirx_utils.fcntl.ioctl", side_effect=fake_ioctl): + self.backend.set_enabled(True) + self.assertEqual(captured["request"], 0x40044803) + self.assertEqual(captured["value"], 1) + + def test_ioctl_failure_wraps_as_ioctlerror(self): + with patch("hdmirx_utils.os.open", return_value=7), patch( + "hdmirx_utils.os.close" + ), patch("hdmirx_utils.fcntl.ioctl", side_effect=OSError(22, "bad")): + with self.assertRaises(U.IoctlError): + self.backend.get_video_info() + + +class _FakePoll: + def __init__(self, ready): + self._ready = list(ready) + + def register(self, *args): + pass + + def poll(self, timeout_ms): + return self._ready.pop(0) if self._ready else [] + + +class _FakeSocket: + def __init__(self, datagrams): + self._datagrams = list(datagrams) + + def bind(self, addr): + pass + + def recv(self, size): + return self._datagrams.pop(0) if self._datagrams else b"" + + def close(self): + pass + + +def _dgram(code, devpath="/devices/platform/soc/hdmirx"): + return b"\0".join( + [ + b"change@" + devpath.encode(), + b"SWITCH_NOTIFY=" + str(code).encode(), + b"DEVPATH=" + devpath.encode(), + ] + ) + + +class TestUeventWait(unittest.TestCase): + EVENT_MAP = { + 0: Event.PWR_5V_CHANGE, + 1: Event.TIMING_LOCK, + 3: Event.AUD_LOCK, + 11: Event.PLUG_IN, + } + + def _run(self, datagrams, ready, devpath_filter="hdmirx"): + sock = _FakeSocket(datagrams) + poll = _FakePoll(ready) + with patch("hdmirx_utils.socket.socket", return_value=sock), patch( + "hdmirx_utils.select.poll", return_value=poll + ), patch("hdmirx_utils.time.monotonic", return_value=0.0): + return U.uevent_wait( + "SWITCH_NOTIFY", + self.EVENT_MAP, + 5.0, + devpath_filter=devpath_filter, + ) + + def test_collects_plug_burst(self): + got = self._run( + [_dgram(0), _dgram(11), _dgram(1), _dgram(3)], + ready=[[1], [1], [1], [1], []], + ) + self.assertEqual( + got, + { + Event.PWR_5V_CHANGE, + Event.PLUG_IN, + Event.TIMING_LOCK, + Event.AUD_LOCK, + }, + ) + + def test_timeout_returns_empty(self): + self.assertEqual(self._run([], ready=[[]]), set()) + + def test_devpath_filter_rejects_other_subsystem(self): + got = self._run( + [_dgram(11, devpath="/devices/platform/other")], ready=[[1], []] + ) + self.assertEqual(got, set()) + + def test_unknown_code_is_ignored(self): + got = self._run([_dgram(99)], ready=[[1], []]) + self.assertEqual(got, set()) + + +class TestDecodeEvent(unittest.TestCase): + def test_known_code(self): + self.assertEqual( + U._decode_event(b"11", {11: Event.PLUG_IN}), Event.PLUG_IN + ) + + def test_unknown_and_garbage(self): + self.assertIsNone(U._decode_event(b"99", {})) + self.assertIsNone(U._decode_event(b"xx", {})) + + +class TestExpectedEventSet(unittest.TestCase): + def test_plug_and_unplug_sets(self): + self.assertEqual( + U.expected_event_set("plug"), + { + Event.PWR_5V_CHANGE, + Event.PLUG_IN, + Event.TIMING_LOCK, + Event.AUD_LOCK, + }, + ) + self.assertEqual( + U.expected_event_set("unplug"), + { + Event.AUD_UNLOCK, + Event.TIMING_UNLOCK, + Event.PWR_5V_CHANGE, + Event.PLUG_OUT, + }, + ) + + def test_zapper_drops_physical_events(self): + got = U.expected_event_set("plug", with_zapper=True) + self.assertEqual(got, {Event.TIMING_LOCK, Event.AUD_LOCK}) + self.assertNotIn(Event.PLUG_IN, got) + + def test_invalid_kind(self): + with self.assertRaises(ValueError): + U.expected_event_set("nonsense") + + +class TestVerifyEvents(unittest.TestCase): + def test_all_present_passes(self): + self.assertEqual( + U.verify_events(U.expected_event_set("plug"), "plug"), [] + ) + + def test_missing_event_reported(self): + partial = {Event.PWR_5V_CHANGE, Event.PLUG_IN} + reasons = U.verify_events(partial, "plug") + self.assertEqual(len(reasons), 1) + self.assertIn("HDMI_RX_TIMING_LOCK", reasons[0]) + self.assertIn("HDMI_RX_AUD_LOCK", reasons[0]) + + def test_zapper_ignores_physical_events(self): + # Only lock events are required with a zapper. + got = {Event.TIMING_LOCK, Event.AUD_LOCK} + self.assertEqual(U.verify_events(got, "plug", with_zapper=True), []) + + +class TestVerifyVideo(unittest.TestCase): + def _info(self, h=1920, v=1080, r=60): + return VideoInfo(hactive=h, vactive=v, frame_rate=r) + + def test_match_passes(self): + self.assertEqual(U.verify_video(self._info(), 1920, 1080, 60), []) + + def test_each_field_mismatch_reported(self): + self.assertEqual( + len(U.verify_video(self._info(h=1280), 1920, 1080, 60)), 1 + ) + self.assertEqual( + len(U.verify_video(self._info(v=720), 1920, 1080, 60)), 1 + ) + self.assertEqual( + len(U.verify_video(self._info(r=30), 1920, 1080, 60)), 1 + ) + + def test_string_expectations_are_coerced(self): + self.assertEqual( + U.verify_video(self._info(), "1920", "1080", "60"), [] + ) + + +class TestVerifyAudio(unittest.TestCase): + def _info(self, b=24, c=2, f=48.0): + return AudioInfo(bit_depth=b, channels=c, sample_freq_khz=f) + + def test_match_passes(self): + self.assertEqual(U.verify_audio(self._info(), 24, 2, 48.0), []) + + def test_mismatches_reported(self): + self.assertEqual(len(U.verify_audio(self._info(b=16), 24, 2, 48.0)), 1) + self.assertEqual(len(U.verify_audio(self._info(c=8), 24, 2, 48.0)), 1) + self.assertEqual( + len(U.verify_audio(self._info(f=44.1), 24, 2, 48.0)), 1 + ) + + def test_frequency_tolerance(self): + # Within 0.01 kHz is accepted; outside is not. + self.assertEqual(U.verify_audio(self._info(f=48.005), 24, 2, 48.0), []) + self.assertEqual( + len(U.verify_audio(self._info(f=48.5), 24, 2, 48.0)), 1 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/category.pxu b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/category.pxu new file mode 100644 index 0000000000..4785d0e726 --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/category.pxu @@ -0,0 +1,3 @@ +unit: category +id: ce-oem-hdmi-rx +_name: HDMI RX (Input) tests diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/jobs.pxu b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/jobs.pxu new file mode 100644 index 0000000000..3a372ab2a0 --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/jobs.pxu @@ -0,0 +1,133 @@ +id: ce-oem-hdmi-rx/module-detect +plugin: shell +category_id: ce-oem-hdmi-rx +estimated_duration: 1 +imports: from com.canonical.plainbox import manifest +requires: manifest.has_hdmi_rx == "True" +user: root +_summary: Check that an HDMI RX kernel driver is loaded +_description: + Detect an available HDMI RX backend (the Genio mtk_hdmirx char + device or a mainline V4L2 HDMI RX node) without relying on any + vendor binary. +command: hdmirx_tool.py module-check + +id: ce-oem-hdmi-rx/plug-event-detect +plugin: user-interact +category_id: ce-oem-hdmi-rx +estimated_duration: 20 +depends: ce-oem-hdmi-rx/module-detect +imports: from com.canonical.plainbox import manifest +requires: manifest.has_hdmi_rx == "True" +flags: also-after-suspend +user: root +command: hdmirx_tool.py wait-event plug +_summary: Detect the HDMI RX events while plugging the HDMI cable +_purpose: + This test checks that the PWR_5V_CHANGE, PLUG_IN, TIMING_LOCK and + AUD_LOCK events can be detected while the HDMI RX port is connected + to a Host or Player such as a Laptop through an HDMI cable. +_steps: + On the Host or Player side (e.g. a Laptop with Ubuntu Desktop): + 1. Check and set the value of the Sample Specification to be + "s16le 2ch 48000Hz" (list sinks via '$ pactl list sinks'). + 2. Plug an HDMI cable into the Host or Player. + On the DUT side: + 1. Press "Enter" to start event detection. + 2. You will have 15 seconds to plug the HDMI cable into the + HDMI RX port. + +id: ce-oem-hdmi-rx/check-hdmi-cable-connection +plugin: shell +category_id: ce-oem-hdmi-rx +estimated_duration: 5 +depends: ce-oem-hdmi-rx/module-detect +imports: from com.canonical.plainbox import manifest +requires: manifest.has_hdmi_rx == "True" +flags: also-after-suspend +user: root +command: hdmirx_tool.py cable +_summary: Check the HDMI RX cable connection +_description: + This test checks that the HDMI RX device reports "hdmi connected" + (HPD and 5V present) after the HDMI RX port has been connected to a + Host or Player such as a Laptop through an HDMI cable. + +id: ce-oem-hdmi-rx/check-video-info +plugin: shell +category_id: ce-oem-hdmi-rx +estimated_duration: 5 +depends: + ce-oem-hdmi-rx/module-detect + ce-oem-hdmi-rx/check-hdmi-cable-connection +imports: from com.canonical.plainbox import manifest +requires: manifest.has_hdmi_rx == "True" +flags: also-after-suspend +user: root +command: hdmirx_tool.py video-info -rh 1920 -rv 1080 -rr 60 +_summary: Check the received HDMI RX video information +_description: + This test checks that the received video information matches the + expectation. It expects hactive = 1920, vactive = 1080 and + frame_rate = 60 once the HDMI RX port is connected to a Host or + Player through an HDMI cable. + +id: ce-oem-hdmi-rx/check-audio-info +plugin: shell +category_id: ce-oem-hdmi-rx +estimated_duration: 5 +depends: + ce-oem-hdmi-rx/module-detect + ce-oem-hdmi-rx/check-hdmi-cable-connection +imports: from com.canonical.plainbox import manifest +requires: manifest.has_hdmi_rx == "True" +flags: also-after-suspend +user: root +command: hdmirx_tool.py audio-info -ab 24 -ac 2 -asf 48.0 +_summary: Check the received HDMI RX audio information +_description: + This test checks that the received audio information matches the + expectation. It expects a 24-bit word length, 2 channels and a + 48.0 kHz sample frequency once the HDMI RX port is connected to a + Host or Player through an HDMI cable. + +id: ce-oem-hdmi-rx/disable-then-enable-hdmi +plugin: shell +category_id: ce-oem-hdmi-rx +estimated_duration: 10 +depends: + ce-oem-hdmi-rx/module-detect + ce-oem-hdmi-rx/check-hdmi-cable-connection +imports: from com.canonical.plainbox import manifest +requires: manifest.has_hdmi_rx == "True" +flags: also-after-suspend +user: root +command: hdmirx_tool.py disable-then-enable +_summary: Disable and re-enable the HDMI RX functionality +_description: + This test disables the HDMI RX functionality, verifies the cable is + reported as disconnected, then re-enables it and verifies the cable + is reported as connected again. The HDMI RX port stays physically + connected throughout. + +id: ce-oem-hdmi-rx/unplug-event-detect +plugin: user-interact +category_id: ce-oem-hdmi-rx +estimated_duration: 20 +depends: ce-oem-hdmi-rx/module-detect +imports: from com.canonical.plainbox import manifest +requires: manifest.has_hdmi_rx == "True" +flags: also-after-suspend +user: root +command: hdmirx_tool.py wait-event unplug +_summary: Detect the HDMI RX events while unplugging the HDMI cable +_purpose: + This test checks that the AUD_UNLOCK, TIMING_UNLOCK, PWR_5V_CHANGE + and PLUG_OUT events can be detected while removing the HDMI cable + from the HDMI RX port. +_steps: + 1. Plug the HDMI cable into the HDMI RX port if it is not connected + yet. + 2. Press "Enter" to start event detection. + 3. You will have 15 seconds to remove the HDMI cable from the HDMI + RX port. diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/manifest.pxu b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/manifest.pxu new file mode 100644 index 0000000000..aeb551722e --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/manifest.pxu @@ -0,0 +1,5 @@ +unit: manifest entry +id: has_hdmi_rx +_name: HDMI RX (Input) +_prompt: Does this machine support the HDMI RX feature? +value-type: bool diff --git a/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/test-plan.pxu b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/test-plan.pxu new file mode 100644 index 0000000000..df5dfd9547 --- /dev/null +++ b/contrib/checkbox-ce-oem/checkbox-provider-ce-oem/units/hdmi-rx/test-plan.pxu @@ -0,0 +1,54 @@ +id: ce-oem-hdmi-rx-full +unit: test plan +_name: HDMI RX (Input) tests +_description: Full HDMI RX (input) tests for devices +include: +nested_part: + ce-oem-hdmi-rx-manual + ce-oem-hdmi-rx-automated + +id: ce-oem-hdmi-rx-manual +unit: test plan +_name: HDMI RX (Input) manual tests +_description: Manual HDMI RX (input) tests for devices +include: + ce-oem-hdmi-rx/module-detect + ce-oem-hdmi-rx/plug-event-detect + ce-oem-hdmi-rx/check-hdmi-cable-connection + ce-oem-hdmi-rx/check-video-info + ce-oem-hdmi-rx/check-audio-info + ce-oem-hdmi-rx/disable-then-enable-hdmi + ce-oem-hdmi-rx/unplug-event-detect + +id: ce-oem-hdmi-rx-automated +unit: test plan +_name: HDMI RX (Input) auto tests +_description: Automated HDMI RX (input) tests for devices +include: + +id: after-suspend-ce-oem-hdmi-rx-full +unit: test plan +_name: HDMI RX (Input) tests (after suspend) +_description: Full after suspend HDMI RX (input) tests for devices +include: +nested_part: + after-suspend-ce-oem-hdmi-rx-manual + after-suspend-ce-oem-hdmi-rx-automated + +id: after-suspend-ce-oem-hdmi-rx-manual +unit: test plan +_name: HDMI RX (Input) manual tests (after suspend) +_description: Manual after suspend HDMI RX (input) tests for devices +include: + after-suspend-ce-oem-hdmi-rx/plug-event-detect + after-suspend-ce-oem-hdmi-rx/check-hdmi-cable-connection + after-suspend-ce-oem-hdmi-rx/check-video-info + after-suspend-ce-oem-hdmi-rx/check-audio-info + after-suspend-ce-oem-hdmi-rx/disable-then-enable-hdmi + after-suspend-ce-oem-hdmi-rx/unplug-event-detect + +id: after-suspend-ce-oem-hdmi-rx-automated +unit: test plan +_name: HDMI RX (Input) auto tests (after suspend) +_description: Automated after suspend HDMI RX (input) tests for devices +include: