Basic Usage

A collection of examples showing the basics of how to use tm_devices in a project.

List available VISA devices

This will print the available VISA devices to the console when run from a shell terminal.

$ list-visa-resources
[
  "TCPIP0::192.168.0.1::inst0::INSTR",
  "ASRL4::INSTR"
]

Adding devices

Configure device connections as needed using the config file, an environment variable, or via Python code (shown here). See the Configuration guide for more information on how to configure devices to connect with.

"""An example of adding devices via Python code."""

from tm_devices import DeviceManager
from tm_devices.drivers import AWG5K, MSO5, MSO6B, PSU2200, SMU2470, TMT4
from tm_devices.helpers import (
    DMConfigOptions,
    PYVISA_PY_BACKEND,
    SerialConfig,
    SYSTEM_DEFAULT_VISA_BACKEND,
)

# Specific config options can optionally be passed in when creating
# the DeviceManager via a dataclass, they are used to update any existing
# configuration options from a config file.
CONFIG_OPTIONS = DMConfigOptions(
    setup_cleanup=True,  # update the value for this option, all other options will remain untouched
)


# Create the DeviceManager, turning on verbosity and passing in some specific configuration values.
with DeviceManager(
    verbose=True,  # optional argument
    config_options=CONFIG_OPTIONS,  # optional argument
) as device_manager:
    # Explicitly specify to use the system VISA backend, this is the default,
    # **this code is not required** to use the system default.
    device_manager.visa_library = SYSTEM_DEFAULT_VISA_BACKEND

    # Enable resetting the devices when connecting and closing
    device_manager.setup_cleanup_enabled = True
    device_manager.teardown_cleanup_enabled = True

    # Note: USB and GPIB connections are not supported with PyVISA-py backend
    psu: PSU2200 = device_manager.add_psu("MODEL-SERIAL", connection_type="USB")

    # Use the PyVISA-py backend
    device_manager.visa_library = PYVISA_PY_BACKEND

    # Add a device using a hostname
    scope: MSO5 = device_manager.add_scope("MSO56-100083")
    print(scope)

    # Add a device using an IP address, specific LAN device endpoint, and optional alias
    awg: AWG5K = device_manager.add_awg("192.168.0.1", lan_device_endpoint="inst0", alias="AWG5k")
    print(awg)

    # Add a device using a VISA resource address string,
    # it auto-detects the connection type is TCPIP.
    scope_2: MSO6B = device_manager.add_scope("TCPIP0::192.168.0.3::inst0::INSTR")

    # Add a device using a VISA resource address string involving a socket connection
    mf_1 = device_manager.add_mf("TCPIP0::192.168.0.4::4000::SOCKET")
    print(mf_1)

    # Add a device using an IP address and optional alias and socket port
    mt: TMT4 = device_manager.add_mt("192.168.0.2", "TMT4", alias="margin tester", port=5000)

    # Add a device using a serial connection, define a SerialConfig for serial settings
    serial_settings = SerialConfig(
        baud_rate=9600,
        data_bits=8,
        flow_control=SerialConfig.FlowControl.xon_xoff,
        parity=SerialConfig.Parity.none,
        stop_bits=SerialConfig.StopBits.one,
        end_input=SerialConfig.Termination.none,
    )
    smu: SMU2470 = device_manager.add_smu(
        "1", connection_type="SERIAL", serial_config=serial_settings
    )

    # Remove devices
    device_manager.remove_all_devices()

Access a module in a mainframe

The DeviceManager can be used to connect to a Mainframe, and then its modules’ commands can be accessed via the Mainframe driver object.

"""Add a mainframe to the Device Manager and access a PSU module through the mainframe."""

from typing import cast, TYPE_CHECKING

from tm_devices import DeviceManager
from tm_devices.drivers import MP5103

if TYPE_CHECKING:
    from tm_devices.commands import MPSU50_2STCommands

