======================================================== Writing your first SKA Tango device using ska-tango-base ======================================================== This page will guide you through the steps to writing your SKA Tango device based on the ``ska-tango-base`` package. This tutorial assumes you are already familiar with Tango, i.e. that you have come across Tango devices, attributes and commands before. To serve as an example, the device we are going to build for this tutorial is called :class:`!FileStats`. The device will be configurable with a file path and will report statistics about the file at that path via Tango attributes. The device will also support two commands to manipulate the size of the configured file in order to demonstrate how to add "fast" and "long-running" commands. The intention is for the example device to not require any specific domain knowledge to understand the business logic of the device and we can focus on learning what we need to know to build a SKA Tango device. .. contents:: Steps :depth: 1 :local: :backlinks: none Step 0: Setup your environment ------------------------------ To follow along with this tutorial `set up your development environment`_ and install ``ska-tango-base`` package from the SKAO repository, along with the ``tutorial`` dependencies: .. code-block:: shell-session $ python3 -m pip --require-virtualenv install --extra-index-url https://artefact.skao.int/repository/pypi-all/simple ska-tango-base[tutorial] .. _set up your development environment: https://developer.skatelescope.org/en/latest/tools/tango-devenv-setup.html Step 1: Decide on a Tango interface ----------------------------------- The SKA Control Model defines several kinds of Tango devices that make up the control system. The ska-tango-base repository provides the following series of :class:`!Interface` base classes that correspond to these kinds of Tango devices: - :class:`~ska_tango_base.base.base_interface.BaseInterface` - :class:`~ska_tango_base.controller.controller_interface.ControllerInterface` - :class:`~ska_tango_base.obs.obs_interface.ObsInterface` - :class:`~ska_tango_base.subarray.subarray_interface.SubarrayInterface` Each interface defines a set of Tango commands and attributes that the particular kind of SKA Tango device should implement. Each interface is abstract in the sense that subclasses of this interface are expected to provide the behaviour of these commands and attributes by overriding methods. The interfaces form the following inheritance hierarchy with the :class:`~ska_tango_base.base.base_interface.BaseInterface`, unsurprisingly, at the base of the interfaces: .. uml:: create-tango-device/interface-heir.uml For our :class:`!FileStats` device, we only need the :class:`~ska_tango_base.base.base_interface.BaseInterface`. As this interface is the base of all the others, all SKA Tango devices need to implement it and the steps we follow here are going to be applicable to all SKA Tango devices. Once you have selected the interface class that is appropriate for your device, you should inherit from this interface, in our case we have the following, which should be saved as :download:`file_stats_device.py`: .. literalinclude:: create-tango-device/01/file_stats_device.py :language: python :linenos: :caption: :download:`Step 1`: A :class:`~ska_tango_base.base.base_interface.BaseInterface` device .. tip:: If, for whatever reason, your device does not need to implement any of the Tango commands/attributes provided by the :class:`~ska_tango_base.base.base_interface.BaseInterface`, it is recommended to at least use :class:`~ska_tango_base.ska_device.SKADevice`. This will setup logging so that it can be consumed by the SKA infrastructure and also provides standard SKA attributes for version information. These will help your device fit into the SKA ecosystem. Step 2: Starting our SKA Tango device ------------------------------------- Now is a good time to get into the habit of launching our Tango device and connecting to it to see how it behaves. .. tip:: While automated testing is an important practice when developing software, the purpose of this tutorial is to learn how to use ``ska-tango-base`` and there is nothing better for learning than getting your hands dirty with a Tango device. So, here we are going to focus on manual testing. We need to be able to launch a Tango device server with our Tango device. A common way to do this is to add a :code:`__name__ == "__main__"` block to our :download:`file_stats_device.py` script, like so: .. literalinclude:: create-tango-device/02/file_stats_device.py :language: python :linenos: :caption: :download:`Step 2`: Running our device server :emphasize-lines: 2, 9-10 Now we can launch the device server in ``-nodb`` mode. Here we are specifying the port 12345 and creating a single :class:`!FileStats` device: .. code:: shell-session $ python file_stats_device.py tutorial -nodb -ORBendPoint giop:tcp::12345 -dlist tut/fs/1 1|2026-02-10T12:22:16.789Z|INFO|MainThread|set_logging_level|ska_device.py#224|tango-device:tut/fs/1|Logging level set to LoggingLevel.INFO on Python and Tango loggers 1|2026-02-10T12:22:16.789Z|INFO|MainThread|update_logging_handlers|logging.py#379|tango-device:tut/fs/1|Logging targets set to ['tango::logger'] Ready to accept request Finally, from a separate ``itango`` terminal, we can create a :class:`tango.DeviceProxy` to the running device. With this :class:`tango.DeviceProxy` we can query what commands and attributes are defined for this device: .. This itango session is checked by tests/tutorial/test_create_tango_device.py::test_step_2 .. code:: python-console In [1]: dp = Device("tango://localhost:12345/tut/fs/1#dbase=no") In [2]: dp.ping() Out[2]: 346 In [3]: dp.get_command_list() Out[3]: ['GetVersionInfo', 'Init', 'State', 'Status'] In [4]: dp.get_attribute_list() Out[4]: ['buildState', 'versionId', 'loggingLevel', 'loggingTargets', 'adminMode', 'commandedState', 'healthState', 'healthInfo', 'State', 'Status'] Many of these attributes are provided by the :class:`~ska_tango_base.base.base_interface.BaseInterface` and will require us to add code to our device to implement the behaviour of these attributes. We can also read the device state and notice that we are "stuck" in :class:`DevState.INIT `: .. code:: python-console In [5]: dp.State() Out[5]: This is because the :class:`~ska_tango_base.base.base_interface.BaseInterface` requires us to override the :meth:`!init_device()` method. Step 3: Overriding the init_device() method ------------------------------------------- The :meth:`~tango.LatestDeviceImpl.init_device()` method is called automatically by Tango when the device is created and whenever a Tango client calls the built-in :meth:`!Init()` command. The device is created at device server startup but also when a client calls either of the :meth:`!DevRestart` or :meth:`!RestartServer` admin device commands. The SKA Control Model recommends that a Tango device pushes a ``state=DevState.INIT`` change event at the start of device initialisation and then pushes another event at the end of initialisation so that clients can be notified that the device is re-initialising. In order to achieve this, the :class:`ska_tango_base.base.base_interface.BaseInterface` requires subclasses to override the :meth:`!init_device()` method. When you override the :meth:`BaseInterface.init_device() ` method, it is important that you: - call :code:`super().init_device()` at the start; and - call :meth:`~ska_tango_base.base.base_interface.BaseInterface.init_completed()` at the end .. warning:: :code:`super().init_device()` does much more than push the initial change event during initialisation, it is very important this is always called when you override :meth:`~tango.LatestDeviceImpl.init_device()`. While we are at it, we should also provide information so that clients can query the device to determine what version of the software the device is running. This is achieved by setting :attr:`~ska_tango_base.ska_device.SKADevice._version_id` and :attr:`~ska_tango_base.ska_device.SKADevice._build_state` python attributes. As is common practice at SKAO, we are going to store the version information in a :download:`release.py` module, which for this tutorial you should save in the same directory as your file_stats_device.py module. With this release module, we can add the version information to our :meth:`!init_device()` method: .. literalinclude:: create-tango-device/03/file_stats_device.py :language: python :linenos: :caption: :download:`Step 3`: Adding :meth:`!init_device` :emphasize-lines: 3, 9-16 Now, after we restart the :download:`file_stats_device.py ` script, we can query the version information from the device via the :attr:`~ska_tango_base.ska_device.SKADevice.versionId` and :attr:`~ska_tango_base.ska_device.SKADevice.buildState` Tango attributes and via the :meth:`~ska_tango_base.ska_device.SKADevice.GetVersionInfo()` Tango command: .. This itango session is checked by tests/tutorial/test_create_tango_device.py::test_step_3 .. code:: python-console In [1]: dp = Device("tango://localhost:12345/tut/fs/1#dbase=no") In [2]: dp.versionId Out[2]: '0.1.0' In [3]: dp.buildState Out[3]: 'ska-tut-file-stats 0.1.0: A tutorial SKA Tango device.' In [4]: dp.GetVersionInfo() Out[4]: ['FileStats, ska-tut-file-stats 0.1.0: A tutorial SKA Tango device.'] We can also subscribe to change events for the :attr:`!state` attribute and observe that events are pushed when we initialise the device: .. code:: python-console In [5]: sid = dp.subscribe_event("state", tango.EventType.CHANGE_EVENT, tango.utils.EventCallback()) 2026-02-10 12:26:05.196861 TUT/FS/1 STATE CHANGE SUBSUCCESS [ATTR_VALID] DISABLE In [6]: dp.Init() 2026-02-10 12:26:10.296456 TUT/FS/1 STATE CHANGE UPDATE [ATTR_VALID] INIT 2026-02-10 12:26:10.296552 TUT/FS/1 STATE CHANGE UPDATE [ATTR_VALID] DISABLE Notice here that the :class:`~ska_tango_base.base.base_interface.BaseInterface` has placed us into :class:`DevState.DISABLE ` after we have called :meth:`~ska_tango_base.base.base_interface.BaseInterface.init_completed`. We will discuss what the SKA Control Model says about this state below, but it doesn't sound like a state we want our device to be in. Our device is in :class:`DevState.DISABLE ` because our device initially has an :attr:`~ska_tango_base.base.base_interface.BaseInterface.adminMode` attribute with a value of :class:`OFFLINE `. We need to move our :attr:`~ska_tango_base.base.base_interface.BaseInterface.adminMode` to :class:`ONLINE ` to instruct our Tango device that we wish to use it. .. tip:: The :attr:`~ska_tango_base.base.base_interface.BaseInterface.adminMode` is a memorised Tango attribute, meaning its (write) value is saved to the Tango database whenever it is written to by a client. This means that if we restart the device while it is in :class:`AdminMode.ONLINE ` it will come back in :class:`AdminMode.ONLINE `. However, in our case we are starting the device in ``-nodb`` mode so there is no database to save the value to and our device will always start in :class:`AdminMode.OFFLINE `. Let's try setting the :attr:`~ska_tango_base.base.base_interface.BaseInterface.adminMode`: .. code:: python-console In [7]: dp.adminMode Out[7]: In [8]: dp.adminMode = "ONLINE" PyDs_PythonError: NotImplementedError: 'change_control_level' method must be implemented by 'FileStats'. The parent 'BaseInterface' is an abstract base class. (For more detailed information type: tango_error) This doesn't work because we need to implement the behaviour of the :attr:`~ska_tango_base.base.base_interface.BaseInterface.adminMode` for our device by overriding the :meth:`~ska_tango_base.base.base_interface.BaseInterface.change_control_level` method. Step 4: Implementing the adminMode attribute -------------------------------------------- When a device is in :class:`AdminMode.OFFLINE ` the SKA Control Model requires that the device does not communicate with its "component" and reports its state as :class:`DevState.DISABLE `. Whereas, when a device is in :class:`AdminMode.ONLINE `, it should be monitoring and controlling its component. This means when a client transitions our device from :class:`AdminMode.OFFLINE ` to :class:`AdminMode.ONLINE ` we need to start monitoring and controlling some component and the :class:`~ska_tango_base.base.base_interface.BaseInterface` notifies us of this by calling the :meth:`~ska_tango_base.base.base_interface.BaseInterface.change_control_level` method which we are responsible for overriding in order to start monitoring. But hang on, what's a component? For the SKA Control Model, the "component" is just whatever the Tango device is responsible for monitoring and controlling, for example, it could be a piece of hardware such as a motor, it could be some other subordinate Tango devices or it could be a piece of software responsible for doing some number crunching. It could be almost anything and depends on the device in question. For our :class:`!FileStats` device the system under control is whichever file we are monitoring on the file system. .. note:: In other SKA documentation the "component" is often referred to as the "system under control". In this tutorial we use the term "component" as historically this is the term that has been used in ska-tango-base and is reflected in the ska-tango-base API. If we are going to start monitoring a file, we need to know which file to monitor. Let's add a Tango device property for the file path, being sure to provide a default value to make it easier to test. Let's also override the :meth:`~ska_tango_base.base.base_interface.BaseInterface.change_control_level` method to start monitoring this path when we are instructed: .. literalinclude:: create-tango-device/04/file_stats_device.py :language: python :linenos: :caption: :download:`Step 4`: Implementing :attr:`~ska_tango_base.base.base_interface.BaseInterface.adminMode` :emphasize-lines: 4-6, 17-24 :pyobject: FileStats The :meth:`~ska_tango_base.base.base_interface.BaseInterface.change_control_level` method accepts a :meth:`~ska_tango_base.base.base_interface.ControlLevel` parameter which we can use to determine whether we should start or stop monitoring our component. As we start and stop monitoring, we are responsible for updating the state of our Tango device to match the state that we find the component in. The :class:`~ska_tango_base.base.base_interface.BaseInterface` uses an internal state machine (an instance of :class:`~ska_control_model.OpStateModel`) to manage the device's state. We can drive this state machine by calling the various :meth:`!component_` methods which will "perform an action" on the state machine. For our implementation of :meth:`~ska_tango_base.base.base_interface.BaseInterface.change_control_level`, we perform the :meth:`~ska_tango_base.base.base_interface.BaseInterface.component_disconnected` action transitioning the device to :class:`DevState.DISABLE ` when we are instructed to stop monitoring (:class:`ControlLevel.NO_CONTACT `). When we are instructed to monitor the component (:class:`ControlLevel.FULL_CONTROL `) we transition to :class:`DevState.ON ` via the :meth:`~ska_tango_base.base.base_interface.BaseInterface.component_on` action to signal that we are ready to gather some file statistics. .. note:: The SKA Control Model defines the :class:`DevState.ON `, :class:`DevState.STANDBY ` and :class:`DevState.OFF ` states in terms of the monitored power consumption of the component. As there is no power consumption that we can monitor for a file, our :class:`!FileStats` device should just use the :class:`DevState.ON ` state and ignore the others. The SKA Control Model also defines optional :meth:`~ska_tango_base.base.base_interface.BaseInterface.On`, :meth:`~ska_tango_base.base.base_interface.BaseInterface.Standby` and :meth:`~ska_tango_base.base.base_interface.BaseInterface.Off` commands to transition the power consumption of the component. Again our :class:`!FileStats` device should ignore these commands as they are not applicable. Similarly, our :class:`!FileStats` should ignore the :attr:`~ska_tango_base.base.base_interface.BaseInterface.commandedState` attribute as it does not implement commands to transition the state. Let's restart our :download:`file_stats_device.py ` script and check that our Tango device transitions to :class:`DevState.ON ` now that we have implemented the :attr:`~ska_tango_base.base.base_interface.BaseInterface.adminMode`: .. This itango session is checked by tests/tutorial/test_create_tango_device.py::test_step_4 .. code:: python-console In [1]: dp = Device("tango://localhost:12345/tut/fs/1#dbase=no") In [2]: sid = dp.subscribe_event("state", tango.EventType.CHANGE_EVENT, tango.utils.EventCallback()) 2026-02-10 15:09:10.926130 TUT/FS/1 STATE CHANGE SUBSUCCESS [ATTR_VALID] DISABLE In [3]: dp.adminMode = "ONLINE" 2026-02-10 15:09:13.383190 TUT/FS/1 STATE CHANGE UPDATE [ATTR_VALID] ON Great! We are now ready to add our own attributes to expose metadata about the configured file and start "communicating" with our component. Step 5: Implementing our own attributes --------------------------------------- We are going to implement some attributes to expose the file's size, owner, mode (permissions) and last modified time. We will expose most of these as strings in a similar format to the ``ls -l`` command. To separate concerns somewhat, let's implement a :class:`!Metadata` class that is responsible for querying the file system for information about the file and converting the data into a string when required. .. literalinclude:: create-tango-device/05a/file_stats_device.py :language: python :linenos: :caption: :download:`Step 5a`: Adding :class:`!Metadata` :lines: 5-12, 39-98 Separating application specific logic out from the Tango device is generally a good idea to allow unit testing of this functionality and to allow the Tango device to focus on providing a Tango interface. Here, we separated the file system query (i.e. the :func:`os.stat` call in :func:`!refresh`) from the data conversion (i.e. the various properties). We also allow a disengaged state (where :attr:`!data` is :code:`None`) so that the Tango device can support :class:`DevState.DISABLE ` and not "communicate" with its component. .. note:: Historically, ``ska-tango-base`` has forced Tango devices to follow this pattern by requiring them to implement application specific logic in a "component manager". While this design pattern is a good one in the abstract, experience has shown that the exact line where concern should be separated is application specific and drawing this line in a foundational library such as ``ska-tango-base`` is too opinionated. It is better for each application to decide how to implement this design pattern for their specific case. ``ska-tango-base`` still supports the component manager approach via the :class:`~ska_tango_base.base.base_device.SKABaseDevice` class and its subclasses, however, it is not recommended to use this approach for new devices. Now let's use this :class:`!Metadata` class to implement our attributes: .. literalinclude:: create-tango-device/05b/file_stats_device.py :language: python :linenos: :caption: :download:`Step 5b`: Using :class:`!Metadata` :emphasize-lines: 14, 21, 28-60 :pyobject: FileStats Here, we are using :meth:`~tango.LatestDeviceImpl.read_attr_hardware` to refresh the metadata so that a client will receive data from the same :func:`os.stat` call if they read multiple attributes in the same :meth:`~tango.DeviceProxy.read_attributes` call. We use the same timestamp for each attribute to make it clearer that they have come from the same :func:`os.stat` call. We are also using a helper method :meth:`!_to_read_attr_type` to ensure that we report the attribute quality factor as invalid if we do not have any data. Let's restart the :download:`file_stats_device.py` script, and have a go at querying file metadata with our Tango device: .. This itango session is checked by tests/tutorial/test_create_tango_device.py::test_step_5b .. code:: python-console In [1]: dp = Device("tango://localhost:12345/tut/fs/1#dbase=no") In [2]: print(dp.size) None In [3]: dp.adminMode = "ONLINE" In [4]: dp.size PyDs_PythonError: FileNotFoundError: [Errno 2] No such file or directory: '/home/tri/skao/ska-tango-base/docs/src/tutorials/create-tango-device/dummy' (For more detailed information type: tango_error) In [5]: !touch dummy In [6]: dp.size Out[6]: 0 In [7]: !head -c128 /dev/urandom >> dummy In [8]: dp.size Out[8]: 128 In [9]: dp.owner Out[9]: 'tri:tri' In [10]: dp.mode Out[10]: '-rw-r--r--' In [11]: dp.lastModifiedTime Out[11]: 'Tue Feb 10 15:26:38 2026' In [12]: print(f"{dp.mode} {dp.owner} {dp.size} {dp.lastModifiedTime}") -rw-r--r-- tri:tri 128 Tue Feb 10 15:26:38 2026 Great! A client can request the file metadata, but unfortunately, we cannot subscribe to change events for these attributes: .. code:: python-console In [13]: sid = dp.subscribe_event("size", tango.EventType.CHANGE_EVENT, tango.utils.EventCallback()) API_AttributePollingNotStarted: The polling (necessary to send events) for the attribute size is not started (For more detailed information type: tango_error) The standard way to solve this problem with Tango would be to enable polling, which can be done by configuring the Tango device. However, there are two issues with this approach which means we avoid using the built-in Tango polling at SKAO: 1. The Tango polling loop does not cope well when running in a resource constrained environment such as a kubernetes cluster. See `Tango device server polling loop considered harmful`_. 2. Polling introduces latency so it should be avoided if possible. If the component is capable of generating events itself then this should be preferred. Fortunately, the `watchfiles`_ python package (installed as a ``tutorial`` dependency) can provide events whenever our file changes allowing us to manually push Tango events with new metadata. This avoids the need to use polling for this device. .. tip:: If your component does not support generating events itself, you should use :class:`~ska_tango_base.poller.poller.Poller` rather than the built in Tango polling loop to avoid the issue 1 above. Team Wombat is working with the wider Tango community to improve the Tango polling loop so that we can use it at SKA. .. _Tango device server polling loop considered harmful: https://confluence.skatelescope.org/x/qELwE .. _watchfiles: https://pypi.org/project/watchfiles/ Step 6: Implementing event driven monitoring -------------------------------------------- In order for our Tango device to get notified that the file we are monitoring has changed, we need a dedicated background thread to receive these notifications. Let's start by adding a method, :meth:`!monitor_for_updates` to our :class:`!Metadata` class that can refresh the metadata whenever we receive a change notification from ``watchfiles``: .. literalinclude:: create-tango-device/06a/file_stats_device.py :language: python :linenos: :caption: :download:`Step 6a`: Watching files :lines: 5,13,16-18,81-83,102-123 Here, we setup the :func:`~watchfiles.main.watch`, filtering out events not for our file, before refreshing the metadata and waiting for notifications. This ensures that we do not miss any changes. Once we get notified of changes, we don't look at what the changes are and instead just refresh the metadata. We accept a :attr:`!stop_event` parameter to pass on :func:`~watchfiles.main.watch` so that the monitoring can be interrupted on request. .. note:: As is outlined in the ``TODO``, this implementation of :meth:`!monitor_for_updates` does not handle missing parent directories. If :class:`!FileStats` was a real SKA Tango device, this would need resolving, however, we have omitted this here to keep the implementation simple so that we can focus on the generally applicable behaviour of the Tango device. Next, let's spawn a thread from our Tango device to call :meth:`!monitor_for_updates` whenever we change control level: .. literalinclude:: create-tango-device/06b/file_stats_device.py :language: python :linenos: :caption: :download:`Step 6b`: Spawning a thread :lines: 21-83 :emphasize-lines: 16-17,21-24,29,34,38-63 Here, we make sure to also shutdown the thread in our :meth:`!delete_device()` method. The thread target function wraps the call to :meth:`!monitor_for_updates` in a `try`/`except` statement defensively to ensure that any issues not handled by the :meth:`!monitor_for_updates` method are logged and the device is transitioned to the :class:`DevState.FAULT ` state. This state indicates that the device has encountered an error it is not going to recover from automatically and requires an operator to intervene. The operator can try to resolve this by calling the :meth:`!Init()` to reinitialise the device. In general, requiring manual intervention like this should be considered a bug in the SKA Tango device, or at least that the SKA Tango device is not being used as intended, and should be avoided. However, it is good practice to be defensive in a scenario such as spawning a thread so that if a problem occurs it is reported and an operator knows that something fishy has occurred. .. warning:: Any resources you allocate in your :meth:`!init_device()` method (such as threads, sockets, open files, etc.) _must_ be cleaned up in a :meth:`!delete_device()` method, otherwise the built-in :meth:`!Init()` command will not work reliably. Also, when overriding the :meth:`!delete_device()` method, you _must_ call :code:`super().delete_device()` to clean up any resources a super class has allocated. :ref:`init-guidelines` has more guidelines for managing the lifecycle of your device. .. tip:: Whenever your SKA Tango device transitions to :class:`DevState.FAULT `, you should provide a status describing what has failed. This can be done by assigning to the :attr:`~ska_tango_base.base.base_interface.BaseInterface._status` signal which will update the Tango built-in :attr:`!status` attribute. You also need to make sure the status gets cleared when the device no longer has :class:`DevState.FAULT `. Now that we have a thread that can monitor our file, we need that thread to be able to push data to the Tango device to be sent on as Tango events. The :class:`~ska_tango_base.base.base_interface.BaseInterface` class provides a signal bus mechanism to facilitate this. The :class:`!Metadata` class can emit signals on the bus which can then be listened for by the Tango device to push events for the corresponding attributes. The signal processing happens on a background thread managed by the :class:`~ska_tango_base.base.base_interface.BaseInterface`. The advantage of this approach is that it decouples the :class:`!Metadata` from the Tango device and avoids our monitoring background thread from having to grab the Tango device lock at any point. Let's add some :class:`~ska_tango_base.software_bus.AttrSignal` to our :class:`!Metadata` class and replace our existing attributes with :class:`ska_tango_base.software_bus.attribute_from_signal` objects pointing to the :class:`!Metadata` signals: .. literalinclude:: create-tango-device/06c/file_stats_device.py :language: python :linenos: :caption: :download:`Step 6c`: Hooking up signals :lines: 21-22,39-44,95-113 Here, we moved the initialisation of the :class:`!Metadata` object to the :meth:`~ska_tango_base.base.base_interface.BaseInterface.on_new_shared_bus` method as it is now a :class:`~ska_tango_base.software_bus.SharingObserver` and we need it to be available to share the bus with it. Finally, let's actually emit values on each of these signals whenever we :meth:`!refresh()` or :meth:`!reset()` the metadata: .. literalinclude:: create-tango-device/06d/file_stats_device.py :language: python :linenos: :caption: :download:`Step 6d`: Pushing signals :lines: 21-22,88-95,105-108,122-165 :emphasize-lines: 9-10,21-22,29-30,32-58 Here, we assign ``None`` to each signal if we do not have any data. This will instruct the :class:`ska_tango_base.software_bus.attribute_from_signal` objects to clear any data they have stored and return a :class:`ATTR_INVALID ` result if a client reads the attribute. We also call :meth:`BusProtocol.wait_for_thread ` in our :meth:`~tango.LatestDeviceImpl.read_attr_hardware` method to ensure that the signals emitted by the call to :meth:`!refresh` have been processed before we read any attributes as the :class:`ska_tango_base.software_bus.attribute_from_signal` will not return the new values until the corresponding events have been pushed. .. warning:: When an attribute is updated via a signal, the actual update happens asynchronously on a background thread. If the order of operations is important, you must ensure that all attribute updates happen on this background thread by using, for example, :class:`~ska_tango_base.software_bus.attribute_from_signal`; or you must ensure that the updates are synchronised with the thread by using :meth:`~ska_tango_base.type_hints.BusProtocol.wait_for_thread` as we do in our :meth:`!read_attr_hardware`. Now, after we have restarted the :download:`file_stats_device.py` script, we can receive events from the Tango device as the file changes: .. This itango session is checked by tests/tutorial/test_create_tango_device.py::test_step_6d .. code:: python-console In [1]: dp = Device("tango://localhost:12345/tut/fs/1#dbase=no") In [2]: sid = dp.subscribe_event("size", tango.EventType.CHANGE_EVENT, tango.utils.EventCallback()) 2026-02-10 20:37:04.617333 TUT/FS/1 SIZE CHANGE SUBSUCCESS [ATTR_INVALID] None In [3]: sid2 = dp.subscribe_event("lastModifiedTime", tango.EventType.CHANGE_EVENT, tango.utils.EventCallback()) 2026-02-10 20:37:13.877250 TUT/FS/1 LASTMODIFIEDTIME CHANGE SUBSUCCESS [ATTR_INVALID] None In [4]: dp.adminMode = "ONLINE" 2026-02-10 20:37:23.244046 TUT/FS/1 SIZE CHANGE UPDATE [ATTR_VALID] 128 2026-02-10 20:37:23.244046 TUT/FS/1 LASTMODIFIEDTIME CHANGE UPDATE [ATTR_VALID] Tue Feb 10 15:26:38 2026 In [5]: !head -c128 /dev/urandom >> dummy 2026-02-10 20:37:33.753628 TUT/FS/1 SIZE CHANGE UPDATE [ATTR_VALID] 256 2026-02-10 20:37:33.753628 TUT/FS/1 LASTMODIFIEDTIME CHANGE UPDATE [ATTR_VALID] Tue Feb 10 20:37:33 2026 The monitoring side of our Tango device is now almost complete, but there are two attributes provided by :class:`~ska_tango_base.base.base_interface.BaseInterface` that we have not yet implemented: :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthState` and :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthInfo`. Let's try reading them now: .. code:: python-console In [6]: dp.healthState Out[6]: In [7]: dp.healthInfo Out[7]: ('Device implementation has not provided a health report',) The :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthInfo` attribute has told us that our device implementation needs to provide a health report. So let's do that. Step 7: Providing health reports ---------------------------------------------- The :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthState` of a SKA Tango device should be used to indicate whether the Tango device is capable of performing its functionality. The :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthInfo` attribute consists of a list of strings used to describe why we are in a non-OK health state. Together, these two attributes are called the health report of the device and it is our job to provide health reports via the :meth:`~ska_tango_base.base.base_interface.BaseInterface.report_health` method. For our :class:`!FileStats` device, we should use this method to communicate any issues encountered by the monitoring thread, notably, if we are unable to call :func:`os.stat` on the configured file. In order for our :class:`!Metadata` class to asynchronously send exceptions to the Tango device, let's introduce another signal, :attr:`!stat_failed`, and then hook it up to the :meth:`!refresh` and :meth:`!reset` methods: .. literalinclude:: create-tango-device/07a/file_stats_device.py :language: python :linenos: :caption: :download:`Step 7a`: Pushing exceptions :lines: 107-109,117,124-149 :emphasize-lines: 4,10,20-27 Then, let's add a signal listener to the Tango device that can update the :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthState` whenever this new signal is emitted: .. literalinclude:: create-tango-device/07b/file_stats_device.py :language: python :linenos: :caption: :download:`Step 7b`: Receiving exceptions :lines: 21-22,105-111 Here, we are supplying an empty ``health_info`` list to :meth:`~ska_tango_base.base.base_interface.BaseInterface.report_health` when the ``health_state`` is :class:`OK `. This is required, the ``health_info`` must be provided for :class:`DEGRADED ` and :class:`FAILED ` health states and must be empty for :class:`OK `. Our :class:`!FileStats` device is relatively simple, so there is only ever one issue to include in the health report at a time. For more complex devices there may be multiple issues which should all be reported in the health info. .. note:: The :class:`UNKNOWN ` should no longer be used. And finally, let's update the :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthState` in our monitor thread target function when we encounter any unexpected problem: .. literalinclude:: create-tango-device/07c/file_stats_device.py :language: python :linenos: :caption: :download:`Step 7c`: Update healthState :lines: 21-22,61-74 :emphasize-lines: 13 .. tip:: Whenever a SKA Tango device transitions to :class:`DevState.FAULT ` it should also transition to :class:`HealthState.FAILED `. The :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthState` should be used to indicate whether the device can fulfil its core functionality, if the Tango device has encountered an unexpected error (as indicated by :class:`DevState.FAULT `) then it cannot fulfil its core functionality and therefore should report :class:`HealthState.FAILED `. However, the converse is not true. :class:`DevState.FAULT ` should only be used if the SKA Tango device knows for certain that operator intervention is required for the Tango device itself to recover when the underlying component recovers. For our :class:`!FileStats` device, the configured file being missing does not warrant :class:`DevState.FAULT ` because if the file appears (via miracle or otherwise) the Tango device will detect this and the :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthState` will recover to :class:`HealthState.OK `. However, if the _parent_ directory was missing then :class:`DevState.FAULT ` is warranted because the monitor thread crashes if the parent directory is missing (because we did not implement this for simplicity's sake) and if the parent directory is created, the Tango device will not notice. It requires an operator to call the :meth:`!Init()` command after the directory has been created. With all this in place, let's restart the :download:`file_stats_device.py` script and try out our new :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthState`: .. This itango session is checked by tests/tutorial/test_create_tango_device.py::test_step_7c .. code:: python-console In [1]: dp = Device("tango://localhost:12345/tut/fs/1#dbase=no") In [2]: import ska_control_model as scm In [3]: sid = dp.subscribe_event("healthState", tango.EventType.CHANGE_EVENT, lambda ev: print(scm.HealthState(ev.attr_value.value))) HealthState.FAILED In [4]: sid2 = dp.subscribe_event("healthInfo", tango.EventType.CHANGE_EVENT, tango.utils.EventCallback()) 2026-03-27 13:18:24.534954 TUT/FS/1 HEALTHINFO CHANGE SUBSUCCESS [ATTR_VALID] ('Device implementation has not provided a health report',) In [5]: dp.adminMode = "ONLINE" 2026-03-27 13:19:20.462533 TUT/FS/1 HEALTHINFO CHANGE UPDATE [ATTR_VALID] () HealthState.OK In [6]: !rm dummy 2026-03-27 13:19:27.111892 TUT/FS/1 HEALTHINFO CHANGE UPDATE [ATTR_VALID] ("stat('dummy'): [Errno 2] No such file or directory: '/path/to/dummy'",) HealthState.FAILED In [7]: !touch dummy 2026-03-27 13:19:35.976478 TUT/FS/1 HEALTHINFO CHANGE UPDATE [ATTR_VALID] () HealthState.OK With health reporting now implemented, our :class:`!FileStats` device can monitor the stats on a file and report problems according to the SKA Control Model. However, our Tango device does not perform any _control_ of its component. Let's add some commands to explore how control is handled for SKA Tango devices. Step 8: Implementing commands ----------------------------- At SKA we talk about two different kinds of Tango commands which we call fast commands and long running commands respectively. As the names suggest, so-called fast commands are standard Tango commands that complete quickly (the SKA CS Guidelines suggest <10ms as the cut off). When a client is invoking a fast command, it is assumed that the client will block to wait for a response, as such it is important that these commands really are fast. However, it is not just the client that calls the command that suffers if a fast command is slow. Due to the use of the default "SYNC mode" used by SKA Tango devices, a lock is held while each Tango command is executed such that no other client can interact with the Tango device while the Tango command is executing. This means that a slow "fast command" locks the Tango device from _all_ other clients. Moreover, while in "SYNC mode" the Tango device is unable to push events while this command is executing. To alleviate these issues with Tango commands that take too long, at SKA we have devised the "long running command" protocol, built on top of the Tango event system. The long running command mechanism allows the Tango device to release the lock while it services a command. This allows other clients to use the Tango device while this command is being executed. More information about long running commands can be found at :ref:`long-running-commands-concept`. Our :class:`!FileStats` device is going to implement two commands :func:`!Shrink` and :func:`!Grow` to allow clients to manipulate the size of the file. Shrinking a file is a relatively quick operation as it just requires updating the metadata so we will implement :func:`!Shrink` as a SKA fast command. Conversely, we are going to require that the :func:`!Grow` command does not return until the data has been committed to disk (via ``fsync(2)``), so this we will implement it as a long running command. For both these commands, we are going to require that they really are shrinking or growing the file otherwise we will refuse to execute them. To support this we are going to need to know the size of the file before we grow or shrink it. This should be handled by our :class:`!Metadata` class and indeed we can query the current size of the file using the :attr:`!size` property. However, if we cannot determine the size of file for whatever reason we just get back ``None`` and it would be nice to provide some details to the caller of the command if we cannot determine the size of the file. Let's begin by adding a :meth:`!get_size_or_raise` method to the :class:`!Metadata` class that solves this problem for us: .. literalinclude:: create-tango-device/08a/file_stats_device.py :language: python :linenos: :caption: :download:`Step 8a`: Storing exceptions :lines: 114-115,123-160,254-276 :emphasize-lines: 4,11,15-21,28-39, 41- Here, we have passed ``stored=True`` to the :attr:`!stat_failed` signal so that the last value emitted is kept around and we can use it later. We have introduced a lock to ensure that the exception and the :attr:`!data` are updated atomically. Implementing a fast command ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Armed with our new :meth:`!get_size_or_raise` method, let's implement a :func:`!Shrink` command using :func:`os.truncate`: .. literalinclude:: create-tango-device/08b/file_stats_device.py :language: python :linenos: :caption: :download:`Step 8b`: Shrink :lines: 21-22,112-138 Here, we are returning a (:class:`ska_control_model.ResultCode`, message) pair as this is recommended by the SKA CS Guidelines, however, we are using exceptions to report total failures. This is because clients already have to deal with exceptions thrown by Tango when e.g. there are network issues connecting to the device, it is often easier for them to handle an additional exception rather than having to check error codes as well. The :class:`ska_control_model.ResultCode` should only be used by a command if it needs to communicate partial success. See :ref:`failure-guidelines` for a longer discussion on reporting failures. We are also providing an :meth:`!is_Shrink_allowed` method to disallow calling the command if our device is disabled. This is important to avoid modifying the file if our device has been put in :class:`AdminMode.OFFLINE `. .. warning:: It might be tempting to disallow this command while the :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthState` is not ``OK`` to avoid having to deal with the current size not being available. In this exact situation this might be reasonable because we know for certain it isn't going to work, however, it is a bad idea for a Tango device to disobey a client based on the :attr:`~ska_tango_base.base.base_interface.BaseInterface.healthState`. Similarly, we do not reject the command when we are in :class:`DevState.FAULT `. In general, your SKA Tango device should make its best effort to execute the commands it has been instructed to execute and to report back any problems it finds. The client already knows about the problem and might be trying to diagnose issues by invoking commands. We should not get in their way. Note, however, that the command still fails if we cannot determine the size of the file because it would be "dangerous" to shrink the file if we do not know that it is really a shrink operation. This argument actually suggests that we should not use the :class:`!Metadata` object to determine the original size, but instead call :func:`os.stat` directly in the command. However, we chose the current implementation to highlight the ``stored=True`` option for the :class:`~ska_tango_base.software_bus.Signal` data descriptor. Implementing a long running command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When an asynchronous long running command is invoked on a SKA Tango device, it is sometimes necessary to cancel the command because, for example, the situation has changed so the command is no longer required or because the command was invoked in error. The SKA long running command mechanism provides an :meth:`!Abort` command to support command cancellation and this is implemented as a "co-operative abort", in that each command is responsible for checking if it has been aborted while it is executed. In order to support being aborted, our :func:`!Grow` command will grow the file in chunks and check if the command has been aborted between writing each chunk. As the logic for doing this transfer is a little complicated, it would be a good idea to test this independently, let's start by writing a :func:`!chunked_transfer` function that supports being aborted: .. literalinclude:: create-tango-device/08c/file_stats_device.py :language: python :linenos: :caption: :download:`Step 8c`: Chunked transfer :pyobject: chunked_transfer Here, we also accept a :attr:`!progress_callback` which we call with a completion percentage as we transfer each chunk. As we will see later, this callback is provided by ``ska-tango-base``'s long running command API and it is generally a good idea to provide this progress information if possible. If we notice that the :attr:`!task_abort_event` has been set, we raise a :class:`~ska_tango_base.executor.executor.TaskAborted` exception to acknowledge that we have been aborted. This exception will be used by the long running command API later. Now we are ready to add our long running command. In order to support long running commands, our device needs to inherit from :class:`~ska_tango_base.long_running_commands.mixin.LRCMixin` in addition to the :class:`~ska_tango_base.base.base_interface.BaseInterface` class. We then decorate our command function with :func:`~ska_tango_base.long_running_commands.decorators.long_running_command` as opposed to :func:`~tango.server.command`. This command method needs to return a task function that will then be scheduled on a background thread to perform the long running command. This task function can be built with the :func:`~ska_tango_base.executor.executor.TaskExecutor.task` decorator. Let's have a go: .. literalinclude:: create-tango-device/08d/file_stats_device.py :language: python :linenos: :caption: :download:`Step 8d`: Grow :lines: 3, 20-23,140-233 Here, we are using the :func:`ska_tango_base.validators.validate_json_args` to support multiple arguments for the command. The resulting Tango command will have a single string as an argument which is expected to hold a JSON string. This JSON string will be validated against the :attr:`!Grow_SCHEMA` provided before the task is submitted to be executed. The client will receive an exception if the string is not valid JSON or does not match the schema. Notice as well, that we are not raising exceptions from our task (except :class:`~ska_tango_base.executor.executor.TaskAborted`), but instead we now return a :class:`~ska_control_model.ResultCode`. This is because there is no mechanism to transfer an exception to an LRC client once the task has been scheduled and so instead long running commands use error codes. Again, see :ref:`failure-guidelines` for a more detailed discussion of this. Let's restart the :download:`file_stats_device.py` script and try out our new commands. We can use the :func:`~ska_tango_base.long_running_commands.api.invoke_lrc` function to call our long running command and get notified of its progress: .. This itango session is checked by tests/tutorial/test_create_tango_device.py::test_step_8d .. code:: python-console In [1]: dp = Device("tango://localhost:12345/tut/fs/1#dbase=no") In [2]: import json In [3]: import ska_tango_base.long_running_commands as stb_lrc In [4]: def make_callback(name): ...: def callback(**kwargs): ...: print(f"{name}: {kwargs}") ...: return callback ...: In [5]: dp.Shrink(0) API_CommandNotAllowed: Command Shrink not allowed when the device is in DISABLE state (For more detailed information type: tango_error) In [6]: subs = stb_lrc.invoke_lrc(make_callback("Grow"), dp, "Grow", command_args=(json.dumps({"new_size": 4096, "chunk_size": 512, "source": "/dev/urandom"}),)) Grow: {'status': } Grow: {'status': } Grow: {'status': , 'result': [6, 'Command is not allowed']} In [7]: dp.adminMode = "ONLINE" In [8]: dp.Shrink(0) Out[8]: [array([0], dtype=int32), ["File shrunk to size '0'"]] In [9]: dp.size Out[9]: 0 In [10]: subs = stb_lrc.invoke_lrc(make_callback("Grow"), dp, "Grow", command_args=(json.dumps({"new_size": 4096, "chunk_size": 512, "source": "/dev/urandom"}),)) Grow: {'status': } Grow: {'status': } Grow: {'status': } Grow: {'progress': 0} Grow: {'progress': 12} Grow: {'progress': 25} Grow: {'progress': 37} Grow: {'progress': 50} Grow: {'progress': 62} Grow: {'progress': 75} Grow: {'progress': 87} Grow: {'progress': 100} Grow: {'status': , 'result': [0, 'File size increased to 4096']} In [11]: dp.size Out[11]: 4096 Let's also check that we can :func:`!Abort()` the long running command: .. code:: python-console In [12]: subs = stb_lrc.invoke_lrc(make_callback("Grow"), dp, "Grow", command_args=(json.dumps({"new_size": 30 * 1024 * 1024, "chunk_size": 512, "source": "/dev/urandom"}),)) Grow: {'status': } Grow: {'status': } Grow: {'status': } Grow: {'progress': 0} Grow: {'progress': 1} Grow: {'progress': 2} Grow: {'progress': 3} Grow: {'progress': 4} Grow: {'progress': 5} Grow: {'progress': 6} Grow: {'progress': 7} Grow: {'progress': 8} Grow: {'progress': 9} Grow: {'progress': 10} Grow: {'progress': 11} Grow: {'progress': 12} Grow: {'progress': 13} Grow: {'progress': 14} Grow: {'progress': 15} Grow: {'progress': 16} Grow: {'progress': 17} In [13]: subs2 = stb_lrc.invoke_lrc(make_callback("Abort"), dp, "Abort") Abort: {'status': } Abort: {'status': } Grow: {'status': , 'result': [7, 'Task aborted']} Abort: {'status': , 'result': [0, 'Abort completed OK']} Great! This all seems to be working. Conclusion ---------- With this you now have built an SKA Tango device that can monitor and control a file on the file system. Along the way we have learnt: - How to manage the various :class:`~ska_tango_base.base.base_interface.BaseInterface` attributes - How to setup monitoring of a component and push data to the Tango device using the signal bus - How to create commands, be they long running or fast Further reading ^^^^^^^^^^^^^^^ - `Tango Best Practices`_: For some general advice about writing Tango devices - :ref:`optional-attributes`: For if you need to support some of the optional attributes defined by the SKA Control Model - :ref:`component-manager-concept`: For if you are working with a device that uses a component manager - :ref:`invoke-lrc`: For when you need to call some other Tango device's long running command .. _Tango Best Practices: https://confluence.skatelescope.org/x/meDDFQ .. vim: sw=4