diff --git a/CMakeLists.txt b/CMakeLists.txt index 86750a1..a9f5356 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,8 @@ cmake_minimum_required(VERSION 3.1...3.27) project(utils) +include(CTest) + option(ENABLE_WERROR "Treat compiler warnings as errors" OFF) # List of subsidiary CMakeLists @@ -16,6 +18,7 @@ add_subdirectory(ovmerge) add_subdirectory(pinctrl) add_subdirectory(piolib) add_subdirectory(raspinfo) +add_subdirectory(rp1-ptp-pps) add_subdirectory(rpieepromab) add_subdirectory(rpi-gpu-usage) add_subdirectory(vcgencmd) diff --git a/README.md b/README.md index 1da5939..4cdb757 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ A collection of scripts and simple applications * [piolib](piolib/) - A library for accessing the Pi 5's PIO hardware. * [raspinfo](raspinfo/) - A short script to dump information about the Pi. Intended for the submission of bug reports. +* [rp1-ptp-pps](rp1-ptp-pps/) - PTP timestamp input and PPS output on the RP1 Ethernet PHC. * [rpieepromab](rpieepromab/) - A tool to update and manage the AB EEPROM partitions on a device in the Pi 5 family. * [rpifwcrypto](rpifwcrypto/) - A command line application and shared library for the diff --git a/rp1-ptp-pps/CMakeLists.txt b/rp1-ptp-pps/CMakeLists.txt new file mode 100644 index 0000000..6fa8007 --- /dev/null +++ b/rp1-ptp-pps/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.10...3.27) +include(GNUInstallDirs) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +find_package(Python3 COMPONENTS Interpreter REQUIRED) + +add_executable(testptp testptp.c) +target_include_directories(testptp PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_link_libraries(testptp PRIVATE m) + +configure_file(rp1-pps.in ${CMAKE_CURRENT_BINARY_DIR}/rp1-pps @ONLY) +configure_file(rp1-pps.service.in ${CMAKE_CURRENT_BINARY_DIR}/rp1-pps.service @ONLY) +configure_file(rp1-pps-duplex.service.in + ${CMAKE_CURRENT_BINARY_DIR}/rp1-pps-duplex.service @ONLY) +configure_file(rp1-pps.conf.example.in + ${CMAKE_CURRENT_BINARY_DIR}/rp1-pps.conf.example @ONLY) + +install(TARGETS testptp RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(PROGRAMS ${CMAKE_CURRENT_BINARY_DIR}/rp1-pps + DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(FILES rp1_pps.py phc_identity.py + DESTINATION ${CMAKE_INSTALL_LIBEXECDIR}/rp1-ptp-pps) +set(RP1_PPS_SYSTEMD_UNITDIR "lib/systemd/system" CACHE STRING + "Directory for RP1 PPS systemd unit files") +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/rp1-pps.service + ${CMAKE_CURRENT_BINARY_DIR}/rp1-pps-duplex.service + DESTINATION ${RP1_PPS_SYSTEMD_UNITDIR}) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/rp1-pps.conf.example + DESTINATION ${CMAKE_INSTALL_DATADIR}/doc/rp1-ptp-pps/examples) +install(FILES README.md LICENSE + DESTINATION ${CMAKE_INSTALL_DATADIR}/doc/rp1-ptp-pps) + +include(CTest) +if(BUILD_TESTING) + add_test(NAME rp1-ptp-pps-runtime + COMMAND ${Python3_EXECUTABLE} -m unittest discover + -s ${CMAKE_CURRENT_SOURCE_DIR}/tests + -p test_rp1_pps_runtime.py) +endif() diff --git a/rp1-ptp-pps/LICENSE b/rp1-ptp-pps/LICENSE new file mode 100644 index 0000000..22c0b5b --- /dev/null +++ b/rp1-ptp-pps/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ahmad Byagowi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rp1-ptp-pps/README.md b/rp1-ptp-pps/README.md new file mode 100644 index 0000000..e33f323 --- /dev/null +++ b/rp1-ptp-pps/README.md @@ -0,0 +1,54 @@ +# RP1 Ethernet PHC PPS support + +`rp1-ptp-pps` configures the PTP hardware clock on the RP1 Ethernet MAC through +Linux's standard PTP interface. The command-line utility uses the kernel's +`testptp` tool. It supports selected GPIO input and output, including a mode +with two independent rising-edge inputs and one simultaneous periodic output. + +The PHC driver must report two external timestamp channels, one periodic +output channel, and 28 programmable pins. The runtime checks those capabilities +before it maps a pin. The kernel support is under review in +[raspberrypi/linux PR 7659](https://github.com/raspberrypi/linux/pull/7659). + +GPIO numbers are BCM/RP1 GPIO numbers 0 through 27, not physical header pin +numbers. GPIO18 is physical pin 12, GPIO23 is pin 16, and GPIO24 is pin 18. +Signals connected to a Pi GPIO must remain between 0 and 3.3 V. Do not connect +two actively driven output pins together. + +Build and install from the `utils` repository: + +```sh +cmake -S . -B build -DCMAKE_INSTALL_PREFIX=/usr +cmake --build build +sudo cmake --install build +``` + +The standalone command prints a plan without changing hardware. Add +`--execute` only after checking that the selected pins are wired and available: + +```sh +sudo rp1-pps --mode duplex --input-gpio 18 --input-gpio2 24 \ + --output-gpio 23 --events 8 --execute +``` + +To run continuously under systemd, copy +`/usr/share/doc/rp1-ptp-pps/examples/rp1-pps.conf.example` to +`/etc/default/rp1-pps`, edit the PHC interface and BCM GPIOs, then enable +exactly one service. The example values use GPIO18 and prepare GPIO24 and +GPIO23 for duplex mode. For duplex operation, enable +`rp1-pps-duplex.service`: + +```sh +sudo cp /usr/share/doc/rp1-ptp-pps/examples/rp1-pps.conf.example \ + /etc/default/rp1-pps +sudoedit /etc/default/rp1-pps +sudo systemctl daemon-reload +sudo systemctl enable --now rp1-pps-duplex.service +``` + +The second input uses EXTTS channel 1, which currently requires active PEROUT. +Disable the service before removing or rewiring its GPIO connections. Rising +edges are used by the simultaneous counter path. Its edge timing has not been +independently calibrated, so this software makes no board-level accuracy or +jitter guarantee. Physical validation currently covers only earlier +sequential tests; simultaneous two-input capture is still under qualification. diff --git a/rp1-ptp-pps/include/linux/ptp_clock.h b/rp1-ptp-pps/include/linux/ptp_clock.h new file mode 100644 index 0000000..46d45f9 --- /dev/null +++ b/rp1-ptp-pps/include/linux/ptp_clock.h @@ -0,0 +1,263 @@ +/* SPDX-License-Identifier: GPL-2.0+ WITH Linux-syscall-note */ +/* + * PTP 1588 clock support - user space interface + * + * Copyright (C) 2010 OMICRON electronics GmbH + * + * 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, write to the Free Software + * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +#ifndef _PTP_CLOCK_H_ +#define _PTP_CLOCK_H_ + +#include +#include + +/* + * Bits of the ptp_extts_request.flags field: + */ +#define PTP_ENABLE_FEATURE (1<<0) +#define PTP_RISING_EDGE (1<<1) +#define PTP_FALLING_EDGE (1<<2) +#define PTP_STRICT_FLAGS (1<<3) +#define PTP_EXT_OFFSET (1<<4) +#define PTP_EXTTS_EDGES (PTP_RISING_EDGE | PTP_FALLING_EDGE) + +/* + * flag fields valid for the new PTP_EXTTS_REQUEST2 ioctl. + * + * Note: PTP_STRICT_FLAGS is always enabled by the kernel for + * PTP_EXTTS_REQUEST2 regardless of whether it is set by userspace. + */ +#define PTP_EXTTS_VALID_FLAGS (PTP_ENABLE_FEATURE | \ + PTP_RISING_EDGE | \ + PTP_FALLING_EDGE | \ + PTP_STRICT_FLAGS | \ + PTP_EXT_OFFSET) + +/* + * flag fields valid for the original PTP_EXTTS_REQUEST ioctl. + * DO NOT ADD NEW FLAGS HERE. + */ +#define PTP_EXTTS_V1_VALID_FLAGS (PTP_ENABLE_FEATURE | \ + PTP_RISING_EDGE | \ + PTP_FALLING_EDGE) + +/* + * flag fields valid for the ptp_extts_event report. + */ +#define PTP_EXTTS_EVENT_VALID (PTP_ENABLE_FEATURE) + +/* + * Bits of the ptp_perout_request.flags field: + */ +#define PTP_PEROUT_ONE_SHOT (1<<0) +#define PTP_PEROUT_DUTY_CYCLE (1<<1) +#define PTP_PEROUT_PHASE (1<<2) + +/* + * flag fields valid for the new PTP_PEROUT_REQUEST2 ioctl. + */ +#define PTP_PEROUT_VALID_FLAGS (PTP_PEROUT_ONE_SHOT | \ + PTP_PEROUT_DUTY_CYCLE | \ + PTP_PEROUT_PHASE) + +/* + * No flags are valid for the original PTP_PEROUT_REQUEST ioctl + */ +#define PTP_PEROUT_V1_VALID_FLAGS (0) + +/* + * struct ptp_clock_time - represents a time value + * + * The sign of the seconds field applies to the whole value. The + * nanoseconds field is always unsigned. The reserved field is + * included for sub-nanosecond resolution, should the demand for + * this ever appear. + * + */ +struct ptp_clock_time { + __s64 sec; /* seconds */ + __u32 nsec; /* nanoseconds */ + __u32 reserved; +}; + +struct ptp_clock_caps { + int max_adj; /* Maximum frequency adjustment in parts per billon. */ + int n_alarm; /* Number of programmable alarms. */ + int n_ext_ts; /* Number of external time stamp channels. */ + int n_per_out; /* Number of programmable periodic signals. */ + int pps; /* Whether the clock supports a PPS callback. */ + int n_pins; /* Number of input/output pins. */ + /* Whether the clock supports precise system-device cross timestamps */ + int cross_timestamping; + /* Whether the clock supports adjust phase */ + int adjust_phase; + int max_phase_adj; /* Maximum phase adjustment in nanoseconds. */ + int rsv[11]; /* Reserved for future use. */ +}; + +struct ptp_extts_request { + unsigned int index; /* Which channel to configure. */ + unsigned int flags; /* Bit field for PTP_xxx flags. */ + unsigned int rsv[2]; /* Reserved for future use. */ +}; + +struct ptp_perout_request { + union { + /* + * Absolute start time. + * Valid only if (flags & PTP_PEROUT_PHASE) is unset. + */ + struct ptp_clock_time start; + /* + * Phase offset. The signal should start toggling at an + * unspecified integer multiple of the period, plus this value. + * The start time should be "as soon as possible". + * Valid only if (flags & PTP_PEROUT_PHASE) is set. + */ + struct ptp_clock_time phase; + }; + struct ptp_clock_time period; /* Desired period, zero means disable. */ + unsigned int index; /* Which channel to configure. */ + unsigned int flags; + union { + /* + * The "on" time of the signal. + * Must be lower than the period. + * Valid only if (flags & PTP_PEROUT_DUTY_CYCLE) is set. + */ + struct ptp_clock_time on; + /* Reserved for future use. */ + unsigned int rsv[4]; + }; +}; + +#define PTP_MAX_SAMPLES 25 /* Maximum allowed offset measurement samples. */ + +struct ptp_sys_offset { + unsigned int n_samples; /* Desired number of measurements. */ + unsigned int rsv[3]; /* Reserved for future use. */ + /* + * Array of interleaved system/phc time stamps. The kernel + * will provide 2*n_samples + 1 time stamps, with the last + * one as a system time stamp. + */ + struct ptp_clock_time ts[2 * PTP_MAX_SAMPLES + 1]; +}; + +/* + * ptp_sys_offset_extended - data structure for IOCTL operation + * PTP_SYS_OFFSET_EXTENDED + * + * @n_samples: Desired number of measurements. + * @clockid: clockid of a clock-base used for pre/post timestamps. + * @rsv: Reserved for future use. + * @ts: Array of samples in the form [pre-TS, PHC, post-TS]. The + * kernel provides @n_samples. + * + * Starting from kernel 6.12 and onwards, the first word of the reserved-field + * is used for @clockid. That's backward compatible since previous kernel + * expect all three reserved words (@rsv[3]) to be 0 while the clockid (first + * word in the new structure) for CLOCK_REALTIME is '0'. + */ +struct ptp_sys_offset_extended { + unsigned int n_samples; + __kernel_clockid_t clockid; + unsigned int rsv[2]; + struct ptp_clock_time ts[PTP_MAX_SAMPLES][3]; +}; + +struct ptp_sys_offset_precise { + struct ptp_clock_time device; + struct ptp_clock_time sys_realtime; + struct ptp_clock_time sys_monoraw; + unsigned int rsv[4]; /* Reserved for future use. */ +}; + +enum ptp_pin_function { + PTP_PF_NONE, + PTP_PF_EXTTS, + PTP_PF_PEROUT, + PTP_PF_PHYSYNC, +}; + +struct ptp_pin_desc { + /* + * Hardware specific human readable pin name. This field is + * set by the kernel during the PTP_PIN_GETFUNC ioctl and is + * ignored for the PTP_PIN_SETFUNC ioctl. + */ + char name[64]; + /* + * Pin index in the range of zero to ptp_clock_caps.n_pins - 1. + */ + unsigned int index; + /* + * Which of the PTP_PF_xxx functions to use on this pin. + */ + unsigned int func; + /* + * The specific channel to use for this function. + * This corresponds to the 'index' field of the + * PTP_EXTTS_REQUEST and PTP_PEROUT_REQUEST ioctls. + */ + unsigned int chan; + /* + * Reserved for future use. + */ + unsigned int rsv[5]; +}; + +#define PTP_CLK_MAGIC '=' + +#define PTP_CLOCK_GETCAPS _IOR(PTP_CLK_MAGIC, 1, struct ptp_clock_caps) +#define PTP_EXTTS_REQUEST _IOW(PTP_CLK_MAGIC, 2, struct ptp_extts_request) +#define PTP_PEROUT_REQUEST _IOW(PTP_CLK_MAGIC, 3, struct ptp_perout_request) +#define PTP_ENABLE_PPS _IOW(PTP_CLK_MAGIC, 4, int) +#define PTP_SYS_OFFSET _IOW(PTP_CLK_MAGIC, 5, struct ptp_sys_offset) +#define PTP_PIN_GETFUNC _IOWR(PTP_CLK_MAGIC, 6, struct ptp_pin_desc) +#define PTP_PIN_SETFUNC _IOW(PTP_CLK_MAGIC, 7, struct ptp_pin_desc) +#define PTP_SYS_OFFSET_PRECISE \ + _IOWR(PTP_CLK_MAGIC, 8, struct ptp_sys_offset_precise) +#define PTP_SYS_OFFSET_EXTENDED \ + _IOWR(PTP_CLK_MAGIC, 9, struct ptp_sys_offset_extended) + +#define PTP_CLOCK_GETCAPS2 _IOR(PTP_CLK_MAGIC, 10, struct ptp_clock_caps) +#define PTP_EXTTS_REQUEST2 _IOW(PTP_CLK_MAGIC, 11, struct ptp_extts_request) +#define PTP_PEROUT_REQUEST2 _IOW(PTP_CLK_MAGIC, 12, struct ptp_perout_request) +#define PTP_ENABLE_PPS2 _IOW(PTP_CLK_MAGIC, 13, int) +#define PTP_SYS_OFFSET2 _IOW(PTP_CLK_MAGIC, 14, struct ptp_sys_offset) +#define PTP_PIN_GETFUNC2 _IOWR(PTP_CLK_MAGIC, 15, struct ptp_pin_desc) +#define PTP_PIN_SETFUNC2 _IOW(PTP_CLK_MAGIC, 16, struct ptp_pin_desc) +#define PTP_SYS_OFFSET_PRECISE2 \ + _IOWR(PTP_CLK_MAGIC, 17, struct ptp_sys_offset_precise) +#define PTP_SYS_OFFSET_EXTENDED2 \ + _IOWR(PTP_CLK_MAGIC, 18, struct ptp_sys_offset_extended) +#define PTP_MASK_CLEAR_ALL _IO(PTP_CLK_MAGIC, 19) +#define PTP_MASK_EN_SINGLE _IOW(PTP_CLK_MAGIC, 20, unsigned int) +#define PTP_SYS_OFFSET_PRECISE_CYCLES \ + _IOWR(PTP_CLK_MAGIC, 21, struct ptp_sys_offset_precise) +#define PTP_SYS_OFFSET_EXTENDED_CYCLES \ + _IOWR(PTP_CLK_MAGIC, 22, struct ptp_sys_offset_extended) + +struct ptp_extts_event { + struct ptp_clock_time t; /* Time event occurred. */ + unsigned int index; /* Which channel produced the event. */ + unsigned int flags; /* Event type. */ + unsigned int rsv[2]; /* Reserved for future use. */ +}; + +#endif diff --git a/rp1-ptp-pps/phc_identity.py b/rp1-ptp-pps/phc_identity.py new file mode 100644 index 0000000..8513ae1 --- /dev/null +++ b/rp1-ptp-pps/phc_identity.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Read-only RP1 MAC PHC discovery. Never open a /dev/ptp* device.""" +import argparse +import json +import os +from pathlib import Path +import shlex +import stat + + +def inventory(interface='eth0', sysfs=Path('/sys'), devices=Path('/dev')): + net = (sysfs / 'class/net' / interface).resolve(strict=True) + hardware = (net / 'device').resolve(strict=True) + driver = (hardware / 'driver').resolve(strict=True).name + if hardware.name != '1f00100000.ethernet': + raise ValueError('Interface is not the supported RP1 Ethernet device') + result = {'interface': interface, 'hardware': str(hardware), 'driver': driver, + 'clocks': [], 'skipped': []} + for entry in sorted((sysfs / 'class/ptp').glob('ptp*')): + try: + name = (entry / 'clock_name').read_text().strip() + parent = (entry / 'device').resolve(strict=True) + dev_t = tuple(map(int, (entry / 'dev').read_text().strip().split(':'))) + node = devices / entry.name + node_stat = node.stat() + if (len(dev_t) != 2 or not stat.S_ISCHR(node_stat.st_mode) or + dev_t != (os.major(node_stat.st_rdev), os.minor(node_stat.st_rdev))): + raise ValueError('Character device does not match sysfs identity') + row = {'name': name, 'device': str(node), 'parent': str(parent), + 'device_number': list(dev_t), + 'rp1_mac': name == 'gem-ptp-timer' and parent in (net, hardware)} + if row['rp1_mac']: + row['capabilities'] = { + key: int((entry / attr).read_text()) for key, attr in + [('extts', 'n_external_timestamps'), + ('perout', 'n_periodic_outputs'), ('pins', 'n_programmable_pins')]} + result['clocks'].append(row) + except (OSError, ValueError) as exc: + result['skipped'].append({'entry': entry.name, 'reason': str(exc)}) + return result + + +def select_mac(info): + matches = [row for row in info['clocks'] if row['rp1_mac']] + if len(matches) != 1: + raise ValueError(f'Expected one live RP1 MAC PHC, found {len(matches)}') + return matches[0] + + +def command_plan(info, input_gpio, output_gpio, testptp, input_gpio2=None): + if not (0 <= input_gpio <= 27 and 0 <= output_gpio <= 27): + raise ValueError('Select GPIO numbers in 0..27, not physical header pin numbers') + if input_gpio == output_gpio: + raise ValueError('Select different input and output GPIOs') + if input_gpio2 is not None and ( + not 0 <= input_gpio2 <= 27 or input_gpio2 in (input_gpio, output_gpio)): + raise ValueError('Select a distinct second input GPIO in 0..27') + mac = select_mac(info) + if info['driver'] not in ('macb', 'macb_pps') or mac['capabilities'] != { + 'extts': 2, 'perout': 1, 'pins': 28}: + raise ValueError('The active PHC does not expose two EXTTS channels and one PEROUT channel') + base = [testptp, '-d', mac['device'], '-i', '0'] + input1 = [testptp, '-d', mac['device'], '-i', '0'] + input2 = [testptp, '-d', mac['device'], '-i', '1'] + mapping = [shlex.join(base + ['-L', f'{input_gpio},1']), + shlex.join(base + ['-L', f'{output_gpio},2'])] + result = { + 'device': mac['device'], + 'note': 'Commands are printed only. EXTTS channels 0 and 1 and PEROUT can run together.', + 'mapping': mapping, + 'input_example': shlex.join(input1 + ['-E', '1', '-e', '10']), + 'input_stop': shlex.join(base + ['-L', f'{input_gpio},0']), + 'input_stop_note': 'Stops capture by clearing its pin mapping. Reapply the input mapping before restarting.', + 'output_start': shlex.join(base + ['-p', '1000000000', '-H', '0', '-w', '10000000']), + 'output_stop': shlex.join(base + ['-p', '0']), + } + if input_gpio2 is not None: + result['mapping'].insert(1, shlex.join(input2 + ['-L', f'{input_gpio2},1'])) + result['second_input_example'] = shlex.join(input2 + ['-E', '1', '-e', '10']) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--interface', default='eth0') + mode = parser.add_mutually_exclusive_group() + mode.add_argument('--device', action='store_true', help='Print only the discovered MAC device') + mode.add_argument('--commands', action='store_true', help='Print standard testptp commands') + parser.add_argument('--input-gpio', type=int, default=18) + parser.add_argument('--input-gpio2', type=int) + parser.add_argument('--output-gpio', type=int, default=23) + parser.add_argument('--testptp', default='testptp') + args = parser.parse_args() + try: + info = inventory(args.interface) + if args.device: + print(select_mac(info)['device']) + else: + print(json.dumps(command_plan(info, args.input_gpio, args.output_gpio, + args.testptp, args.input_gpio2) + if args.commands else info, indent=2)) + except (OSError, ValueError) as exc: + parser.exit(1, f'{exc}\n') + + +if __name__ == '__main__': + main() diff --git a/rp1-ptp-pps/rp1-pps-duplex.service.in b/rp1-ptp-pps/rp1-pps-duplex.service.in new file mode 100644 index 0000000..0a03fa4 --- /dev/null +++ b/rp1-ptp-pps/rp1-pps-duplex.service.in @@ -0,0 +1,32 @@ +[Unit] +Description=RP1 Ethernet PHC simultaneous PPS input and output +After=systemd-modules-load.service +Conflicts=rp1-pps.service +StartLimitIntervalSec=0 +ConditionPathExists=/etc/default/rp1-pps + +[Service] +Type=simple +User=root +EnvironmentFile=/etc/default/rp1-pps +StateDirectory=rp1-ptp-pps +StateDirectoryMode=0700 +ExecStartPre=@Python3_EXECUTABLE@ @CMAKE_INSTALL_FULL_LIBEXECDIR@/rp1-ptp-pps/rp1_pps.py --interface ${PPS_INTERFACE} --testptp ${TESTPTP} --mode duplex --input-gpio ${PPS_INPUT_GPIO} --input-gpio2 ${PPS_INPUT_GPIO2} --output-gpio ${PPS_OUTPUT_GPIO} --ownership-file /var/lib/rp1-ptp-pps/owned.json --cleanup +ExecStart=@Python3_EXECUTABLE@ @CMAKE_INSTALL_FULL_LIBEXECDIR@/rp1-ptp-pps/rp1_pps.py --interface ${PPS_INTERFACE} --testptp ${TESTPTP} --mode duplex --input-gpio ${PPS_INPUT_GPIO} --input-gpio2 ${PPS_INPUT_GPIO2} --output-gpio ${PPS_OUTPUT_GPIO} --input-channel ${PPS_INPUT_CHANNEL} --edge ${PPS_EDGE} --events ${PPS_EVENTS} --event-timeout ${PPS_EVENT_TIMEOUT} --period-ns ${PPS_PERIOD_NS} --high-ns ${PPS_HIGH_NS} --phase-ns ${PPS_PHASE_NS} --ownership-file /var/lib/rp1-ptp-pps/owned.json --execute --wait-for-phc +ExecStopPost=@Python3_EXECUTABLE@ @CMAKE_INSTALL_FULL_LIBEXECDIR@/rp1-ptp-pps/rp1_pps.py --interface ${PPS_INTERFACE} --testptp ${TESTPTP} --mode duplex --input-gpio ${PPS_INPUT_GPIO} --input-gpio2 ${PPS_INPUT_GPIO2} --output-gpio ${PPS_OUTPUT_GPIO} --ownership-file /var/lib/rp1-ptp-pps/owned.json --cleanup +Restart=on-failure +RestartSec=5s +KillSignal=SIGTERM +TimeoutStopSec=30s +CapabilityBoundingSet=CAP_SYS_TIME +AmbientCapabilities=CAP_SYS_TIME +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes + +[Install] +WantedBy=multi-user.target diff --git a/rp1-ptp-pps/rp1-pps.conf.example.in b/rp1-ptp-pps/rp1-pps.conf.example.in new file mode 100644 index 0000000..9101b14 --- /dev/null +++ b/rp1-ptp-pps/rp1-pps.conf.example.in @@ -0,0 +1,15 @@ +# Copy to /etc/default/rp1-pps, edit the BCM GPIO values, then enable one unit. +PPS_INTERFACE=eth0 +TESTPTP=@CMAKE_INSTALL_FULL_BINDIR@/testptp +PPS_MODE=input +PPS_GPIO=18 +PPS_INPUT_GPIO=18 +PPS_INPUT_GPIO2=24 +PPS_OUTPUT_GPIO=23 +PPS_INPUT_CHANNEL=0 +PPS_EDGE=rising +PPS_EVENTS=0 +PPS_EVENT_TIMEOUT=5 +PPS_PERIOD_NS=1000000000 +PPS_HIGH_NS=10000000 +PPS_PHASE_NS=0 diff --git a/rp1-ptp-pps/rp1-pps.in b/rp1-ptp-pps/rp1-pps.in new file mode 100644 index 0000000..c3417a5 --- /dev/null +++ b/rp1-ptp-pps/rp1-pps.in @@ -0,0 +1,3 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +exec @Python3_EXECUTABLE@ "@CMAKE_INSTALL_FULL_LIBEXECDIR@/rp1-ptp-pps/rp1_pps.py" "$@" diff --git a/rp1-ptp-pps/rp1-pps.service.in b/rp1-ptp-pps/rp1-pps.service.in new file mode 100644 index 0000000..0c0b7ca --- /dev/null +++ b/rp1-ptp-pps/rp1-pps.service.in @@ -0,0 +1,32 @@ +[Unit] +Description=RP1 Ethernet PHC PPS input or output +After=systemd-modules-load.service +Conflicts=rp1-pps-duplex.service +StartLimitIntervalSec=0 +ConditionPathExists=/etc/default/rp1-pps + +[Service] +Type=simple +User=root +EnvironmentFile=/etc/default/rp1-pps +StateDirectory=rp1-ptp-pps +StateDirectoryMode=0700 +ExecStartPre=@Python3_EXECUTABLE@ @CMAKE_INSTALL_FULL_LIBEXECDIR@/rp1-ptp-pps/rp1_pps.py --interface ${PPS_INTERFACE} --testptp ${TESTPTP} --mode ${PPS_MODE} --gpio ${PPS_GPIO} --ownership-file /var/lib/rp1-ptp-pps/owned.json --cleanup +ExecStart=@Python3_EXECUTABLE@ @CMAKE_INSTALL_FULL_LIBEXECDIR@/rp1-ptp-pps/rp1_pps.py --interface ${PPS_INTERFACE} --testptp ${TESTPTP} --mode ${PPS_MODE} --gpio ${PPS_GPIO} --edge ${PPS_EDGE} --events ${PPS_EVENTS} --event-timeout ${PPS_EVENT_TIMEOUT} --period-ns ${PPS_PERIOD_NS} --high-ns ${PPS_HIGH_NS} --phase-ns ${PPS_PHASE_NS} --ownership-file /var/lib/rp1-ptp-pps/owned.json --execute --wait-for-phc +ExecStopPost=@Python3_EXECUTABLE@ @CMAKE_INSTALL_FULL_LIBEXECDIR@/rp1-ptp-pps/rp1_pps.py --interface ${PPS_INTERFACE} --testptp ${TESTPTP} --mode ${PPS_MODE} --gpio ${PPS_GPIO} --ownership-file /var/lib/rp1-ptp-pps/owned.json --cleanup +Restart=on-failure +RestartSec=5s +KillSignal=SIGTERM +TimeoutStopSec=30s +CapabilityBoundingSet=CAP_SYS_TIME +AmbientCapabilities=CAP_SYS_TIME +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes + +[Install] +WantedBy=multi-user.target diff --git a/rp1-ptp-pps/rp1_pps.py b/rp1-ptp-pps/rp1_pps.py new file mode 100644 index 0000000..8fd3ee2 --- /dev/null +++ b/rp1-ptp-pps/rp1_pps.py @@ -0,0 +1,1148 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Guarded RP1 PPS control and qualification using the standard testptp CLI. + +The default action is plan-only. Use --execute to make a pin mapping or start +PPS. GPIO values are RP1/BCM numbers 0..27, not physical header pin numbers. +Duplex mode uses testptp for simultaneous PEROUT and one or two EXTTS channels. +Use a positive event count for qualification or zero for supervised service. +""" +import argparse +from concurrent.futures import ThreadPoolExecutor +import fcntl +import json +import os +from pathlib import Path +import queue +import re +import shlex +import signal +import struct +import subprocess +import sys +import threading +import time + +from phc_identity import inventory, select_mac + + +CAPABILITY_PATTERNS = { + 'extts': re.compile(r'^\s*(\d+) external time stamp channels$'), + 'perout': re.compile(r'^\s*(\d+) programmable periodic signals$'), + 'pins': re.compile(r'^\s*(\d+) programmable pins$'), +} +PIN_PATTERN = re.compile(r'^name (.+) index (\d+) func (\d+) chan (\d+)$') +EVENT_PATTERN = re.compile(r'^event index (\d+) at (\d+)\.(\d{9})$') +ERROR_PREFIXES = ('opening ', 'clock_adjtime:', 'PTP_PIN_SETFUNC:', + 'PTP_PIN_GETFUNC:', + 'PTP_EXTTS_REQUEST', 'PTP_PEROUT_REQUEST:', 'read:') + + +def _ioc(direction, type_, number, size): + return (direction << 30) | (size << 16) | (type_ << 8) | number + + +PTP_EXTTS_REQUEST_IOCTL = _ioc(1, ord('='), 2, 16) + + +class PHCUnavailable(RuntimeError): + """The network PHC has not registered yet and may appear later.""" + + +def _optional_gpio(value): + if value.lower() in ('none', 'off'): + return None + return int(value) + + +class RP1PPS: + def __init__(self, *, interface='eth0', testptp='testptp', + inventory_fn=None, monotonic=None, sleep=None, + popen_factory=None, output_monitor_interval=1.0, + input_retry_delay=1.0, extts_disable_fn=None, ownership_file=None, + boot_id_fn=None): + self.interface = interface + self.testptp = str(testptp) + if output_monitor_interval <= 0: + raise ValueError('output monitor interval must be positive') + if input_retry_delay <= 0: + raise ValueError('input retry delay must be positive') + self.inventory_fn = inventory_fn or inventory + self.monotonic = monotonic or time.monotonic + self.sleep = sleep or time.sleep + self.popen_factory = popen_factory or subprocess.Popen + self.output_monitor_interval = output_monitor_interval + self.input_retry_delay = input_retry_delay + self.extts_disable_fn = extts_disable_fn or self._disable_extts_request + self.ownership_file = Path(ownership_file) if ownership_file else None + self.boot_id_fn = boot_id_fn or self._read_boot_id + self.owned_mappings = {} + self.clock = None + self.capabilities = None + self.pins = None + + @staticmethod + def _read_boot_id(): + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + + def _save_owned_mappings(self): + if self.ownership_file is None: + return + if not self.owned_mappings: + self.ownership_file.unlink(missing_ok=True) + return + self.ownership_file.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + record = {'interface': self.interface, 'phc': self.clock['device'], + 'boot_id': self.boot_id_fn(), + 'mappings': sorted(self.owned_mappings.values(), + key=lambda row: row['gpio'])} + temporary = self.ownership_file.with_name( + self.ownership_file.name + f'.{os.getpid()}.tmp') + temporary.write_text(json.dumps(record, sort_keys=True) + '\n') + temporary.chmod(0o600) + temporary.replace(self.ownership_file) + + def _track_mapping(self, gpio, function, channel): + self.owned_mappings[gpio] = {'gpio': gpio, 'function': function, + 'channel': channel} + self._save_owned_mappings() + + def _forget_mapping(self, gpio): + self.owned_mappings.pop(gpio, None) + self._save_owned_mappings() + + def cleanup_owned_mappings(self, expected): + """Clean only the exact mapping set previously recorded by this service.""" + if self.ownership_file is None or not self.ownership_file.exists(): + return {'cleaned': False, 'reason': 'no owned mapping record'} + try: + record = json.loads(self.ownership_file.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f'Cannot read PPS ownership record: {exc}') from exc + if not isinstance(record, dict): + raise RuntimeError('PPS ownership record is not a JSON object') + mappings = record.get('mappings') + if mappings is None and {'mode', 'gpio'} <= record.keys(): + mappings = [{'gpio': record['gpio'], + 'function': 1 if record['mode'] == 'input' else 2, + 'channel': 0}] + if not isinstance(mappings, list): + raise RuntimeError('PPS ownership record has no valid mapping list') + normalize = lambda rows: sorted( + ({'gpio': row.get('gpio'), 'function': row.get('function'), + 'channel': row.get('channel', 0)} for row in rows), + key=lambda row: row['gpio']) + if (record.get('interface') != self.interface or + normalize(mappings) != normalize(expected)): + return {'cleaned': False, 'reason': 'ownership record mismatch'} + try: + boot_id = self.boot_id_fn() + except OSError as exc: + raise RuntimeError(f'Cannot verify boot identity for PPS cleanup: {exc}') from exc + if record.get('boot_id') != boot_id: + self.ownership_file.unlink(missing_ok=True) + return {'cleaned': False, 'reason': 'record belongs to an earlier boot'} + + try: + self._discover_phc() + except PHCUnavailable as exc: + return {'cleaned': False, 'reason': f'PHC unavailable: {exc}'} + if record.get('phc') != self.clock['device']: + return {'cleaned': False, 'reason': 'PHC identity changed'} + pins = self._read_pin_map() + expected_by_gpio = {row['gpio']: row for row in mappings} + occupied = {gpio: row for gpio, row in pins.items() + if row['function'] != 0} + unexpected = set(occupied) - set(expected_by_gpio) + if unexpected: + raise RuntimeError(f'PPS cleanup found unrelated PTP pin mappings: ' + f'{sorted(unexpected)}') + for gpio, actual in occupied.items(): + owner = expected_by_gpio[gpio] + if (actual['function'] != owner['function'] or + actual['channel'] != owner['channel']): + raise RuntimeError(f'PPS cleanup ownership mismatch on GPIO{gpio}') + + # Disable timestamp requests before stopping their testptp readers or + # releasing GPIOs. This also recovers a daemon that died unexpectedly. + for row in mappings: + if row['function'] == 1: + self.extts_disable_fn(row['channel']) + if any(row['function'] == 2 for row in mappings): + self._command('-p', '0', timeout=10, + acknowledgments=('periodic output request okay',)) + for row in reversed(mappings): + if row['gpio'] in occupied: + self._command('-L', f"{row['gpio']},0", index=row['channel'], + timeout=10, + acknowledgments=('set pin function okay',)) + self.ownership_file.unlink(missing_ok=True) + return {'cleaned': True, 'mappings': mappings} + + def _disable_extts_request(self, channel): + """Disable a live standard PTP EXTTS request before stopping its reader.""" + fd = os.open(self.clock['device'], os.O_RDWR | os.O_CLOEXEC) + try: + request = bytearray(struct.pack('=IIII', channel, 0, 0, 0)) + fcntl.ioctl(fd, PTP_EXTTS_REQUEST_IOCTL, request, True) + finally: + os.close(fd) + + def _command(self, *options, index=0, timeout=10, acknowledgments=()): + if self.clock is None: + raise RuntimeError('Discover the RP1 PHC before running testptp') + argv = [self.testptp, '-d', self.clock['device'], '-i', str(index), + *map(str, options)] + child = self.popen_factory(argv, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + try: + stdout, stderr = child.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + child.terminate() + try: + stdout, stderr = child.communicate(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + stdout, stderr = child.communicate() + raise TimeoutError(f'testptp timed out: {shlex.join(argv)}\n{stdout}{stderr}') + except KeyboardInterrupt: + child.terminate() + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + raise + output = stdout + stderr + if child.returncode: + raise RuntimeError(f'testptp exited {child.returncode}: {output.strip()}') + if any(line.startswith(ERROR_PREFIXES) for line in output.splitlines()): + raise RuntimeError(f'testptp reported an ioctl/read error: {output.strip()}') + for expected in acknowledgments: + if expected not in stdout.splitlines(): + raise RuntimeError(f'testptp did not acknowledge {expected!r}: {output.strip()}') + return stdout + + def _discover_phc(self): + try: + info = self.inventory_fn(self.interface) + except OSError as exc: + raise PHCUnavailable(str(exc)) from exc + if not any(row.get('rp1_mac') for row in info.get('clocks', [])): + raise PHCUnavailable('No live RP1 Ethernet PHC is registered') + self.clock = select_mac(info) + if info['driver'] not in ('macb', 'macb_pps'): + raise RuntimeError(f"Unexpected RP1 MAC PHC driver: {info['driver']!r}") + expected = {'extts': 2, 'perout': 1, 'pins': 28} + if self.clock.get('capabilities', {}) != expected: + raise RuntimeError(f'Unexpected RP1 PPS capabilities: ' + f'{self.clock.get("capabilities", {})!r}') + return info, expected + + def _read_pin_map(self): + pin_output = self._command('-l') + pins = {} + for line in pin_output.splitlines(): + match = PIN_PATTERN.match(line) + if match: + name, index, function, channel = match.groups() + pins[int(index)] = {'name': name, 'function': int(function), + 'channel': int(channel)} + if set(pins) != set(range(28)): + raise RuntimeError(f'testptp returned an incomplete pin map: {sorted(pins)}') + return pins + + def preflight(self): + info, expected = self._discover_phc() + cap_output = self._command('-c') + observed = {} + for line in cap_output.splitlines(): + for key, pattern in CAPABILITY_PATTERNS.items(): + match = pattern.match(line) + if match: + observed[key] = int(match.group(1)) + if observed != expected: + raise RuntimeError(f'testptp capabilities differ from sysfs: {observed!r}') + pins = self._read_pin_map() + occupied = {gpio: row for gpio, row in pins.items() if row['function'] != 0} + if occupied: + raise RuntimeError(f'Existing PTP pin mappings must be cleared first: {occupied}') + self.capabilities, self.pins = observed, pins + return {'interface': self.interface, 'driver': info['driver'], + 'phc': self.clock['device'], 'capabilities': observed, + 'pins': pins} + + def plan(self, mode, gpio, *, edge='rising', events=4, + period_ns=1_000_000_000, high_ns=10_000_000, + phase_ns=0, duration=None, timeout=None, + event_timeout=3.0, channel=0): + if mode not in ('input', 'output'): + raise ValueError('mode must be input or output') + if not isinstance(gpio, int) or not 0 <= gpio < 28: + raise ValueError('GPIO must be an RP1/BCM header GPIO in 0..27') + if self.pins is None: + raise RuntimeError('Run preflight before creating a plan') + channel_count = self.capabilities['extts'] if mode == 'input' else 1 + if not isinstance(channel, int) or not 0 <= channel < channel_count: + if mode == 'input': + raise ValueError(f'EXTTS channel must be in 0..{channel_count - 1}') + raise ValueError('PEROUT channel must be 0') + if mode == 'input' and channel == 1: + raise ValueError('EXTTS channel 1 requires active PEROUT; use duplex mode') + function = 1 if mode == 'input' else 2 + base = [self.testptp, '-d', self.clock['device'], '-i', str(channel)] + mapping = ['-L', f'{gpio},{function}'] + if mode == 'input': + if edge not in ('rising', 'falling'): + raise ValueError('edge must be rising or falling') + if events < 0: + raise ValueError('events must be zero for continuous capture or positive') + testptp_events = 2147483647 if events == 0 else events + capture = ['-E', '1' if edge == 'rising' else '2', + '-e', str(testptp_events)] + return {'mode': mode, 'gpio': gpio, 'channel': channel, 'edge': edge, + 'events': events, 'continuous': events == 0, + 'mapping': shlex.join(base + mapping), + 'capture': shlex.join(base + mapping + capture), + 'cleanup': shlex.join(base + ['-L', f'{gpio},0']), + 'timeout_s': (event_timeout if events == 0 else + timeout if timeout is not None else max(5, events * 3))} + if period_ns < 1 or high_ns < 1 or high_ns >= period_ns: + raise ValueError('require 0 < high-ns < period-ns') + if phase_ns < 0 or phase_ns >= period_ns: + raise ValueError('phase-ns must be in [0, period-ns)') + start = ['-p', str(period_ns), '-H', str(phase_ns), '-w', str(high_ns)] + return {'mode': mode, 'gpio': gpio, 'period_ns': period_ns, + 'high_ns': high_ns, 'phase_ns': phase_ns, + 'mapping': shlex.join(base + mapping), + 'start': shlex.join(base + mapping + start), + 'stop': shlex.join(base + ['-p', '0']), + 'cleanup': shlex.join(base + ['-L', f'{gpio},0']), + 'duration_s': duration} + + def plan_duplex(self, input_gpio, output_gpio, *, input_channel=0, + input_gpio2=None, + edge='rising', events=4, + period_ns=1_000_000_000, high_ns=10_000_000, + phase_ns=0, timeout=None, event_timeout=5.0): + if self.pins is None: + raise RuntimeError('Run preflight before creating a plan') + if not isinstance(input_channel, int) or not 0 <= input_channel < self.capabilities['extts']: + raise ValueError(f'EXTTS channel must be in 0..{self.capabilities["extts"] - 1}') + if not isinstance(input_gpio, int) or not 0 <= input_gpio < 28: + raise ValueError('Input GPIO must be an RP1/BCM GPIO in 0..27') + if not isinstance(output_gpio, int) or not 0 <= output_gpio < 28: + raise ValueError('Output GPIO must be an RP1/BCM GPIO in 0..27') + if input_gpio == output_gpio: + raise ValueError('Duplex input and output must use different GPIOs') + if input_gpio2 is not None: + if input_channel != 0: + raise ValueError('Two-input duplex uses EXTTS channels 0 and 1') + if not isinstance(input_gpio2, int) or not 0 <= input_gpio2 < 28: + raise ValueError('Second input GPIO must be an RP1/BCM GPIO in 0..27') + if input_gpio2 in (input_gpio, output_gpio): + raise ValueError('Each duplex direction must use a different GPIO') + if edge != 'rising': + raise ValueError('duplex counter lanes currently support rising edges only') + if events < 0: + raise ValueError('events must be zero for continuous duplex or positive') + if period_ns < 1 or high_ns < 1 or high_ns >= period_ns: + raise ValueError('require 0 < high-ns < period-ns') + if phase_ns < 0 or phase_ns >= period_ns: + raise ValueError('phase-ns must be in [0, period-ns)') + if event_timeout <= 0 or (timeout is not None and timeout <= 0): + raise ValueError('timeouts must be positive') + testptp_events = 2147483647 if events == 0 else events + base = [self.testptp, '-d', self.clock['device'], '-i', '0'] + input_base = [self.testptp, '-d', self.clock['device'], + '-i', str(input_channel)] + plan = { + 'mode': 'duplex', 'input_gpio': input_gpio, + 'input_channel': input_channel, + 'output_gpio': output_gpio, 'edge': edge, 'events': events, + 'period_ns': period_ns, 'high_ns': high_ns, + 'phase_ns': phase_ns, + 'map_output': shlex.join(base + ['-L', f'{output_gpio},2']), + 'map_input': shlex.join(input_base + ['-L', f'{input_gpio},1']), + 'start_output': shlex.join(base + ['-p', str(period_ns), + '-H', str(phase_ns), '-w', str(high_ns)]), + 'capture_input': shlex.join(input_base + ['-E', + '1' if edge == 'rising' else '2', '-e', str(testptp_events)]), + 'stop_output': shlex.join(base + ['-p', '0']), + 'clear_input': shlex.join(input_base + ['-L', f'{input_gpio},0']), + 'clear_output': shlex.join(base + ['-L', f'{output_gpio},0']), + 'timeout_s': (timeout if timeout is not None else + (None if events == 0 else max(5, events * 3))), + 'continuous': events == 0, + 'event_timeout_s': event_timeout, + } + if input_gpio2 is not None: + second_base = [self.testptp, '-d', self.clock['device'], '-i', '1'] + plan['input_gpio2'] = input_gpio2 + plan['map_input2'] = shlex.join( + second_base + ['-L', f'{input_gpio2},1']) + plan['capture_input2'] = shlex.join(second_base + ['-E', + '1' if edge == 'rising' else '2', '-e', str(testptp_events)]) + plan['clear_input2'] = shlex.join( + second_base + ['-L', f'{input_gpio2},0']) + return plan + + def _capture_continuous(self, edge, *, channel=0, event_timeout, + event_callback=None, cleanup_state=None): + argv = [self.testptp, '-d', self.clock['device'], '-i', str(channel), + '-E', '1' if edge == 'rising' else '2', '-e', '2147483647'] + child = self.popen_factory(argv, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, + bufsize=1) + lines = queue.Queue() + + def read_lines(): + try: + for line in child.stdout: + lines.put(line.rstrip('\r\n')) + finally: + lines.put(None) + + reader = threading.Thread(target=read_lines, daemon=True) + reader.start() + events_received = 0 + acknowledged = False + last_event = time.monotonic() + stopped_by = 'process-exit' + cleanup_state = cleanup_state if cleanup_state is not None else {} + cleanup_state['active'] = True + try: + while True: + remaining = event_timeout - (time.monotonic() - last_event) + if remaining <= 0: + raise TimeoutError(f'No PPS input event received for {event_timeout:g} seconds') + try: + line = lines.get(timeout=remaining) + except queue.Empty: + raise TimeoutError(f'No PPS input event received for {event_timeout:g} seconds') + if line is None: + child.wait() + raise RuntimeError('testptp stopped while continuous input capture was active') + if line.startswith(ERROR_PREFIXES): + raise RuntimeError(f'testptp reported an input error: {line}') + if line == 'external time stamp request okay': + acknowledged = True + continue + match = EVENT_PATTERN.match(line) + if not match: + continue + if not acknowledged: + raise RuntimeError('testptp returned an event without acknowledging EXTS setup') + event_channel, sec, nsec = match.groups() + if int(event_channel) != int(argv[argv.index('-i') + 1]): + raise RuntimeError(f'Unexpected PTP timestamp channel {event_channel}') + event = {'channel': int(event_channel), + 'timestamp_ns': int(sec) * 1_000_000_000 + int(nsec)} + events_received += 1 + last_event = time.monotonic() + if event_callback: + event_callback(event) + except KeyboardInterrupt: + stopped_by = 'signal' + finally: + disable_error = None + if cleanup_state.get('active'): + try: + self.extts_disable_fn(channel) + cleanup_state['active'] = False + except Exception as exc: + disable_error = exc + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + reader.join(timeout=2) + if child.stdout: + child.stdout.close() + if disable_error: + raise RuntimeError( + f'EXTTS channel {channel} disable failed; retaining its ' + f'GPIO mapping: {disable_error}') + return {'events_received': events_received, 'stopped_by': stopped_by} + + def capture(self, gpio, *, channel=0, edge='rising', events=4, timeout=None, + event_timeout=3.0, event_callback=None): + plan = self.plan('input', gpio, edge=edge, events=events, + timeout=timeout, event_timeout=event_timeout, + channel=channel) + timeout = plan['timeout_s'] + mapped = False + mapping_attempted = False + continuous_state = {'active': False} + request_state = {'active': False} + try: + mapping_attempted = True + self._track_mapping(gpio, 1, channel) + self._command('-L', f'{gpio},1', index=channel, timeout=10, + acknowledgments=('set pin function okay',)) + mapped = True + if events == 0: + events_received = 0 + input_inactive = False + + def report_event(event): + nonlocal events_received, input_inactive + events_received += 1 + if input_inactive: + print('PPS input resumed', file=sys.stderr, flush=True) + input_inactive = False + if event_callback: + event_callback(event) + + while True: + try: + capture = self._capture_continuous(edge, channel=channel, + event_timeout=event_timeout, + event_callback=report_event, + cleanup_state=continuous_state) + except TimeoutError as exc: + if not input_inactive: + print(f'{exc}; rearming input capture in ' + f'{self.input_retry_delay:g} seconds', + file=sys.stderr, flush=True) + input_inactive = True + try: + self.sleep(self.input_retry_delay) + except KeyboardInterrupt: + return {'passed': True, 'mode': 'input', + 'gpio': gpio, 'channel': channel, + 'edge': edge, 'continuous': True, + 'events_received': events_received, + 'stopped_by': 'signal'} + continue + return {'passed': True, 'mode': 'input', + 'gpio': gpio, 'channel': channel, + 'edge': edge, 'continuous': True, + 'events_received': events_received, + 'stopped_by': capture['stopped_by']} + request_state['active'] = True + output = self._command('-E', '1' if edge == 'rising' else '2', + '-e', str(events), + index=channel, + timeout=timeout, + acknowledgments=('external time stamp request okay',)) + request_state['active'] = False + records = [] + for line in output.splitlines(): + match = EVENT_PATTERN.match(line) + if match: + event_channel, sec, nsec = match.groups() + records.append({'channel': int(event_channel), + 'timestamp_ns': int(sec) * 1_000_000_000 + int(nsec)}) + if len(records) != events or any(row['channel'] != channel for row in records): + raise RuntimeError(f'Expected {events} channel-{channel} timestamps, received {records!r}') + return {'passed': True, 'mode': 'input', 'gpio': gpio, + 'channel': channel, 'edge': edge, + 'events': records, 'testptp_output': output} + finally: + disable_error = None + if request_state['active']: + try: + self.extts_disable_fn(channel) + request_state['active'] = False + except Exception as exc: + disable_error = exc + if mapping_attempted and not continuous_state['active'] and not request_state['active']: + self._command('-L', f'{gpio},0', index=channel, timeout=10, + acknowledgments=('set pin function okay',)) + self._forget_mapping(gpio) + if disable_error: + raise RuntimeError( + f'EXTTS channel {channel} disable failed; retaining its ' + f'GPIO mapping: {disable_error}') + + def _verify_output_mapping(self, gpio): + output = self._command('-l', timeout=10) + pins = {} + for line in output.splitlines(): + match = PIN_PATTERN.match(line) + if match: + name, index, function, channel = match.groups() + pins[int(index)] = {'name': name, 'function': int(function), + 'channel': int(channel)} + if set(pins) != set(range(28)): + raise RuntimeError('PTP pin map became incomplete while PPS output was active') + occupied = {index: row for index, row in pins.items() + if row['function'] and index != gpio} + if occupied: + raise RuntimeError(f'Other PTP pin mappings appeared during output: {occupied}') + if pins[gpio]['function'] != 2 or pins[gpio]['channel'] != 0: + raise RuntimeError(f'GPIO{gpio} lost its PEROUT mapping while output was active') + + def _verify_duplex_mappings(self, input_gpio, output_gpio, input_channel=0, + other_inputs=()): + output = self._command('-l', timeout=10) + pins = {} + for line in output.splitlines(): + match = PIN_PATTERN.match(line) + if match: + name, index, function, channel = match.groups() + pins[int(index)] = {'name': name, 'function': int(function), + 'channel': int(channel)} + if set(pins) != set(range(28)): + raise RuntimeError('PTP pin map became incomplete during duplex PPS') + expected = {input_gpio: (1, input_channel), output_gpio: (2, 0)} + expected.update({gpio: (1, channel) for gpio, channel in other_inputs}) + occupied = {index: row for index, row in pins.items() + if row['function'] and index not in expected} + if occupied: + raise RuntimeError(f'Unexpected PTP pin mappings during duplex PPS: {occupied}') + for gpio, (function, channel) in expected.items(): + if pins[gpio]['function'] != function or pins[gpio]['channel'] != channel: + label = 'EXTTS' if function == 1 else 'PEROUT' + raise RuntimeError(f'GPIO{gpio} lost its {label} mapping during duplex PPS') + + def _capture_duplex_events(self, input_gpio, output_gpio, edge, events, *, + input_channel=0, timeout, + event_timeout, event_callback=None, + other_inputs=(), cleanup_state=None): + argv = [self.testptp, '-d', self.clock['device'], '-i', str(input_channel), + '-E', '1' if edge == 'rising' else '2', '-e', str(events)] + child = self.popen_factory(argv, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, + bufsize=1) + lines = queue.Queue() + + def read_lines(): + try: + for line in child.stdout: + lines.put(line.rstrip('\r\n')) + finally: + lines.put(None) + + reader = threading.Thread(target=read_lines, daemon=True) + reader.start() + records = [] + acknowledged = False + start = time.monotonic() + last_event = start + next_mapping_check = start + self.output_monitor_interval + stopped = False + cleanup_state = cleanup_state if cleanup_state is not None else { + 'active_channels': set()} + cleanup_state['active_channels'].add(input_channel) + try: + while len(records) < events: + now = time.monotonic() + if now - start >= timeout: + raise TimeoutError(f'testptp input exceeded {timeout:g} seconds') + if now - last_event >= event_timeout: + raise TimeoutError(f'No PPS input event received for {event_timeout:g} seconds') + if now >= next_mapping_check: + self._verify_duplex_mappings(input_gpio, output_gpio, + input_channel, other_inputs) + next_mapping_check = time.monotonic() + self.output_monitor_interval + wait = min(0.2, timeout - (now - start), + event_timeout - (now - last_event)) + try: + line = lines.get(timeout=max(0.001, wait)) + except queue.Empty: + continue + if line is None: + child.wait() + stopped = True + break + if line.startswith(ERROR_PREFIXES): + raise RuntimeError(f'testptp reported an input error: {line}') + if line == 'external time stamp request okay': + acknowledged = True + continue + match = EVENT_PATTERN.match(line) + if not match: + continue + if not acknowledged: + raise RuntimeError('testptp returned an event without acknowledging EXTS setup') + channel, sec, nsec = match.groups() + if int(channel) != input_channel: + raise RuntimeError(f'Unexpected PTP timestamp channel {channel}') + event = {'channel': input_channel, + 'timestamp_ns': int(sec) * 1_000_000_000 + int(nsec)} + records.append(event) + last_event = time.monotonic() + if event_callback: + event_callback(event) + if len(records) != events: + raise RuntimeError(f'Expected {events} channel-{input_channel} timestamps, received {records!r}') + if not acknowledged: + raise RuntimeError('testptp did not acknowledge EXTS setup') + if not stopped: + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + raise TimeoutError('testptp did not disable EXTTS after the requested events') + stopped = True + if child.returncode: + raise RuntimeError(f'testptp exited {child.returncode} during EXTTS capture') + self.extts_disable_fn(input_channel) + cleanup_state['active_channels'].discard(input_channel) + return records + finally: + disable_error = None + if input_channel in cleanup_state['active_channels']: + try: + self.extts_disable_fn(input_channel) + cleanup_state['active_channels'].discard(input_channel) + except Exception as exc: + disable_error = exc + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + reader.join(timeout=2) + if child.stdout: + child.stdout.close() + if disable_error: + raise RuntimeError( + f'EXTTS channel {input_channel} disable failed; retaining ' + f'its GPIO mapping: {disable_error}') + + def _capture_duplex_continuous(self, inputs, output_gpio, edge, *, + event_timeout, event_callback=None, + cleanup_state=None): + """Supervise both EXTTS readers and rearm inputs without stopping PEROUT.""" + cleanup_state = (cleanup_state if cleanup_state is not None else + {'active_channels': set()}) + channels = [channel for _gpio, channel in inputs] + counts = {channel: 0 for channel in channels} + input_inactive = False + + while True: + lines = queue.Queue() + children = {} + readers = [] + acknowledged = set() + last_event = {channel: time.monotonic() for channel in channels} + next_mapping_check = time.monotonic() + self.output_monitor_interval + outcome = None + + def read_lines(channel, child): + try: + for line in child.stdout: + lines.put((channel, line.rstrip('\r\n'))) + finally: + lines.put((channel, None)) + + try: + for gpio, channel in inputs: + argv = [self.testptp, '-d', self.clock['device'], + '-i', str(channel), '-E', + '1' if edge == 'rising' else '2', + '-e', '2147483647'] + child = self.popen_factory(argv, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1) + children[channel] = child + cleanup_state['active_channels'].add(channel) + reader = threading.Thread(target=read_lines, + args=(channel, child), daemon=True) + readers.append(reader) + reader.start() + + while True: + now = time.monotonic() + expired = [channel for channel in channels + if now - last_event[channel] >= event_timeout] + if expired: + raise TimeoutError( + f'No PPS input event received on EXTTS channels {expired} ' + f'for {event_timeout:g} seconds') + if now >= next_mapping_check: + for gpio, channel in inputs: + self._verify_duplex_mappings(gpio, output_gpio, + channel, tuple(item for item in inputs + if item != (gpio, channel))) + next_mapping_check = now + self.output_monitor_interval + wait = min(0.2, *(event_timeout - + (now - last_event[channel]) for channel in channels)) + try: + channel, line = lines.get(timeout=max(0.001, wait)) + except queue.Empty: + continue + if line is None: + children[channel].wait() + raise RuntimeError( + f'testptp stopped while EXTTS channel {channel} was active') + if line.startswith(ERROR_PREFIXES): + raise RuntimeError( + f'testptp channel {channel} reported an input error: {line}') + if line == 'external time stamp request okay': + acknowledged.add(channel) + continue + match = EVENT_PATTERN.match(line) + if not match: + continue + if channel not in acknowledged: + raise RuntimeError( + f'testptp channel {channel} returned an event before ' + 'acknowledging EXTTS setup') + event_channel, sec, nsec = match.groups() + if int(event_channel) != channel: + raise RuntimeError( + f'Unexpected PTP timestamp channel {event_channel}, ' + f'expected {channel}') + event = {'channel': channel, + 'timestamp_ns': int(sec) * 1_000_000_000 + int(nsec)} + counts[channel] += 1 + last_event[channel] = time.monotonic() + if input_inactive: + print('PPS inputs resumed', file=sys.stderr, flush=True) + input_inactive = False + if event_callback: + event_callback(event) + except KeyboardInterrupt: + outcome = 'signal' + except Exception as exc: + outcome = exc + finally: + disable_errors = [] + for channel in channels: + if channel not in cleanup_state['active_channels']: + continue + try: + self.extts_disable_fn(channel) + cleanup_state['active_channels'].remove(channel) + except Exception as exc: + disable_errors.append(f'EXTTS{channel}: {exc}') + for child in children.values(): + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + for reader in readers: + reader.join(timeout=2) + for child in children.values(): + if child.stdout: + child.stdout.close() + if disable_errors: + raise RuntimeError( + 'EXTTS disable failed; retaining all GPIO mappings and ' + 'PEROUT: ' + '; '.join(disable_errors)) + + if isinstance(outcome, TimeoutError): + if not input_inactive: + print(f'{outcome}; rearming input capture in ' + f'{self.input_retry_delay:g} seconds', + file=sys.stderr, flush=True) + input_inactive = True + try: + self.sleep(self.input_retry_delay) + except KeyboardInterrupt: + return {'events_received': counts, 'stopped_by': 'signal'} + continue + if isinstance(outcome, Exception): + raise outcome + return {'events_received': counts, 'stopped_by': 'signal'} + + def duplex(self, input_gpio, output_gpio, *, input_channel=0, + input_gpio2=None, + edge='rising', events=4, + period_ns=1_000_000_000, high_ns=10_000_000, + phase_ns=0, timeout=None, event_timeout=5.0, + event_callback=None): + plan = self.plan_duplex(input_gpio, output_gpio, + input_channel=input_channel, input_gpio2=input_gpio2, edge=edge, + events=events, period_ns=period_ns, high_ns=high_ns, + phase_ns=phase_ns, timeout=timeout, event_timeout=event_timeout) + inputs = [(input_gpio, input_channel)] + if input_gpio2 is not None: + inputs.append((input_gpio2, 1)) + output_mapping_attempted = False + input_mappings_attempted = [] + output_request_attempted = False + output_stopped = False + cleanup_errors = [] + cleanup_state = {'active_channels': set()} + try: + output_mapping_attempted = True + self._track_mapping(output_gpio, 2, 0) + self._command('-L', f'{output_gpio},2', timeout=10, + acknowledgments=('set pin function okay',)) + for gpio, channel in inputs: + input_mappings_attempted.append((gpio, channel)) + self._track_mapping(gpio, 1, channel) + self._command('-L', f'{gpio},1', index=channel, timeout=10, + acknowledgments=('set pin function okay',)) + self._verify_duplex_mappings(input_gpio, output_gpio, + input_channel, inputs[1:]) + output_request_attempted = True + output_response = self._command('-p', str(period_ns), '-H', + str(phase_ns), '-w', str(high_ns), timeout=10, + acknowledgments=('periodic output request okay',)) + if events == 0: + capture = self._capture_duplex_continuous( + inputs, output_gpio, edge, event_timeout=event_timeout, + event_callback=event_callback, cleanup_state=cleanup_state) + events_by_channel = capture['events_received'] + records = [] + elif len(inputs) == 1: + records = self._capture_duplex_events( + input_gpio, output_gpio, edge, events, + input_channel=input_channel, timeout=plan['timeout_s'], + event_timeout=event_timeout, event_callback=event_callback, + cleanup_state=cleanup_state) + events_by_channel = {input_channel: records} + else: + events_by_channel = {} + with ThreadPoolExecutor(max_workers=len(inputs)) as executor: + futures = {} + for gpio, channel in inputs: + other_inputs = tuple(item for item in inputs + if item != (gpio, channel)) + futures[channel] = executor.submit( + self._capture_duplex_events, gpio, output_gpio, + edge, events, input_channel=channel, + timeout=plan['timeout_s'], + event_timeout=event_timeout, + event_callback=event_callback, + other_inputs=other_inputs, + cleanup_state=cleanup_state) + for channel, future in futures.items(): + events_by_channel[channel] = future.result() + records = sorted( + [dict(event, gpio=gpio) + for gpio, channel in inputs + for event in events_by_channel[channel]], + key=lambda event: event['timestamp_ns']) + return {'passed': True, 'mode': 'duplex', + 'input_gpio': input_gpio, 'output_gpio': output_gpio, + 'input_channel': input_channel, + 'input_gpio2': input_gpio2, + 'events_by_channel': events_by_channel, + 'edge': edge, 'events': records, + 'period_ns': period_ns, 'high_ns': high_ns, + 'phase_ns': phase_ns, 'testptp_output': output_response, + 'continuous': events == 0, + 'stopped_by': capture.get('stopped_by') if events == 0 else None} + finally: + inputs_stopped = not cleanup_state['active_channels'] + if inputs_stopped: + for gpio, channel in reversed(input_mappings_attempted): + try: + self._command('-L', f'{gpio},0', index=channel, + timeout=10, + acknowledgments=('set pin function okay',)) + self._forget_mapping(gpio) + except Exception as exc: + cleanup_errors.append(f'EXTTS{channel} unmap failed: {exc}') + elif input_mappings_attempted: + cleanup_errors.append( + 'EXTTS disable failed; retaining all GPIO mappings and PEROUT') + if output_request_attempted and inputs_stopped: + try: + self._command('-p', '0', timeout=10, + acknowledgments=('periodic output request okay',)) + output_stopped = True + except Exception as exc: + cleanup_errors.append(f'PEROUT disable failed: {exc}') + if output_mapping_attempted and (not output_request_attempted or + (inputs_stopped and output_stopped)): + try: + self._command('-L', f'{output_gpio},0', timeout=10, + acknowledgments=('set pin function okay',)) + self._forget_mapping(output_gpio) + except Exception as exc: + cleanup_errors.append(f'PEROUT unmap failed: {exc}') + if cleanup_errors: + raise RuntimeError('; '.join(cleanup_errors)) + + def output(self, gpio, *, period_ns=1_000_000_000, + high_ns=10_000_000, phase_ns=0, duration=None): + plan = self.plan('output', gpio, period_ns=period_ns, + high_ns=high_ns, phase_ns=phase_ns, duration=duration) + mapped = False + start_attempted = False + cleanup_error = None + started_at = None + stopped_by = 'duration' + try: + self._track_mapping(gpio, 2, 0) + self._command('-L', f'{gpio},2', timeout=10, + acknowledgments=('set pin function okay',)) + mapped = True + start_attempted = True + output = self._command('-p', str(period_ns), + '-H', str(phase_ns), '-w', str(high_ns), timeout=10, + acknowledgments=('periodic output request okay',)) + started_at = self.monotonic() + deadline = None if duration is None else started_at + duration + next_monitor = started_at + try: + while deadline is None or self.monotonic() < deadline: + now = self.monotonic() + if now >= next_monitor: + self._verify_output_mapping(gpio) + next_monitor = now + self.output_monitor_interval + wait = min(0.25, max(0, next_monitor - self.monotonic())) + if deadline is not None: + wait = min(wait, max(0, deadline - self.monotonic())) + self.sleep(wait) + except KeyboardInterrupt: + stopped_by = 'interrupt' + finally: + if start_attempted: + try: + self._command('-p', '0', timeout=10, + acknowledgments=('periodic output request okay',)) + except Exception as exc: + cleanup_error = f'PEROUT disable failed: {exc}' + if mapped and cleanup_error is None: + try: + self._command('-L', f'{gpio},0', timeout=10, + acknowledgments=('set pin function okay',)) + self._forget_mapping(gpio) + except Exception as exc: + cleanup_error = f'PEROUT stopped but pin unmap failed: {exc}' + if cleanup_error: + raise RuntimeError(cleanup_error) + return {'passed': True, 'mode': 'output', 'gpio': gpio, + 'period_ns': period_ns, 'high_ns': high_ns, + 'phase_ns': phase_ns, 'duration_s': duration, + 'stopped_by': stopped_by, 'testptp_output': output} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--interface', default='eth0') + parser.add_argument('--testptp', default='testptp') + parser.add_argument('--mode', choices=('input', 'output', 'duplex'), required=True) + parser.add_argument('--gpio', type=int, + help='single-direction GPIO, RP1/BCM number 0..27') + parser.add_argument('--input-gpio', type=int, + help='duplex EXTTS GPIO, RP1/BCM number 0..27') + parser.add_argument('--input-gpio2', type=_optional_gpio, + help='optional second duplex EXTTS GPIO on channel 1') + parser.add_argument('--output-gpio', type=int, + help='duplex PEROUT GPIO, RP1/BCM number 0..27') + parser.add_argument('--input-channel', type=int, default=0, + help='PTP EXTTS channel, 0 or 1') + parser.add_argument('--edge', choices=('rising', 'falling'), default='rising') + parser.add_argument('--events', type=int, default=4, + help='input event count; use 0 for continuous service capture') + parser.add_argument('--timeout', type=float, + help='input capture timeout, default is max(5 s, 3 s/event)') + parser.add_argument('--event-timeout', type=float, default=5.0, + help='continuous capture inactivity timeout in seconds') + parser.add_argument('--period-ns', type=int, default=1_000_000_000) + parser.add_argument('--high-ns', type=int, default=10_000_000) + parser.add_argument('--phase-ns', type=int, default=0) + parser.add_argument('--duration', type=float, + help='output run time; omit to run until Ctrl-C') + parser.add_argument('--ownership-file', + help='record mappings owned by this service for crash cleanup') + parser.add_argument('--cleanup', action='store_true', + help='recover only mappings recorded for this service') + parser.add_argument('--execute', action='store_true', + help='apply the plan; without this flag only inspect and print') + parser.add_argument('--wait-for-phc', action='store_true', + help='wait for a supported RP1 PHC before starting') + args = parser.parse_args() + signal.signal(signal.SIGTERM, _handle_sigterm) + try: + if args.timeout is not None and args.timeout <= 0: + raise ValueError('timeout must be positive') + if args.event_timeout <= 0: + raise ValueError('event-timeout must be positive') + if args.duration is not None and args.duration <= 0: + raise ValueError('duration must be positive') + if args.mode == 'duplex': + if args.gpio is not None or args.input_gpio is None or args.output_gpio is None: + raise ValueError('duplex mode requires --input-gpio and --output-gpio') + if args.duration is not None: + raise ValueError('duplex mode runs until the event count or a signal stops it') + elif (args.gpio is None or args.input_gpio is not None or + args.input_gpio2 is not None or args.output_gpio is not None): + raise ValueError('input/output mode requires --gpio only') + if args.cleanup and (args.execute or not args.ownership_file): + raise ValueError('--cleanup requires --ownership-file and cannot use --execute') + if args.cleanup and args.mode == 'duplex': + if args.input_gpio is None or args.output_gpio is None or args.gpio is not None: + raise ValueError('duplex cleanup requires --input-gpio and --output-gpio') + expected = [{'gpio': args.input_gpio, 'function': 1, 'channel': 0}, + {'gpio': args.output_gpio, 'function': 2, 'channel': 0}] + if args.input_gpio2 is not None: + expected.append({'gpio': args.input_gpio2, + 'function': 1, 'channel': 1}) + elif args.cleanup: + if args.gpio is None or args.input_gpio is not None or args.output_gpio is not None: + raise ValueError('single-direction cleanup requires --gpio only') + expected = [{'gpio': args.gpio, + 'function': 1 if args.mode == 'input' else 2, + 'channel': args.input_channel if args.mode == 'input' else 0}] + else: + expected = None + pps = RP1PPS(interface=args.interface, testptp=args.testptp, + ownership_file=args.ownership_file) + if args.cleanup: + print(json.dumps(pps.cleanup_owned_mappings(expected), indent=2)) + return + while True: + try: + preflight = pps.preflight() + break + except PHCUnavailable as exc: + if not args.wait_for_phc: + raise + print(f'Waiting for a supported RP1 PHC: {exc}', file=sys.stderr, + flush=True) + time.sleep(5) + if args.mode == 'duplex': + plan = pps.plan_duplex(args.input_gpio, args.output_gpio, + input_channel=args.input_channel, input_gpio2=args.input_gpio2, + edge=args.edge, + events=args.events, period_ns=args.period_ns, + high_ns=args.high_ns, phase_ns=args.phase_ns, + timeout=args.timeout, event_timeout=args.event_timeout) + else: + plan = pps.plan(args.mode, args.gpio, edge=args.edge, + channel=args.input_channel if args.mode == 'input' else 0, + events=args.events, period_ns=args.period_ns, + high_ns=args.high_ns, phase_ns=args.phase_ns, + duration=args.duration, timeout=args.timeout, + event_timeout=args.event_timeout) + result = {'preflight': preflight, 'plan': plan} + if args.execute: + result['result'] = (pps.capture(args.gpio, edge=args.edge, + channel=args.input_channel, + events=args.events, timeout=args.timeout, + event_timeout=args.event_timeout, + event_callback=lambda event: print( + json.dumps({'event': event}), flush=True)) if args.mode == 'input' + else pps.output(args.gpio, period_ns=args.period_ns, + high_ns=args.high_ns, phase_ns=args.phase_ns, + duration=args.duration) if args.mode == 'output' + else pps.duplex(args.input_gpio, args.output_gpio, + input_channel=args.input_channel, edge=args.edge, + input_gpio2=args.input_gpio2, + events=args.events, + period_ns=args.period_ns, high_ns=args.high_ns, + phase_ns=args.phase_ns, timeout=args.timeout, + event_timeout=args.event_timeout, + event_callback=lambda event: print( + json.dumps({'event': event}), flush=True))) + print(json.dumps(result, indent=2)) + except (OSError, RuntimeError, TimeoutError, ValueError) as exc: + parser.exit(1, f'{exc}\n') + except KeyboardInterrupt: + parser.exit(130, 'interrupted\n') + + +def _handle_sigterm(_signum, _frame): + """Turn service stop into an exception so active PPS is cleaned up.""" + raise KeyboardInterrupt + + +if __name__ == '__main__': + main() diff --git a/rp1-ptp-pps/testptp.c b/rp1-ptp-pps/testptp.c new file mode 100644 index 0000000..ed1e288 --- /dev/null +++ b/rp1-ptp-pps/testptp.c @@ -0,0 +1,694 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * PTP 1588 clock support - User space test program + * + * Copyright (C) 2010 OMICRON electronics GmbH + */ +#define _GNU_SOURCE +#define __SANE_USERSPACE_TYPES__ /* For PPC64, to get LL64 types */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#define DEVICE "/dev/ptp0" + +#ifndef ADJ_SETOFFSET +#define ADJ_SETOFFSET 0x0100 +#endif + +#ifndef CLOCK_INVALID +#define CLOCK_INVALID -1 +#endif + +#define NSEC_PER_SEC 1000000000LL + +/* clock_adjtime is not available in GLIBC < 2.14 */ +#if !__GLIBC_PREREQ(2, 14) +#include +static int clock_adjtime(clockid_t id, struct timex *tx) +{ + return syscall(__NR_clock_adjtime, id, tx); +} +#endif + +static void show_flag_test(int rq_index, unsigned int flags, int err) +{ + printf("PTP_EXTTS_REQUEST%c flags 0x%08x : (%d) %s\n", + rq_index ? '1' + rq_index : ' ', + flags, err, strerror(errno)); + /* sigh, uClibc ... */ + errno = 0; +} + +static void do_flag_test(int fd, unsigned int index) +{ + struct ptp_extts_request extts_request; + unsigned long request[2] = { + PTP_EXTTS_REQUEST, + PTP_EXTTS_REQUEST2, + }; + unsigned int enable_flags[5] = { + PTP_ENABLE_FEATURE, + PTP_ENABLE_FEATURE | PTP_RISING_EDGE, + PTP_ENABLE_FEATURE | PTP_FALLING_EDGE, + PTP_ENABLE_FEATURE | PTP_RISING_EDGE | PTP_FALLING_EDGE, + PTP_ENABLE_FEATURE | (PTP_EXTTS_VALID_FLAGS + 1), + }; + int err, i, j; + + memset(&extts_request, 0, sizeof(extts_request)); + extts_request.index = index; + + for (i = 0; i < 2; i++) { + for (j = 0; j < 5; j++) { + extts_request.flags = enable_flags[j]; + err = ioctl(fd, request[i], &extts_request); + show_flag_test(i, extts_request.flags, err); + + extts_request.flags = 0; + err = ioctl(fd, request[i], &extts_request); + } + } +} + +static clockid_t get_clockid(int fd) +{ +#define CLOCKFD 3 + return (((unsigned int) ~fd) << 3) | CLOCKFD; +} + +static long ppb_to_scaled_ppm(int ppb) +{ + /* + * The 'freq' field in the 'struct timex' is in parts per + * million, but with a 16 bit binary fractional field. + * Instead of calculating either one of + * + * scaled_ppm = (ppb / 1000) << 16 [1] + * scaled_ppm = (ppb << 16) / 1000 [2] + * + * we simply use double precision math, in order to avoid the + * truncation in [1] and the possible overflow in [2]. + */ + return (long) (ppb * 65.536); +} + +static int64_t pctns(struct ptp_clock_time *t) +{ + return t->sec * NSEC_PER_SEC + t->nsec; +} + +static void usage(char *progname) +{ + fprintf(stderr, + "usage: %s [options]\n" + " -c query the ptp clock's capabilities\n" + " -d name device to open\n" + " -e val read 'val' external time stamp events\n" + " -E val enable rising (1), falling (2), or both (3) edges\n" + " -f val adjust the ptp clock frequency by 'val' ppb\n" + " -F chan Enable single channel mask and keep device open for debugfs verification.\n" + " -g get the ptp clock time\n" + " -h prints this message\n" + " -i val index for event/trigger\n" + " -k val measure the time offset between system and phc clock\n" + " for 'val' times (Maximum 25)\n" + " -l list the current pin configuration\n" + " -L pin,val configure pin index 'pin' with function 'val'\n" + " the channel index is taken from the '-i' option\n" + " 'val' specifies the auxiliary function:\n" + " 0 - none\n" + " 1 - external time stamp\n" + " 2 - periodic output\n" + " -n val shift the ptp clock time by 'val' nanoseconds\n" + " -o val phase offset (in nanoseconds) to be provided to the PHC servo\n" + " -p val enable output with a period of 'val' nanoseconds\n" + " -H val set output phase to 'val' nanoseconds (requires -p)\n" + " -w val set output pulse width to 'val' nanoseconds (requires -p)\n" + " -P val enable or disable (val=1|0) the system clock PPS\n" + " -r open the ptp clock in readonly mode\n" + " -s set the ptp clock time from the system time\n" + " -S set the system time from the ptp clock time\n" + " -t val shift the ptp clock time by 'val' seconds\n" + " -T val set the ptp clock time to 'val' seconds\n" + " -x val get an extended ptp clock time with the desired number of samples (up to %d)\n" + " -X get a ptp clock cross timestamp\n" + " -y val pre/post tstamp timebase to use {realtime|monotonic|monotonic-raw}\n" + " -z test combinations of rising/falling external time stamp flags\n", + progname, PTP_MAX_SAMPLES); +} + +int main(int argc, char *argv[]) +{ + struct ptp_clock_caps caps; + struct ptp_extts_event event; + struct ptp_extts_request extts_request; + struct ptp_perout_request perout_request; + struct ptp_pin_desc desc; + struct timespec ts; + struct timex tx; + struct ptp_clock_time *pct; + struct ptp_sys_offset *sysoff; + struct ptp_sys_offset_extended *soe; + struct ptp_sys_offset_precise *xts; + + char *progname; + unsigned int i; + int c, cnt, fd; + + char *device = DEVICE; + clockid_t clkid; + int adjfreq = 0x7fffffff; + int adjtime = 0; + int adjns = 0; + int adjphase = 0; + int capabilities = 0; + int extts = 0; + int edge = 0; + int flagtest = 0; + int gettime = 0; + int index = 0; + int list_pins = 0; + int pct_offset = 0; + int getextended = 0; + int getcross = 0; + int n_samples = 0; + int pin_index = -1, pin_func; + int pps = -1; + int seconds = 0; + int readonly = 0; + int settime = 0; + int channel = -1; + clockid_t ext_clockid = CLOCK_REALTIME; + + int64_t t1, t2, tp; + int64_t interval, offset; + int64_t perout_phase = -1; + int64_t pulsewidth = -1; + int64_t perout = -1; + + progname = strrchr(argv[0], '/'); + progname = progname ? 1+progname : argv[0]; + while (EOF != (c = getopt(argc, argv, "cd:e:E:f:F:ghH:i:k:lL:n:o:p:P:rsSt:T:w:x:Xy:z"))) { + switch (c) { + case 'c': + capabilities = 1; + break; + case 'd': + device = optarg; + break; + case 'e': + extts = atoi(optarg); + break; + case 'E': + edge = atoi(optarg); + edge = (edge & 1 ? PTP_RISING_EDGE : 0) | + (edge & 2 ? PTP_FALLING_EDGE : 0); + break; + case 'f': + adjfreq = atoi(optarg); + break; + case 'F': + channel = atoi(optarg); + break; + case 'g': + gettime = 1; + break; + case 'H': + perout_phase = atoll(optarg); + break; + case 'i': + index = atoi(optarg); + break; + case 'k': + pct_offset = 1; + n_samples = atoi(optarg); + break; + case 'l': + list_pins = 1; + break; + case 'L': + cnt = sscanf(optarg, "%d,%d", &pin_index, &pin_func); + if (cnt != 2) { + usage(progname); + return -1; + } + break; + case 'n': + adjns = atoi(optarg); + break; + case 'o': + adjphase = atoi(optarg); + break; + case 'p': + perout = atoll(optarg); + break; + case 'P': + pps = atoi(optarg); + break; + case 'r': + readonly = 1; + break; + case 's': + settime = 1; + break; + case 'S': + settime = 2; + break; + case 't': + adjtime = atoi(optarg); + break; + case 'T': + settime = 3; + seconds = atoi(optarg); + break; + case 'w': + pulsewidth = atoi(optarg); + break; + case 'x': + getextended = atoi(optarg); + if (getextended < 1 || getextended > PTP_MAX_SAMPLES) { + fprintf(stderr, + "number of extended timestamp samples must be between 1 and %d; was asked for %d\n", + PTP_MAX_SAMPLES, getextended); + return -1; + } + break; + case 'X': + getcross = 1; + break; + case 'y': + if (!strcasecmp(optarg, "realtime")) + ext_clockid = CLOCK_REALTIME; + else if (!strcasecmp(optarg, "monotonic")) + ext_clockid = CLOCK_MONOTONIC; + else if (!strcasecmp(optarg, "monotonic-raw")) + ext_clockid = CLOCK_MONOTONIC_RAW; + else { + fprintf(stderr, + "type needs to be realtime, monotonic or monotonic-raw; was given %s\n", + optarg); + return -1; + } + break; + + case 'z': + flagtest = 1; + break; + case 'h': + usage(progname); + return 0; + case '?': + default: + usage(progname); + return -1; + } + } + + fd = open(device, readonly ? O_RDONLY : O_RDWR); + if (fd < 0) { + fprintf(stderr, "opening %s: %s\n", device, strerror(errno)); + return -1; + } + + clkid = get_clockid(fd); + if (CLOCK_INVALID == clkid) { + fprintf(stderr, "failed to read clock id\n"); + return -1; + } + + if (capabilities) { + if (ioctl(fd, PTP_CLOCK_GETCAPS, &caps)) { + perror("PTP_CLOCK_GETCAPS"); + } else { + printf("capabilities:\n" + " %d maximum frequency adjustment (ppb)\n" + " %d programmable alarms\n" + " %d external time stamp channels\n" + " %d programmable periodic signals\n" + " %d pulse per second\n" + " %d programmable pins\n" + " %d cross timestamping\n" + " %d adjust_phase\n" + " %d maximum phase adjustment (ns)\n", + caps.max_adj, + caps.n_alarm, + caps.n_ext_ts, + caps.n_per_out, + caps.pps, + caps.n_pins, + caps.cross_timestamping, + caps.adjust_phase, + caps.max_phase_adj); + } + } + + if (0x7fffffff != adjfreq) { + memset(&tx, 0, sizeof(tx)); + tx.modes = ADJ_FREQUENCY; + tx.freq = ppb_to_scaled_ppm(adjfreq); + if (clock_adjtime(clkid, &tx)) { + perror("clock_adjtime"); + } else { + puts("frequency adjustment okay"); + } + } + + if (adjtime || adjns) { + memset(&tx, 0, sizeof(tx)); + tx.modes = ADJ_SETOFFSET | ADJ_NANO; + tx.time.tv_sec = adjtime; + tx.time.tv_usec = adjns; + while (tx.time.tv_usec < 0) { + tx.time.tv_sec -= 1; + tx.time.tv_usec += NSEC_PER_SEC; + } + + if (clock_adjtime(clkid, &tx) < 0) { + perror("clock_adjtime"); + } else { + puts("time shift okay"); + } + } + + if (adjphase) { + memset(&tx, 0, sizeof(tx)); + tx.modes = ADJ_OFFSET | ADJ_NANO; + tx.offset = adjphase; + + if (clock_adjtime(clkid, &tx) < 0) { + perror("clock_adjtime"); + } else { + puts("phase adjustment okay"); + } + } + + if (gettime) { + if (clock_gettime(clkid, &ts)) { + perror("clock_gettime"); + } else { + printf("clock time: %ld.%09ld or %s", + ts.tv_sec, ts.tv_nsec, ctime(&ts.tv_sec)); + } + } + + if (settime == 1) { + clock_gettime(CLOCK_REALTIME, &ts); + if (clock_settime(clkid, &ts)) { + perror("clock_settime"); + } else { + puts("set time okay"); + } + } + + if (settime == 2) { + clock_gettime(clkid, &ts); + if (clock_settime(CLOCK_REALTIME, &ts)) { + perror("clock_settime"); + } else { + puts("set time okay"); + } + } + + if (settime == 3) { + ts.tv_sec = seconds; + ts.tv_nsec = 0; + if (clock_settime(clkid, &ts)) { + perror("clock_settime"); + } else { + puts("set time okay"); + } + } + + if (pin_index >= 0) { + memset(&desc, 0, sizeof(desc)); + desc.index = pin_index; + desc.func = pin_func; + desc.chan = index; + if (ioctl(fd, PTP_PIN_SETFUNC, &desc)) { + perror("PTP_PIN_SETFUNC"); + } else { + puts("set pin function okay"); + } + } + + if (extts) { + if (!readonly) { + memset(&extts_request, 0, sizeof(extts_request)); + extts_request.index = index; + extts_request.flags = PTP_ENABLE_FEATURE | edge; + if (ioctl(fd, PTP_EXTTS_REQUEST, &extts_request)) { + perror("PTP_EXTTS_REQUEST"); + extts = 0; + } else { + puts("external time stamp request okay"); + } + } + for (; extts; extts--) { + cnt = read(fd, &event, sizeof(event)); + if (cnt != sizeof(event)) { + perror("read"); + break; + } + printf("event index %u at %lld.%09u\n", event.index, + event.t.sec, event.t.nsec); + fflush(stdout); + } + if (!readonly) { + /* Disable the feature again. */ + extts_request.flags = 0; + if (ioctl(fd, PTP_EXTTS_REQUEST, &extts_request)) { + perror("PTP_EXTTS_REQUEST"); + } + } + } + + if (flagtest) { + do_flag_test(fd, index); + } + + if (list_pins) { + int n_pins = 0; + if (ioctl(fd, PTP_CLOCK_GETCAPS, &caps)) { + perror("PTP_CLOCK_GETCAPS"); + } else { + n_pins = caps.n_pins; + } + for (i = 0; i < n_pins; i++) { + desc.index = i; + if (ioctl(fd, PTP_PIN_GETFUNC, &desc)) { + perror("PTP_PIN_GETFUNC"); + break; + } + printf("name %s index %u func %u chan %u\n", + desc.name, desc.index, desc.func, desc.chan); + } + } + + if (pulsewidth >= 0 && perout < 0) { + puts("-w can only be specified together with -p"); + return -1; + } + + if (perout_phase >= 0 && perout < 0) { + puts("-H can only be specified together with -p"); + return -1; + } + + if (perout >= 0) { + if (clock_gettime(clkid, &ts)) { + perror("clock_gettime"); + return -1; + } + memset(&perout_request, 0, sizeof(perout_request)); + perout_request.index = index; + perout_request.period.sec = perout / NSEC_PER_SEC; + perout_request.period.nsec = perout % NSEC_PER_SEC; + perout_request.flags = 0; + if (pulsewidth >= 0) { + perout_request.flags |= PTP_PEROUT_DUTY_CYCLE; + perout_request.on.sec = pulsewidth / NSEC_PER_SEC; + perout_request.on.nsec = pulsewidth % NSEC_PER_SEC; + } + if (perout_phase >= 0) { + perout_request.flags |= PTP_PEROUT_PHASE; + perout_request.phase.sec = perout_phase / NSEC_PER_SEC; + perout_request.phase.nsec = perout_phase % NSEC_PER_SEC; + } else { + perout_request.start.sec = ts.tv_sec + 2; + perout_request.start.nsec = 0; + } + + if (ioctl(fd, PTP_PEROUT_REQUEST2, &perout_request)) { + perror("PTP_PEROUT_REQUEST"); + } else { + puts("periodic output request okay"); + } + } + + if (pps != -1) { + int enable = pps ? 1 : 0; + if (ioctl(fd, PTP_ENABLE_PPS, enable)) { + perror("PTP_ENABLE_PPS"); + } else { + puts("pps for system time request okay"); + } + } + + if (pct_offset) { + if (n_samples <= 0 || n_samples > 25) { + puts("n_samples should be between 1 and 25"); + usage(progname); + return -1; + } + + sysoff = calloc(1, sizeof(*sysoff)); + if (!sysoff) { + perror("calloc"); + return -1; + } + sysoff->n_samples = n_samples; + + if (ioctl(fd, PTP_SYS_OFFSET, sysoff)) + perror("PTP_SYS_OFFSET"); + else + puts("system and phc clock time offset request okay"); + + pct = &sysoff->ts[0]; + for (i = 0; i < sysoff->n_samples; i++) { + t1 = pctns(pct+2*i); + tp = pctns(pct+2*i+1); + t2 = pctns(pct+2*i+2); + interval = t2 - t1; + offset = (t2 + t1) / 2 - tp; + + printf("system time: %lld.%09u\n", + (pct+2*i)->sec, (pct+2*i)->nsec); + printf("phc time: %lld.%09u\n", + (pct+2*i+1)->sec, (pct+2*i+1)->nsec); + printf("system time: %lld.%09u\n", + (pct+2*i+2)->sec, (pct+2*i+2)->nsec); + printf("system/phc clock time offset is %" PRId64 " ns\n" + "system clock time delay is %" PRId64 " ns\n", + offset, interval); + } + + free(sysoff); + } + + if (getextended) { + soe = calloc(1, sizeof(*soe)); + if (!soe) { + perror("calloc"); + return -1; + } + + soe->n_samples = getextended; + soe->clockid = ext_clockid; + + if (ioctl(fd, PTP_SYS_OFFSET_EXTENDED, soe)) { + perror("PTP_SYS_OFFSET_EXTENDED"); + } else { + printf("extended timestamp request returned %d samples\n", + getextended); + + for (i = 0; i < getextended; i++) { + switch (ext_clockid) { + case CLOCK_REALTIME: + printf("sample #%2d: real time before: %lld.%09u\n", + i, soe->ts[i][0].sec, + soe->ts[i][0].nsec); + break; + case CLOCK_MONOTONIC: + printf("sample #%2d: monotonic time before: %lld.%09u\n", + i, soe->ts[i][0].sec, + soe->ts[i][0].nsec); + break; + case CLOCK_MONOTONIC_RAW: + printf("sample #%2d: monotonic-raw time before: %lld.%09u\n", + i, soe->ts[i][0].sec, + soe->ts[i][0].nsec); + break; + default: + break; + } + printf(" phc time: %lld.%09u\n", + soe->ts[i][1].sec, soe->ts[i][1].nsec); + switch (ext_clockid) { + case CLOCK_REALTIME: + printf(" real time after: %lld.%09u\n", + soe->ts[i][2].sec, + soe->ts[i][2].nsec); + break; + case CLOCK_MONOTONIC: + printf(" monotonic time after: %lld.%09u\n", + soe->ts[i][2].sec, + soe->ts[i][2].nsec); + break; + case CLOCK_MONOTONIC_RAW: + printf(" monotonic-raw time after: %lld.%09u\n", + soe->ts[i][2].sec, + soe->ts[i][2].nsec); + break; + default: + break; + } + } + } + + free(soe); + } + + if (getcross) { + xts = calloc(1, sizeof(*xts)); + if (!xts) { + perror("calloc"); + return -1; + } + + if (ioctl(fd, PTP_SYS_OFFSET_PRECISE, xts)) { + perror("PTP_SYS_OFFSET_PRECISE"); + } else { + puts("system and phc crosstimestamping request okay"); + + printf("device time: %lld.%09u\n", + xts->device.sec, xts->device.nsec); + printf("system time: %lld.%09u\n", + xts->sys_realtime.sec, xts->sys_realtime.nsec); + printf("monoraw time: %lld.%09u\n", + xts->sys_monoraw.sec, xts->sys_monoraw.nsec); + } + + free(xts); + } + + if (channel >= 0) { + if (ioctl(fd, PTP_MASK_CLEAR_ALL)) { + perror("PTP_MASK_CLEAR_ALL"); + } else if (ioctl(fd, PTP_MASK_EN_SINGLE, (unsigned int *)&channel)) { + perror("PTP_MASK_EN_SINGLE"); + } else { + printf("Channel %d exclusively enabled. Check on debugfs.\n", channel); + printf("Press any key to continue\n."); + getchar(); + } + } + + close(fd); + return 0; +} diff --git a/rp1-ptp-pps/tests/test_rp1_pps_runtime.py b/rp1-ptp-pps/tests/test_rp1_pps_runtime.py new file mode 100644 index 0000000..3e4c812 --- /dev/null +++ b/rp1-ptp-pps/tests/test_rp1_pps_runtime.py @@ -0,0 +1,677 @@ +#!/usr/bin/env python3 +"""Offline tests for guarded testptp GPIO control.""" +import importlib.util +import io +import tempfile +import threading +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +SPEC = importlib.util.spec_from_file_location('rp1_pps', ROOT / 'rp1_pps.py') +rp1 = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(rp1) + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.now += seconds + + +class FakePopen: + def __init__(self, argv, output='', *, timeout=False, returncode=0): + self.argv = argv + self.output = output + self.timeout = timeout + self.returncode = returncode + self.terminated = False + self.killed = False + + def communicate(self, timeout=None): + if self.timeout and not self.terminated: + raise subprocess.TimeoutExpired(self.argv, timeout) + return self.output, '' + + def terminate(self): + self.terminated = True + self.returncode = -15 + + def kill(self): + self.killed = True + self.terminated = True + self.returncode = -9 + + def wait(self, timeout=None): + return self.returncode + + +class FakeStream: + def __init__(self, events, channel=0): + self.events = events + self.channel = channel + self.stopped = threading.Event() + + def __iter__(self): + yield 'external time stamp request okay\n' + for index in range(self.events): + yield f'event index {self.channel} at {100 + index}.000000123\n' + self.stopped.wait() + + def close(self): + self.stopped.set() + + +class FakeStreamPopen: + def __init__(self, argv, events): + self.argv = argv + self.stdout = FakeStream(events, int(argv[4])) + self.returncode = None + + def poll(self): + return self.returncode + + def terminate(self): + self.returncode = -15 + self.stdout.stopped.set() + + def kill(self): + self.returncode = -9 + self.stdout.stopped.set() + + def wait(self, timeout=None): + return self.returncode + + +class FakeFiniteStreamPopen: + def __init__(self, argv, output): + self.argv = argv + self.stdout = io.StringIO(output) + self.returncode = 0 + + def poll(self): + return self.returncode + + def wait(self, timeout=None): + return self.returncode + + def terminate(self): + self.returncode = -15 + + def kill(self): + self.returncode = -9 + + +class RP1PPSTests(unittest.TestCase): + def test_duplex_unit_runs_testptp_with_two_input_channels_and_output(self): + unit = (ROOT / 'rp1-pps-duplex.service.in').read_text() + self.assertIn('--mode duplex', unit) + self.assertIn('--input-gpio ${PPS_INPUT_GPIO}', unit) + self.assertIn('--input-gpio2 ${PPS_INPUT_GPIO2}', unit) + self.assertIn('--output-gpio ${PPS_OUTPUT_GPIO}', unit) + self.assertIn('--ownership-file /var/lib/rp1-ptp-pps/owned.json --cleanup', unit) + self.assertIn('--execute --wait-for-phc', unit) + self.assertIn('@CMAKE_INSTALL_FULL_LIBEXECDIR@/rp1-ptp-pps/rp1_pps.py', unit) + self.assertIn('Conflicts=rp1-pps.service', unit) + + def setUp(self): + self.calls = [] + self.pin_functions = {gpio: 0 for gpio in range(28)} + self.pin_channels = {gpio: 0 for gpio in range(28)} + self.fail_output_stop = False + self.capture_error = None + self.capture_timeout = False + self.fail_extts_disable = False + self.disable_calls = [] + self.lose_mapping_gpio = None + self.interrupt_on_sleep = False + self.clock = FakeClock() + self.tempdir = tempfile.TemporaryDirectory() + self.ownership_file = Path(self.tempdir.name) / 'owned.json' + + def sleep(seconds): + self.clock.sleep(seconds) + if self.lose_mapping_gpio is not None: + self.pin_functions[self.lose_mapping_gpio] = 0 + self.lose_mapping_gpio = None + if self.interrupt_on_sleep: + self.interrupt_on_sleep = False + raise KeyboardInterrupt + + def inventory(_interface): + return {'driver': 'macb', 'clocks': [{ + 'name': 'gem-ptp-timer', 'device': '/dev/ptp7', + 'parent': '/sys/devices/platform/rp1/net/eth0', + 'device_number': [248, 7], 'rp1_mac': True, + 'capabilities': {'extts': 2, 'perout': 1, 'pins': 28}}]} + + def disable_extts(channel): + self.disable_calls.append(channel) + self.calls.append(['ioctl-disable', str(channel)]) + if self.fail_extts_disable: + raise OSError('injected PTP_EXTTS_REQUEST disable failure') + + def popen(argv, **_kwargs): + self.calls.append(list(argv)) + options = argv[5:] + if options == ['-c']: + output = ('capabilities:\n 2 external time stamp channels\n' + ' 1 programmable periodic signals\n 28 programmable pins\n') + elif options == ['-l']: + output = ''.join(f'name GPIO{gpio} index {gpio} func ' + f'{self.pin_functions[gpio]} chan {self.pin_channels[gpio]}\n' + for gpio in range(28)) + elif options and options[0] == '-L': + gpio, function = map(int, options[1].split(',')) + channel = int(argv[4]) + if function: + self.pin_functions[gpio] = function + self.pin_channels[gpio] = channel + else: + self.pin_functions[gpio] = 0 + self.pin_channels[gpio] = 0 + output = 'set pin function okay\n' + elif options and options[0] == '-E': + output = self.capture_error or 'external time stamp request okay\n' + if not self.capture_error: + count = int(options[options.index('-e') + 1]) + output += ''.join(f'event index {argv[4]} at {1+i}.000000123\n' + for i in range(count)) + elif options and options[0] == '-p': + if options[1] == '0' and self.fail_output_stop: + output = 'PTP_PEROUT_REQUEST: Connection timed out\n' + else: + output = 'periodic output request okay\n' + else: + raise AssertionError(f'Unexpected testptp options: {options!r}') + return FakePopen(argv, output, timeout=(options and options[0] == '-E' + and self.capture_timeout)) + + self.tool = rp1.RP1PPS(interface='eth0', testptp='/opt/testptp', + inventory_fn=inventory, monotonic=self.clock.monotonic, + sleep=sleep, popen_factory=popen, + extts_disable_fn=disable_extts) + + def tearDown(self): + self.tempdir.cleanup() + + def test_standard_extts_disable_ioctl_clears_the_selected_channel(self): + tool = rp1.RP1PPS() + tool.clock = {'device': '/dev/ptp-test'} + with patch.object(rp1.os, 'open', return_value=17) as open_device, \ + patch.object(rp1.os, 'close') as close_device, \ + patch.object(rp1.fcntl, 'ioctl') as ioctl: + tool._disable_extts_request(1) + open_device.assert_called_once_with('/dev/ptp-test', + rp1.os.O_RDWR | rp1.os.O_CLOEXEC) + ioctl.assert_called_once() + fd, request_number, payload, mutate = ioctl.call_args.args + self.assertEqual(fd, 17) + self.assertEqual(request_number, rp1.PTP_EXTTS_REQUEST_IOCTL) + self.assertEqual(bytes(payload), b'\x01\x00\x00\x00' + b'\x00' * 12) + self.assertTrue(mutate) + close_device.assert_called_once_with(17) + + def test_missing_phc_is_retryable_but_occupied_pin_map_is_not(self): + unavailable = rp1.RP1PPS(inventory_fn=lambda _interface: { + 'driver': 'macb', 'clocks': []}) + with self.assertRaises(rp1.PHCUnavailable): + unavailable.preflight() + + self.pin_functions[7] = 1 + with self.assertRaisesRegex(RuntimeError, 'Existing PTP pin mappings'): + self.tool.preflight() + + def test_owned_duplex_cleanup_disables_both_channels_and_perout(self): + self.tool.preflight() + self.tool.ownership_file = self.ownership_file + self.tool.boot_id_fn = lambda: 'test-boot' + rows = [{'gpio': 18, 'function': 1, 'channel': 0}, + {'gpio': 23, 'function': 2, 'channel': 0}, + {'gpio': 24, 'function': 1, 'channel': 1}] + for row in rows: + self.pin_functions[row['gpio']] = row['function'] + self.pin_channels[row['gpio']] = row['channel'] + self.tool._track_mapping(**row) + + result = self.tool.cleanup_owned_mappings(rows) + + self.assertTrue(result['cleaned']) + self.assertEqual(self.disable_calls, [0, 1]) + self.assertIn(['-p', '0'], [call[5:7] for call in self.calls]) + self.assertEqual([self.pin_functions[gpio] for gpio in (18, 23, 24)], + [0, 0, 0]) + self.assertFalse(self.ownership_file.exists()) + + def test_owned_cleanup_refuses_to_clear_a_different_configuration(self): + self.tool.preflight() + self.tool.ownership_file = self.ownership_file + self.tool.boot_id_fn = lambda: 'test-boot' + self.pin_functions[18] = 1 + self.tool._track_mapping(18, 1, 0) + command_count = len(self.calls) + + result = self.tool.cleanup_owned_mappings([ + {'gpio': 19, 'function': 1, 'channel': 0}]) + + self.assertFalse(result['cleaned']) + self.assertEqual(result['reason'], 'ownership record mismatch') + self.assertEqual(len(self.calls), command_count) + self.assertEqual(self.pin_functions[18], 1) + self.assertTrue(self.ownership_file.exists()) + + def test_capture_uses_selected_gpio_and_unmaps_after_success(self): + self.tool.preflight() + result = self.tool.capture(12, edge='falling', events=3) + self.assertTrue(result['passed']) + self.assertEqual([r['timestamp_ns'] for r in result['events']], [ + 1_000_000_123, 2_000_000_123, 3_000_000_123]) + self.assertIn(['-L', '12,1'], [call[5:7] for call in self.calls]) + self.assertIn(['-E', '2', '-e', '3'], [call[5:9] for call in self.calls]) + self.assertEqual(self.pin_functions[12], 0) + + def test_input_only_mode_rejects_channel_one_without_perout(self): + self.tool.preflight() + before = len(self.calls) + with self.assertRaisesRegex(ValueError, 'requires active PEROUT'): + self.tool.capture(13, channel=1, events=2) + self.assertEqual(len(self.calls), before) + + def test_capture_error_cleans_selected_mapping(self): + self.tool.preflight() + self.capture_error = 'external time stamp request okay\nread: Input/output error\n' + with self.assertRaisesRegex(RuntimeError, 'ioctl/read error'): + self.tool.capture(18) + self.assertEqual(self.pin_functions[18], 0) + self.assertEqual(self.calls[-1][5:7], ['-L', '18,0']) + + def test_capture_timeout_terminates_testptp_then_unmaps(self): + self.tool.preflight() + self.capture_timeout = True + with self.assertRaises(TimeoutError): + self.tool.capture(23, timeout=1) + self.assertEqual(self.pin_functions[23], 0) + self.assertEqual(self.calls[-1][5:7], ['-L', '23,0']) + + def test_continuous_input_streams_events_and_stops_cleanly(self): + self.tool.preflight() + children = [] + + def popen(argv, **kwargs): + self.calls.append(list(argv)) + if argv[-2:] == ['-e', '2147483647']: + child = FakeStreamPopen(argv, events=2) + children.append(child) + return child + return self.tool_popen(argv, **kwargs) + + self.tool_popen = self.tool.popen_factory + self.tool.popen_factory = popen + observed = [] + + def stop_after_two(event): + observed.append(event) + if len(observed) == 2: + raise KeyboardInterrupt + + result = self.tool.capture(10, events=0, event_timeout=1, + event_callback=stop_after_two) + self.assertEqual(result['events_received'], 2) + self.assertEqual(result['stopped_by'], 'signal') + self.assertEqual([row['timestamp_ns'] for row in observed], [ + 100_000_000_123, 101_000_000_123]) + self.assertEqual(children[0].returncode, -15) + self.assertEqual(self.pin_functions[10], 0) + self.assertIn(['-E', '1', '-e', '2147483647'], + [call[5:] for call in self.calls]) + + def test_continuous_input_rearms_after_inactivity_without_unmapping(self): + self.tool.preflight() + self.interrupt_on_sleep = True + children = [] + + def popen(argv, **kwargs): + self.calls.append(list(argv)) + if argv[-2:] == ['-e', '2147483647']: + child = FakeStreamPopen(argv, events=0) + children.append(child) + return child + return self.tool_popen(argv, **kwargs) + + self.tool_popen = self.tool.popen_factory + self.tool.popen_factory = popen + result = self.tool.capture(11, events=0, event_timeout=0.05) + self.assertEqual(result['stopped_by'], 'signal') + self.assertEqual(len(children), 1) + self.assertEqual(children[0].returncode, -15) + self.assertEqual(self.disable_calls, [0]) + self.assertEqual(self.pin_functions[11], 0) + self.assertEqual(self.calls[-1][5:7], ['-L', '11,0']) + + def test_continuous_input_recovers_and_delivers_events_after_rearm(self): + self.tool.preflight() + children = [] + + def popen(argv, **kwargs): + self.calls.append(list(argv)) + if argv[-2:] == ['-e', '2147483647']: + child = FakeStreamPopen(argv, events=0 if not children else 2) + children.append(child) + return child + return self.tool_popen(argv, **kwargs) + + self.tool_popen = self.tool.popen_factory + self.tool.popen_factory = popen + observed = [] + + def stop_after_recovery(event): + observed.append(event) + if len(observed) == 2: + raise KeyboardInterrupt + + result = self.tool.capture(11, events=0, event_timeout=0.05, + event_callback=stop_after_recovery) + self.assertEqual(result['events_received'], 2) + self.assertEqual(len(children), 2) + self.assertEqual(self.disable_calls, [0, 0]) + self.assertEqual(self.pin_functions[11], 0) + self.assertEqual([row['timestamp_ns'] for row in observed], [ + 100_000_000_123, 101_000_000_123]) + + def test_continuous_input_disable_failure_preserves_gpio_mapping(self): + self.tool.preflight() + self.fail_extts_disable = True + + def popen(argv, **kwargs): + self.calls.append(list(argv)) + if argv[-2:] == ['-e', '2147483647']: + return FakeStreamPopen(argv, events=0) + return self.tool_popen(argv, **kwargs) + + self.tool_popen = self.tool.popen_factory + self.tool.popen_factory = popen + with self.assertRaisesRegex(RuntimeError, 'disable failed'): + self.tool.capture(11, events=0, event_timeout=0.05) + self.assertEqual(self.pin_functions[11], 1) + self.assertEqual(self.disable_calls, [0]) + + def test_output_runs_then_disables_before_unmapping(self): + self.tool.preflight() + result = self.tool.output(16, period_ns=2_000_000_000, + high_ns=20_000_000, phase_ns=10, + duration=0.5) + self.assertTrue(result['passed']) + options = [call[5:] for call in self.calls] + self.assertIn(['-L', '16,2'], options) + self.assertIn(['-p', '2000000000', '-H', '10', '-w', '20000000'], options) + self.assertLess(options.index(['-p', '0']), options.index(['-L', '16,0'])) + self.assertEqual(self.pin_functions[16], 0) + + def test_output_stop_failure_retains_pin_mapping(self): + self.tool.preflight() + self.fail_output_stop = True + with self.assertRaisesRegex(RuntimeError, 'PEROUT disable failed'): + self.tool.output(6, duration=0.1) + self.assertEqual(self.pin_functions[6], 2) + self.assertNotIn(['-L', '6,0'], [call[5:7] for call in self.calls]) + + def test_output_aborts_and_cleans_up_if_mapping_disappears(self): + self.tool.preflight() + self.lose_mapping_gpio = 6 + with self.assertRaisesRegex(RuntimeError, 'lost its PEROUT mapping'): + self.tool.output(6, duration=5) + options = [call[5:] for call in self.calls] + self.assertIn(['-l'], options) + self.assertLess(options.index(['-p', '0']), options.index(['-L', '6,0'])) + self.assertEqual(self.pin_functions[6], 0) + + def duplex_stream_factory(self, output): + old_factory = self.tool.popen_factory + + def popen(argv, **kwargs): + if argv[5:7] == ['-E', '1'] or argv[5:7] == ['-E', '2']: + self.calls.append(list(argv)) + channel = argv[4] + indexed_output = output.replace('event index 0 at', + f'event index {channel} at') + return FakeFiniteStreamPopen(argv, indexed_output) + return old_factory(argv, **kwargs) + + self.tool.popen_factory = popen + + def test_interrupt_stops_output_and_runs_cleanup(self): + self.tool.preflight() + self.interrupt_on_sleep = True + result = self.tool.output(6, duration=10) + self.assertEqual(result['stopped_by'], 'interrupt') + options = [call[5:] for call in self.calls] + self.assertLess(options.index(['-p', '0']), options.index(['-L', '6,0'])) + self.assertEqual(self.pin_functions[6], 0) + + def test_sigterm_handler_requests_graceful_unwind(self): + with self.assertRaises(KeyboardInterrupt): + rp1._handle_sigterm(15, None) + + def test_preflight_refuses_preexisting_mapping_before_any_write(self): + self.pin_functions[7] = 1 + with self.assertRaisesRegex(RuntimeError, 'Existing PTP pin mappings'): + self.tool.preflight() + self.assertEqual(len(self.calls), 2) + self.assertEqual(self.pin_functions[7], 1) + + def test_plan_validates_gpio_and_timing_without_actuation(self): + self.tool.preflight() + with self.assertRaises(ValueError): + self.tool.plan('input', 28) + with self.assertRaisesRegex(ValueError, 'requires active PEROUT'): + self.tool.plan('input', 18, channel=1) + with self.assertRaises(ValueError): + self.tool.plan('output', 18, period_ns=100, high_ns=100) + plan = self.tool.plan('output', 23) + self.assertIn('-L 23,2', plan['start']) + self.assertIn('-p 0', plan['stop']) + + def test_plan_uses_testptp_long_count_for_continuous_input(self): + self.tool.preflight() + plan = self.tool.plan('input', 18, events=0) + self.assertTrue(plan['continuous']) + self.assertIn('-e 2147483647', plan['capture']) + with self.assertRaisesRegex(ValueError, 'events must be zero'): + self.tool.plan('input', 18, events=-1) + + def test_duplex_plan_supports_continuous_two_channel_capture(self): + self.tool.preflight() + plan = self.tool.plan_duplex(18, 23, input_gpio2=19, events=0) + self.assertTrue(plan['continuous']) + self.assertIn('-e 2147483647', plan['capture_input']) + self.assertIn('-e 2147483647', plan['capture_input2']) + self.assertIsNone(rp1._optional_gpio('none')) + self.assertEqual(rp1._optional_gpio('24'), 24) + + def test_duplex_starts_perout_then_captures_extts_and_cleans_in_order(self): + self.tool.preflight() + self.duplex_stream_factory( + 'external time stamp request okay\n' + 'event index 0 at 100.000000123\n' + 'event index 0 at 101.000000456\n') + result = self.tool.duplex(18, 23, events=2) + self.assertTrue(result['passed']) + self.assertEqual([event['timestamp_ns'] for event in result['events']], [ + 100_000_000_123, 101_000_000_456]) + options = [call[5:] for call in self.calls] + self.assertLess(options.index(['-L', '23,2']), + options.index(['-L', '18,1'])) + self.assertLess(options.index(['-L', '18,1']), + options.index(['-p', '1000000000', '-H', '0', '-w', '10000000'])) + self.assertLess(options.index(['-p', '1000000000', '-H', '0', '-w', '10000000']), + options.index(['-E', '1', '-e', '2'])) + self.assertLess(options.index(['-L', '18,0']), options.index(['-p', '0'])) + self.assertLess(options.index(['-L', '18,0']), options.index(['-L', '23,0'])) + self.assertEqual(self.pin_functions[18], 0) + self.assertEqual(self.pin_functions[23], 0) + + def test_duplex_channel_one_maps_and_checks_indexed_events(self): + self.tool.preflight() + self.duplex_stream_factory( + 'external time stamp request okay\n' + 'event index 1 at 100.000000123\n') + result = self.tool.duplex(18, 23, input_channel=1, events=1) + self.assertTrue(result['passed']) + self.assertEqual(result['input_channel'], 1) + self.assertEqual(result['events'][0]['channel'], 1) + input_map = next(call for call in self.calls if call[5:7] == ['-L', '18,1']) + self.assertEqual(input_map[4], '1') + self.assertEqual(self.pin_functions[18], 0) + + def test_duplex_captures_two_independent_input_channels(self): + self.tool.preflight() + self.duplex_stream_factory( + 'external time stamp request okay\n' + 'event index 0 at 100.000000123\n') + result = self.tool.duplex(18, 23, input_gpio2=19, events=1) + self.assertTrue(result['passed']) + self.assertEqual(set(result['events_by_channel']), {0, 1}) + self.assertEqual(result['events_by_channel'][0][0]['channel'], 0) + self.assertEqual(result['events_by_channel'][1][0]['channel'], 1) + self.assertEqual({self.pin_functions[18], self.pin_functions[19], + self.pin_functions[23]}, {0}) + mapped = [call for call in self.calls + if len(call) > 6 and call[5] == '-L' and call[6].endswith(',1')] + self.assertEqual({(call[6].split(',')[0], call[4]) for call in mapped}, + {('18', '0'), ('19', '1')}) + + def test_continuous_duplex_supervises_two_readers_and_cleans_in_order(self): + self.tool.preflight() + children = [] + old_factory = self.tool.popen_factory + + def popen(argv, **kwargs): + if argv[5:7] in (['-E', '1'], ['-E', '2']): + child = FakeStreamPopen(argv, events=1) + children.append(child) + return child + return old_factory(argv, **kwargs) + + self.tool.popen_factory = popen + observed = [] + + def stop_after_both(event): + observed.append(event) + if len(observed) == 2: + raise KeyboardInterrupt + + result = self.tool.duplex(18, 23, input_gpio2=19, events=0, + event_timeout=1, + event_callback=stop_after_both) + self.assertTrue(result['continuous']) + self.assertEqual(result['stopped_by'], 'signal') + self.assertEqual({row['channel'] for row in observed}, {0, 1}) + self.assertEqual(self.disable_calls, [0, 1]) + self.assertEqual(len(children), 2) + self.assertTrue(all(child.returncode == -15 for child in children)) + self.assertEqual({self.pin_functions[18], self.pin_functions[19], + self.pin_functions[23]}, {0}) + last_disable = max(i for i, call in enumerate(self.calls) + if call and call[0] == 'ioctl-disable') + unmap_input = next(i for i, call in enumerate(self.calls) + if call[5:7] == ['-L', '19,0']) + self.assertLess(last_disable, unmap_input) + options = [call[5:] for call in self.calls] + self.assertLess(options.index(['-L', '19,0']), options.index(['-p', '0'])) + + def test_continuous_duplex_rearms_both_inputs_without_stopping_perout(self): + self.tool.preflight() + children = [] + old_factory = self.tool.popen_factory + + def popen(argv, **kwargs): + if argv[5:7] in (['-E', '1'], ['-E', '2']): + round_events = 0 if len(children) < 2 else 1 + child = FakeStreamPopen(argv, events=round_events) + children.append(child) + return child + return old_factory(argv, **kwargs) + + self.tool.popen_factory = popen + observed = [] + + def stop_after_recovery(event): + observed.append(event) + if len(observed) == 2: + raise KeyboardInterrupt + + result = self.tool.duplex(18, 23, input_gpio2=19, events=0, + event_timeout=0.05, event_callback=stop_after_recovery) + self.assertEqual(result['stopped_by'], 'signal') + self.assertEqual(len(children), 4) + self.assertEqual({row['channel'] for row in observed}, {0, 1}) + self.assertEqual(self.disable_calls, [0, 1, 0, 1]) + self.assertEqual(sum(call[5:7] == ['-p', '0'] for call in self.calls), 1) + self.assertEqual({self.pin_functions[18], self.pin_functions[19], + self.pin_functions[23]}, {0}) + + def test_continuous_duplex_disable_failure_preserves_all_pin_mappings(self): + self.tool.preflight() + self.fail_extts_disable = True + old_factory = self.tool.popen_factory + + def popen(argv, **kwargs): + if argv[5:7] in (['-E', '1'], ['-E', '2']): + return FakeStreamPopen(argv, events=0) + return old_factory(argv, **kwargs) + + self.tool.popen_factory = popen + with self.assertRaisesRegex(RuntimeError, 'retaining all GPIO mappings'): + self.tool.duplex(18, 23, input_gpio2=19, events=0, + event_timeout=0.05) + self.assertEqual(self.pin_functions[18], 1) + self.assertEqual(self.pin_functions[19], 1) + self.assertEqual(self.pin_functions[23], 2) + self.assertNotIn(['-p', '0'], [call[5:] for call in self.calls]) + + def test_duplex_rejects_shared_gpio_before_mapping(self): + self.tool.preflight() + before = len(self.calls) + with self.assertRaisesRegex(ValueError, 'different GPIO'): + self.tool.plan_duplex(18, 18) + with self.assertRaisesRegex(ValueError, 'different GPIO'): + self.tool.plan_duplex(18, 23, input_gpio2=18) + with self.assertRaisesRegex(ValueError, 'rising edges only'): + self.tool.plan_duplex(18, 23, edge='falling') + self.assertEqual(len(self.calls), before) + + def test_duplex_extts_rejection_stops_output_and_unmaps_both_pins(self): + self.tool.preflight() + self.duplex_stream_factory('PTP_EXTTS_REQUEST: Device or resource busy\n') + with self.assertRaisesRegex(RuntimeError, 'input error'): + self.tool.duplex(18, 23, events=2) + options = [call[5:] for call in self.calls] + self.assertLess(options.index(['-L', '18,0']), options.index(['-p', '0'])) + self.assertLess(options.index(['-L', '18,0']), options.index(['-L', '23,0'])) + self.assertEqual(self.pin_functions[18], 0) + self.assertEqual(self.pin_functions[23], 0) + + def test_duplex_retains_output_mapping_if_perout_stop_fails(self): + self.tool.preflight() + self.duplex_stream_factory( + 'external time stamp request okay\n' + 'event index 0 at 100.000000123\n') + self.fail_output_stop = True + with self.assertRaisesRegex(RuntimeError, 'PEROUT disable failed'): + self.tool.duplex(18, 23, events=1) + self.assertEqual(self.pin_functions[18], 0) + self.assertEqual(self.pin_functions[23], 2) + + +if __name__ == '__main__': + unittest.main()