with DeviceManager(verbose=True) as device_manager:
    # Add a mainframe to the device manager and access its commands.
    mainframe: MP5103 = device_manager.add_mf("192.168.0.1")

    # Some examples demonstrating the usage of mainframe level commands.
    mf_model = mainframe.commands.localnode.model
    value = mainframe.commands.eventlog.count

    # Get access to the psu module command object available in third slot of the mainframe.
    modular_psu = cast("MPSU50_2STCommands", mainframe.get_module_commands_psu(slot=3))
    # Some examples demonstrating the usage of module level commands.
    # Get the psu model and version
    psu_model = modular_psu.model
    psu_version = modular_psu.version
    modular_psu.firmware.verify()

    # Some examples demonstrating the usage of channel level commands.
    # Set the measurement aperture in seconds
    modular_psu.psu[1].measure.count = 5
    # Enable the source output
    modular_psu.psu[2].source.output = 1
    # Set the offset value used for voltage measurements
    rel_value = modular_psu.psu[1].measure.rel.levelv
    # Create a reference to the default buffer
    my_buffer = modular_psu.psu[1].defbuffer1
    # Read the value in the specified reading buffer
    # Measure the voltage on channel 1 of the PSU
    voltage_value = modular_psu.psu[1].measure.v()

VISA backend selection

The DeviceManager can be configured to use VISA backends from different VISA implementations.

"""An example script to choose visa from different visa resources."""

from tm_devices import DeviceManager
from tm_devices.drivers import MSO4B
from tm_devices.helpers import PYVISA_PY_BACKEND, SYSTEM_DEFAULT_VISA_BACKEND

with DeviceManager(verbose=True) as device_manager:
    # Explicitly specify to use the system VISA backend, this is the default,
    # **this code is not required** to use the system default.
    device_manager.visa_library = SYSTEM_DEFAULT_VISA_BACKEND
    # The above code can also be replaced by:
    device_manager.visa_library = "@ivi"

    # To use the PyVISA-py backend
    device_manager.visa_library = PYVISA_PY_BACKEND
    # The above code can also be replaced by:
    device_manager.visa_library = "@py"

    scope: MSO4B = device_manager.add_scope("127.0.0.1")
    print(scope)  # This prints basic information of the connected scope.

Alias usage

Devices can be given custom alias names and can be referenced by that alias.

"""An example of alias usage."""

from tm_devices import DeviceManager
from tm_devices.drivers import AWG5200, MSO5

with DeviceManager(verbose=True) as dm:
    # Add a scope and give an optional alias
    dm.add_scope("MSO56-100083", alias="BOB")
    # Add an awg using an IP address and optional alias
    dm.add_awg("192.168.0.1", alias="JILL")

    # Get the scope with the BOB alias from device manager
    bobs_scope: MSO5 = dm.get_scope("BOB")
    # Get the awg with the JILL alias from device manager
    jills_awg: AWG5200 = dm.get_awg("JILL")

Adding devices with environment variables

Device configuration information can be defined in an environment variable, usually done outside the Python code for ease of automation (shown inside the Python code here for demonstration purposes).

"""An example of using devices which are defined via environment variables.

These environment variables are settable outside Python code, usually they are set in the shell used
to execute the Python script.
"""

import os

from tm_devices import DeviceManager
from tm_devices.drivers import AFG31K, MSO2, SMU2601B

# Indicate to use the PyVISA-py backend rather than any installed VISA backends.
os.environ["TM_OPTIONS"] = "STANDALONE"
# Define some devices.
os.environ["TM_DEVICES"] = (
    "device_type=SCOPE,address=<IP or hostname>"  # Define a scope
    "~~~device_type=AFG,address=<IP or hostname>"  # Define an AFG
    "~~~device_type=SMU,address=<IP or hostname>"  # Define a SMU
)

# Create Tektronix Devices

with DeviceManager(verbose=True) as dm:
    # Scope
    scope: MSO2 = dm.get_scope(1)
    print(scope.query("*IDN?"))

    # Set horizontal scale and verify success
    scope.set_and_check(":HORIZONTAL:SCALE", 400e-9)
    scope.expect_esr(0)

    # AFG
    afg: AFG31K = dm.get_afg(1)
    print(afg.idn_string)

    # Turn on AFG and verify success
    afg.set_and_check(":OUTPUT1:STATE", "1")

    # SMU
    smu: SMU2601B = dm.get_smu(1)

    # Get device information
    print(smu.query("print(localnode.model)"))
    print(smu.query("print(localnode.serialno)"))
    print(smu.query("print(localnode.version)"))

Customize logging and console output

The amount of console output and logging saved to the log file can be customized as needed. This configuration can be done in the Python code itself as demonstrated here, or by using the config file or environment variable. See the configure_logging() API documentation for more details about logging configuration.

Important

If any configuration is performed in the Python code prior to instantiating the DeviceManager, all other logging configuration methods (config file, env var) will be ignored.

"""The console output and level of logging outputs in the log file can be configured as needed."""

from tm_devices import configure_logging, DeviceManager, LoggingLevels
from tm_devices.drivers import MSO6B

# NOTE: This configuration will prevent any logging config options from a config file or
# environment variable from being used.
configure_logging(
    log_console_level=LoggingLevels.NONE,  # completely disable console logging
    log_file_level=LoggingLevels.DEBUG,  # log everything to the file
    log_file_directory="./log_files",  # save the log file in the "./log_files" directory
    log_file_name="custom_log_filename.log",  # customize the filename
    log_pyvisa_messages=True,  # include all the pyvisa debug messages in the same log file
    log_uncaught_exceptions=True,  # log uncaught exceptions (this is the default behavior)
)

with DeviceManager(verbose=False) as dm:
    scope: MSO6B = dm.add_scope("192.168.0.1")
    scope.curve_query(1)
    scope.check_port_connection(4000)
    scope.check_network_connection()
    scope.check_visa_connection()

Disable command checking

This removes an extra query that verifies the property was set to the expected value. This can be disabled at the device level or disabled for all devices by disabling verification via the device manager.

"""An example showing how to disable the verification portion of the ``.set_and_check()`` method."""

from tm_devices import DeviceManager
from tm_devices.drivers import MSO5, SMU2601B

with DeviceManager(verbose=True) as dm:
    # Add some devices
    scope: MSO5 = dm.add_scope("192.168.0.1")
    smu: SMU2601B = dm.add_smu("192.168.0.2")

    #
    # Set some values and use verification to verify they were set properly.
    #
    # using set_and_check
    scope.set_and_check(":HORIZONTAL:SCALE", 100e-9)
    # using force_verify on the auto-generated commands
    scope.commands.horizontal.scale.write(200e-9, verify=True)
    # using the command verification context manager on the auto-generated commands
    with scope.command_verification():
        scope.commands.horizontal.scale.write(50e-9)
    # using set_and_check
    smu.set_and_check("beeper.enable", 1)
    # using the command verification context manager on the auto-generated commands
    with smu.command_verification():
        smu.commands.beeper.enable = 0

    #
    # Disable command verification.
    #
    # Disable just for the scope
    scope.enable_verification = False
    # Disable command verification for all devices
    dm.disable_command_verification = True

    #
    # Set some values, but now **no verification** will happen in any of these method calls.
    #
    # using set_and_check
    scope.set_and_check(":HORIZONTAL:SCALE", 100e-9)
    # using force_verify on the auto-generated commands
    scope.commands.horizontal.scale.write(200e-9, verify=True)
    # using the command verification context manager on the auto-generated commands
    with scope.command_verification():
        scope.commands.horizontal.scale.write(50e-9)
    # using set_and_check
    smu.set_and_check("beeper.enable", 1)
    # using the command verification context manager on the auto-generated commands
    with smu.command_verification():
        smu.commands.beeper.enable = 0

    #
    # Temporarily enable verification for a few commands
    #
    with scope.temporary_enable_verification(True):
        # This will be verified
        scope.set_and_check(":HORIZONTAL:SCALE", 500e-9)

Generate a signal using the Internal AFG

Use the Internal AFG to generate a 1 V, 10 MHz square wave with a 200 mV offset on CH1 of the SCOPE.

  • Requires a SCOPE with a license for the Internal AFG.
  • Requires the Internal AFG output to be connected to CH1 on the SCOPE
"""An example showing how to generate a signal using the scope's internal AFG."""

from tm_devices import DeviceManager
from tm_devices.drivers import MSO5

with DeviceManager(verbose=True) as dm:
    # Create a connection to the scope and indicate that it is a MSO5 scope for type hinting
    scope: MSO5 = dm.add_scope("192.168.0.1")

    # Generate the signal using individual PI commands.
    scope.commands.afg.frequency.write(10e6)  # set frequency
    scope.commands.afg.offset.write(0.2)  # set offset
    scope.commands.afg.square.duty.write(50)  # set duty cycle
    scope.commands.afg.function.write("SQUARE")  # set function
    scope.commands.afg.output.load.impedance.write("FIFTY")  # set load impedance
    scope.commands.ch[1].scale.write(0.5, verify=True)  # set and check vertical scale on CH1
    scope.commands.afg.output.state.write(1)  # turn on the Internal AFG output
    scope.commands.esr.query()  # check for any errors

    scope.commands.acquire.stopafter.write("SEQUENCE")  # perform a single sequence

    # Generate the same signal using a single method call.
    scope.generate_function(
        frequency=10e6,
        offset=0.2,
        amplitude=0.5,
        duty_cycle=50,
        function=scope.source_device_constants.functions.SQUARE,
        termination="FIFTY",
    )
    scope.commands.ch[1].scale.write(0.5, verify=True)  # set and check vertical scale on CH1
    scope.commands.acquire.stopafter.write("SEQUENCE")  # perform a single sequence

Save a screenshot from the device to the local machine

tm_devices provides the ability to save a screenshot with device drivers that inherit from the ScreenCaptureMixin, and then copy that screenshot to the local machine running the Python script.

"""Save a screenshot on the device and copy it to the local machine/environment."""

from tm_devices import DeviceManager
from tm_devices.drivers import MSO6B

with DeviceManager(verbose=True) as dm:
    # Add a scope
    scope: MSO6B = dm.add_scope("192.168.0.1")

    # Send some commands
    scope.add_new_math("MATH1", "CH1")  # add MATH1 to CH1
    scope.turn_channel_on("CH2")  # turn on channel 2
    scope.set_and_check(":HORIZONTAL:SCALE", 100e-9)  # adjust horizontal scale

    # Save a screenshot as a timestamped file. This will create a screenshot on the device,
    # copy it to the current working directory on the local machine,
    # and then delete the screenshot file from the device.
    scope.save_screenshot()

    # Save a screenshot as "example.png". This will create a screenshot on the device,
    # copy it to the current working directory on the local machine,
    # and then delete the screenshot file from the device.
    scope.save_screenshot("example.png")

    # Save a screenshot as "example.jpg". This will create a screenshot on the device
    # using INVERTED colors in the "./device_folder" folder,
    # copy it to "./images/example.jpg" on the local machine,
    # but this time the screenshot file on the device will not be deleted.
    scope.save_screenshot(
        "example.jpg",
        colors="INVERTED",
        local_folder="./images",
        device_folder="./device_folder",
        keep_device_file=True,
    )

Curve query saved to csv

Perform a curve query and save the results to a csv file.

  • Requires an AFG connected to channel 1 on a SCOPE.
"""An example showing a basic curve query."""

from pathlib import Path

from tm_devices import DeviceManager
from tm_devices.drivers import AFG3KC, MSO5

EXAMPLE_CSV_FILE = Path("example_curve_query.csv")

with DeviceManager(verbose=True) as dm:
    # Add a scope via hostname (use IP address if necessary)
    scope: MSO5 = dm.add_scope("MSO56-100083")
    # Add an AFG via IP address
    afg: AFG3KC = dm.add_afg("192.168.0.1")

    # Turn on AFG
    afg.set_and_check(":OUTPUT1:STATE", "1")

    # Perform curve query and save results to csv file
    curve_returned = scope.curve_query(1, output_csv_file=EXAMPLE_CSV_FILE)

# Read in the curve query from file
with EXAMPLE_CSV_FILE.open(encoding="utf-8") as csv_content:
    curve_saved = [int(i) for i in csv_content.read().split(",")]

# Verify query saved to csv is the same as the one returned from curve_query function call
assert curve_saved == curve_returned

Saving / recalling a waveform and session

We can save a waveform on our scope to an external file. This is useful for recalling previously saved waveforms if we ever need to use that waveform again.

The same can be done for scope sessions, sessions are essentially a snapshot of the current state of our scope.

"""An example of saving and recalling a waveform and session file."""

from tm_devices import DeviceManager
from tm_devices.drivers import MSO6B

with DeviceManager(verbose=True) as dm:
    # Get a scope
    scope: MSO6B = dm.add_scope("192.168.0.1")

    # Send some commands
    scope.add_new_math("MATH1", "CH1")  # add MATH1 to CH1
    scope.turn_channel_on("CH2")  # turn on channel 2
    scope.set_and_check(":HORIZONTAL:SCALE", 100e-9)  # adjust horizontal scale

    # save the session as example.tss
    scope.commands.save.session.write("example.tss")
    # save the waveform on CH1 as example.wfm
    scope.commands.save.waveform.write('CH1,"example.wfm"')

    scope.reset()  # reset the scope

    scope.recall_session("example.tss")  # recall the saved session example.tss
    scope.recall_reference("example.wfm", 1)  # recall example.wfm as REF1

Configuring a measurement on a single sequence

A scope can be configured for a measurement on a single acquisition by setting the appropriate acquisition parameters and adding the desired measurement on the selected channel.

"""An example script for connecting and configuring scope for acquisition."""

from tm_devices import DeviceManager
from tm_devices.drivers import MSO6B
from tm_devices.helpers import PYVISA_PY_BACKEND

with DeviceManager(verbose=True) as device_manager:
    # Enable resetting the devices when connecting and closing
    device_manager.setup_cleanup_enabled = True
    device_manager.teardown_cleanup_enabled = True

    # Use the PyVISA-py backend
    device_manager.visa_library = PYVISA_PY_BACKEND

    # Creating Scope driver object by providing ip address.
    scope: MSO6B = device_manager.add_scope("127.0.0.1")

    # Turn on channel 1 and channel 2
    scope.commands.display.waveview1.ch[1].state.write("ON")
    scope.commands.display.waveview1.ch[2].state.write("ON")

    # Set channel 1 vertical scale to 10mV
    scope.commands.ch[1].scale.write(10e-3)

    # Set horizontal record length to 20000
    scope.commands.horizontal.recordlength.write(20000)

    # Set horizontal position to 100
    scope.commands.horizontal.position.write(10)

    # Set trigger type to Edge
    scope.commands.trigger.a.type.write("EDGE")

    # Acquisition setup
    scope.commands.acquire.state.write("OFF")
    scope.commands.acquire.mode.write("Sample")
    scope.commands.acquire.stopafter.write("Sequence")

    # Adding measurements
    scope.commands.measurement.addmeas.write("AMPLitude")
    scope.commands.measurement.addmeas.write("PK2PK")
    scope.commands.measurement.addmeas.write("MAXIMUM")
    scope.commands.measurement.meas[1].source.write("CH1")
    scope.commands.measurement.meas[2].source.write("CH1")
    scope.commands.measurement.meas[3].source.write("CH2")

    # Get the measurement values
    scope.commands.acquire.state.write("ON")

    if int(scope.commands.opc.query()) == 1:
        scope.commands.measurement.meas[1].results.currentacq.mean.query()
        scope.commands.measurement.meas[2].results.currentacq.maximum.query()

Adding DPOJET measurements and plots

DPOJET measurements and plots can be added on a DPO70KSX/C/7KC/DPO5KB scope. Measurements report can be saved in a .pdf format.

"""An example of adding dpojet measurements and plots."""

from tm_devices import DeviceManager
from tm_devices.drivers import MSO70KDX
from tm_devices.helpers import PYVISA_PY_BACKEND

with DeviceManager(verbose=True) as device_manager:
    # Enable resetting the devices when connecting and closing
    device_manager.setup_cleanup_enabled = True
    device_manager.teardown_cleanup_enabled = True

    # Use the PyVISA-py backend
    device_manager.visa_library = PYVISA_PY_BACKEND

    # Creating one 7K/70K/SX Scope driver object by providing ip address.
    scope: MSO70KDX = device_manager.add_scope("127.0.0.1")

    # Starting DPOJET
    scope.commands.dpojet.activate.write()
    scope.commands.dpojet.version.query()

    # CLear all measurements
    scope.commands.dpojet.clearallmeas.write()

    # Add a few DPOJET measurements
    scope.commands.dpojet.addmeas.write("Period")
    scope.commands.dpojet.addmeas.write("Pduty")
    scope.commands.dpojet.addmeas.write("RiseTime")
    scope.commands.dpojet.addmeas.write("acrms")

    # Add a few DPOJET plots for the measurements
    scope.commands.dpojet.addplot.write("spectrum, MEAS1")
    scope.commands.dpojet.addplot.write("dataarray, MEAS2")
    scope.commands.dpojet.addplot.write("TimeTrend, MEAS3")
    scope.commands.dpojet.addplot.write("histogram, MEAS4")

    # Start a measurement
    scope.commands.dpojet.state.write("single")

    # Get the measurement values for the current acquisition data
    scope.commands.dpojet.meas[1].results.currentacq.max.query()
    scope.commands.dpojet.meas[1].results.currentacq.population.query()

    # Save all plots
    scope.commands.dpojet.saveallplots.write()

    # Save the report
    scope.commands.dpojet.report.savewaveforms.write("1")
    scope.commands.dpojet.report.write("EXECUTE")

Directly accessing the PyVISA resource object

The PyVISA resource object can be directly accessed if there is a specific action that is not yet available directly through the drivers in the tm_devices package.

"""Directly access the PyVISA resource object."""

from tm_devices import DeviceManager
from tm_devices.drivers import MSO5B

with DeviceManager() as device_manager:
    # Create the scope object.
    scope: MSO5B = device_manager.add_scope("192.168.0.1")

    # Access the PyVISA resource object directly,
    # `scope.visa_resource` returns a MessageBasedResource object from PyVISA.
    scope.visa_resource.read_bytes(1024)

Dynamic reading buffers (SMUs)

Create and read from a dynamic buffer.

"""An example of creating and reading from a dynamic buffer."""

from tm_devices import DeviceManager
from tm_devices.drivers import SMU2601B

with DeviceManager() as device_manager:
    # Create a SMU and type hint it as a 2601B
    smu: SMU2601B = device_manager.add_smu("192.168.0.1")

    # Create a buffer
    BUFFER_NAME = "mybuffer"
    smu.write(f"{BUFFER_NAME} = smua.makebuffer(100)")
    smu.commands.buffer_var[BUFFER_NAME].clear()
    smu.commands.buffer_var[BUFFER_NAME].collectsourcevalues = 1  # Enable source value storage
    smu.commands.buffer_var[BUFFER_NAME].appendmode = 1  # Enable buffer append mode
    capacity = smu.commands.buffer_var[BUFFER_NAME].capacity  # Get the buffer capacity

Registering the Device Manager to be closed at program termination

Sometimes using the DeviceManager class as a context manager is not feasible. In those instances there is an alternative way to enforce the device manager to close when the Python script execution is finished without needing to explicitly call the .close() method.

"""An example showing how to register the DeviceManager to close on program exit."""

import atexit

from tm_devices import DeviceManager
from tm_devices.drivers import MSO6B

# Create the device manager
dm = DeviceManager()

# Set up the device manager to be automatically closed when the program terminates
atexit.register(dm.close)

# Add a device
scope: MSO6B = dm.add_scope("192.168.0.1")

# Use the device
print(scope)

# The device manager will automatically close as the script exits, no code required.

Add custom device support

Sometimes there is a need to use a device that is not currently supported by tm_devices. When this is the case, custom device driver classes can be created and passed to the DeviceManager when it is first instantiated.

In order to do this a few things will need to be created:

  1. A custom device class. Ideally this would inherit from one of the main device types, though a custom class representing an unsupported device type can also be created.
  2. A mapping of the parsed model series string to the Python class.
"""An example of external device support via a custom driver."""

from tm_devices import DeviceManager, register_additional_usbtmc_mapping
from tm_devices.driver_mixins.device_control import PIControl
from tm_devices.drivers import MSO5
from tm_devices.drivers.device import Device
from tm_devices.drivers.scopes.scope import Scope
from tm_devices.helpers import ReadOnlyCachedProperty as cached_property  # noqa: N813


# Custom devices that inherit from a supported device type can be defined by inheriting from the
# specific device type class. This custom class must implement all abstract methods defined by the
# abstract parent classes.
class CustomScope(PIControl, Scope):
    """Custom scope class."""

    # This is an abstract method that must be implemented by the custom device driver
    @cached_property
    def total_channels(self) -> int:
        return 4

    # This is an abstract method that must be implemented by the custom device driver.
    def _get_errors(self) -> tuple[int, tuple[str, ...]]:
        """Get the current errors from the device."""
        # The contents of this method would need to be properly implemented,
        # this is just example code. :)
        return 0, ()

    def custom_method(self, value: str) -> None:
        """Add a custom method to the custom driver."""
        print(f"{self.name}, {value=}")


# Custom devices that do not inherit from a supported device type can be defined by inheriting from
# a parent class further up the inheritance tree as well as a control mixin class to provide the
# necessary methods for controlling the device. This custom class must also implement all abstract
# methods defined by the abstract parent classes.
class CustomDevice(PIControl, Device):
    """A custom device that is not one of the officially supported devices."""

    # Custom device types not officially supported need to define what type of device they are.
    @cached_property
    def device_type(self) -> str:
        """Return the device type."""
        return "CustomDevice"

    # This is an abstract method that must be implemented by the custom device driver.
    def _get_errors(self) -> tuple[int, tuple[str, ...]]:
        """Get the current errors from the device."""
        # The contents of this method would need to be properly implemented,
        # this is just example code. :)
        return 0, ()

    def custom_device_method(self, value: int) -> None:
        """Add a custom method to the custom device driver."""
        print(f"{self.name}, {value=}")


# For VISA devices, the model series is based on the model that is returned from
# the ``*IDN?`` query. (See the ``tm_devices.helpers.get_model_series()`` function for details)
# For REST API devices, the model series is provided via the ``device_driver`` parameter in
# the configuration file, environment variable, or python code.
CUSTOM_DEVICE_DRIVERS = {  # A mapping of custom model series strings to Python driver classes
    "CustomModelSeries": CustomScope,
    "CustomDeviceModelSeries": CustomDevice,
}


# To enable USBTMC connection support for a device without native USBTMC support in tm_devices,
# simply register the USBTMC connection information for the device's model series.
register_additional_usbtmc_mapping("CustomModelSeries", model_id="0x0000", vendor_id="0x0000")


with DeviceManager(external_device_drivers=CUSTOM_DEVICE_DRIVERS) as device_manager:
    # Add a scope that is currently supported by the package
    mso5: MSO5 = device_manager.add_scope("192.168.0.1")
    # Add the custom scope with a USB connection after registering the USBTMC mapping above
    custom_scope: CustomScope = device_manager.add_scope("MODEL-SERIAL", connection_type="USB")
    # Add the custom device that is a device type not officially supported
    # NOTE: If using a config file or environment variable to define a device that is unsupported,
    #       the `device_type` key must be set to "UNSUPPORTED".
    custom_device: CustomDevice = device_manager.add_unsupported_device("192.168.0.3")

    # Custom drivers inherit all methods and attributes
    print(custom_scope.all_channel_names_list)  # print the channel names
    custom_scope.cleanup()  # cleanup the custom scope
    # Custom drivers can also use added methods
    custom_scope.custom_method("value")

    # Custom device types still inherit methods from their parent classes, though device type
    # specific functionality is not defined by default
    assert not custom_device.has_errors()  # check for no errors
    # Custom devices can also use any custom methods added to the custom class
    custom_device.custom_device_method(10)