================ Migrating to 1.4 ================ This migration guide lists all the deprecations and unintentional breaking changes introduced by ska-tango-base release 1.4.0. Despite the breaking changes, in the majority of cases developers should still be able to update from version 1.x without having to make any changes. However, there are two features that have been deprecated with this release and where ever possible deprecation warnings are emitted whenever they are used. This guide contains recommendations about how to resolve the deprecation warnings in order to prepare for the major release of ska-tango-base where these will be removed. There are two unintentional breaking changes that affect the following sets of devices: - Any devices overriding base class Tango attributes (such as :py:attr:`!adminMode`) and calling the "super" version of the attribute via the super class. - Any devices manually pushing events for base class Tango attributes rather than using the e.g. :py:meth:`!_update_admin_mode` method. Unfortunately, it is not possible to fix these breaking changes in a patch release. This guide contains information about these breaking changes and provides instructions on how to resolve them. In addition to the feature deprecations, the module structure of ska-tango-base has been re-organised for the 1.4.0 release. This re-organisation is backwards compatible, however, there have been some deprecations. Also, a new :py:mod:`~ska_tango_base.type_hints` module is provided and for the :py:class:`!LrcSubscriptions` and :py:class:`~ska_tango_base.base.command_tracker.CommandTracker` classes protocol types have been added to use instead of the concrete type for type hint purposes. These protocols are each the structural interface that the corresponding class will support in the next major release of ska-tango-base. This guide describes how to write ska-tango-base import statements that are future-proof and how to use the provided protocol classes to ensure that your usage of the :py:class:`!LrcSubscriptions` and :py:class:`~ska_tango_base.base.command_tracker.CommandTracker` is future proof. .. warning :: The :py:class:`~ska_tango_base.base.base_device.SKABaseDevice` now makes use of the :py:meth:`!always_executed_hook` and :py:meth:`!delete_device` methods. Subclasses of :py:class:`~ska_tango_base.base.base_device.SKABaseDevice` must always ensure they call the :code:`super()` version of these methods if they override them otherwise :py:class:`~ska_tango_base.base.base_device.SKABaseDevice` will fail to clean up after itself. This has always been a requirement for these functions, however, it may have worked in previous releases as the methods were not used in ska-tango-base. .. contents:: Contents :depth: 2 :local: :backlinks: none ExecutePendingOperations deprecated ----------------------------------- Previous releases of ska-tango-base allowed subclasses of :py:class:`~ska_tango_base.base.base_device.SKABaseDevice` to call so-called "Tango operations", such as :py:meth:`tango.LatestDeviceImpl.push_change_event` from any thread even though Tango requires these operations be called from an omnithread. This was achieved by not calling the operations directly, but scheduling the operations to be called at some point later by a polled :py:func:`!ExecutePendingOperations` command. In the 1.4.0 release of ska-tango-base the :py:func:`!ExecutePendingOperations` command has been removed. It is currently still possible to call tango operations from a non-omnithread as now the operation will be scheduled to be executed on the new signal bus object. However, this is now deprecated and a warning will be emitted when a tango operation is scheduled for execution on the signal bus object. Rationale ^^^^^^^^^ As the :py:func:`!ExecutePendingOperations` command is polled at 100ms it adds, on average, a 50ms delay in pushing an event from the device. This undermines the goal of having an event driven Tango device. Due to this, :py:func:`!ExecutePendingOperations` was removed in favour of the signal bus object. Supporting the calling of Tango operations from non-omnithreads leads to subtle differences in behaviour for functions which call these operations, depending on the context where they are being called. This can lead to difficult to track down bugs, specifically around locking in a multi-threaded application. It is more straight forward to deal with these issues if developers explicitly opt-in to having code running on a separate thread by using the signal bus directly. Preparing for removal ^^^^^^^^^^^^^^^^^^^^^ All threads which wish to invoked Tango operations should use the :py:class:`!tango.EnsureOmniThread` context manager to make the thread an omnithread. This should be done *exactly once*, i.e. the :code:`with` statement should wrap the entire function that runs on the thread. This includes threads spawned by a component manager if the thread calls callbacks from the Tango device which ends up calling :py:meth:`~tango.LatestDeviceImpl.push_change_event()`. The :py:class:`~ska_tango_base.poller.poller.Poller` class has been updated to ensure that the polling thread is an omni-thread. Similarly, the :py:class:`~ska_tango_base.executor.executor.TaskExecutor` has been updated to use the :py:class:`!tango.utils.PyTangoThreadPoolExecutor` when using pytango>=10.0.0, which uses omnithreads. If running against an older version of pytango, calling tango operations from a task scheduled with the :py:class:`~ska_tango_base.executor.executor.TaskExecutor` will emit a deprecation warning unless the call is wrapped in :code:`with tango.EnsureOmniThread()`. However, note that calling :py:class:`!tango.EnsureOmniThread` multiple times in the same thread is bad practice, so to avoid these warnings you should aim to update your pytango dependency to version 10.0.0 or later. For example, the following component manager uses an omnithread because its Tango device has overridden :py:meth:`!ska_tango_base.base.base_device.SKABaseDevice._component_state_changed` so that :py:meth:`!_update_component_state` ends up calling :py:meth:`~tango.LatestDeviceImpl.push_change_event()`. .. code:: python import ska_tango_base as stb ... class MyComponentManager(stb.base.BaseComponentManager): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.thread = threading.Thread(target=self.run) self.thread.start() def run(self): with tango.EnsureOmniThread(): ... self._update_component_state(qux="foo") .. note:: The default :py:meth:`!ska_tango_base.base.base_device.SKABaseDevice._component_state_changed` and :py:meth:`!ska_tango_base.base.base_device.SKABaseDevice._communication_state_changed` callbacks no longer call Tango operations, instead they schedule them to be called by the signal bus at some point later. However, if your Tango device is overriding :py:meth:`!ska_tango_base.base.base_device.SKABaseDevice._component_state_changed` it may be calling :py:meth:`tango.LatestDeviceImpl.push_change_event` directly. Alternatively, you could ensure that a thread does not need to call any Tango operations using the signal bus object. For example, you can use :py:func:`~ska_tango_base.software_bus.attribute_from_signal` to automatically push events from a separate thread whenever a value is emitted for the :py:func:`~ska_tango_base.software_bus.Signal` data descriptor on the component manager: .. code:: python import ska_tango_base as stb ... class MyComponentManager(stb.software_bus.SharingObserver, stb.BaseComponentManager): qux = stb.software_bus.Signal[int]() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.thread = threading.Thread(target=self.run) self.thread.start() def run(self): ... self.qux = 1 class MyDevice(stb.SKABaseDevice[MyComponentManager]): quxAttr = stb.software_bus.attribute_from_signal("_component_manager.qux", dtype=int) ... .. _migrate-1-4-cmd-obj: SKA command objects deprecated ------------------------------ The ska-tango-base release 1.4.0 has deprecated the SKA command objects for implementing Tango commands. Specifically, the :py:func:`~ska_tango_base.base.base_device.SKABaseDevice.register_command_object` and :py:func:`~ska_tango_base.base.base_device.SKABaseDevice.get_command_object` functions are deprecated and will emit a warning when called. In order maintain backwards compatibility the default implementations of most standard commands still use the command objects and will emit a deprecation warning if the command has not been overridden via a new mechanism. In general, teams are required to override the :py:func:`!execute_` methods to provide implementations which don't use the command objects to address this deprecation warning. The exception to this is the :py:meth:`!Abort()` command which will not emit a deprecation warning unless overridden via the command object mechanism. To modify the behaviour of the :py:meth:`!Abort()` command, teams should override the :py:func:`~ska_tango_base.long_running_commands.mixin.AbstractLRCMixin.schedule_abort_task` method. As a corollary, the :py:class:`~ska_tango_base.base.base_device.SKABaseDevice.InitCommand` command object has been deprecated and will emit a warning if used. In order to acknowledge the deprecation, set :code:`InitCommand = None` for your device and override the :py:meth:`!init_device()` method to change initialisation behaviour. Your :py:meth:`!init_device()` must call :py:class:`~ska_tango_base.base.base_device.SKABaseDevice.init_completed()` once initialisation has been completed. This may happen asynchronously if you have a long running initialisation process. See :ref:`init-guidelines` for more details. Rationale ^^^^^^^^^ The SKA command objects introduce a lot of boilerplate in order to implement a command. More over, the current implementation does not handle managing the :py:func:`!is__allowed` methods correctly for long running commands, nor does it handle transitioning the :py:class:`!ObsState` correctly for the :py:class:`ska_tango_base.subarray.subarray_device.SKASubarray` when using long running commands. Fixing these issues requires a breaking change with regards to how commands are implemented, so we took the opportunity to simplify the API and reduce the amount of boiler plate required when using ska-tango-base. Preparing for removal ^^^^^^^^^^^^^^^^^^^^^ Standard SKA state transition commands """""""""""""""""""""""""""""""""""""" The behaviour of standard state transition commands should now be implemented by overriding the :py:meth:`!execute_` methods. For so-called fast commands, this is simply a case of moving the logic from your command object into the `execute_()` method. For long running commands there is a little more work to do. The most straight forward way to migrate a default :py:class:`~ska_tango_base.commands.SubmittedSlowCommand` is to use the new :py:meth:`~ska_tango_base.long_running_commands.mixin.AbstractLRCMixin.allocate_lrc()` and :py:meth:`~ska_tango_base.long_running_commands.mixin.AbstractLRCMixin.convert_submission_result_to_lrc_return()` methods to call the component manager method that submits the task directly. For example, the following :py:meth:`!execute_On()` will have the same behaviour as the default :py:class:`~ska_tango_base.commands.SubmittedSlowCommand` object: .. code:: python import ska_tango_base as stb ... class MyDevice(stb.SKABaseDevice[MyComponentManager]): ... @stb.long_running_commands.mark_long_running def execute_On(self) -> stb.type_hints.DevVarLongStringArrayType: command_id, task_callback = self.allocate_lrc("On") # WARNING: Unless handled by the component manager, this will not call # the is allowed method when the task is dequeued status, message = self.component_manager.on(task_callback) return self.convert_submission_result_to_lrc_return(command_id, status, message) The :py:func:`@mark_long_running ` decorator here tells the default :py:meth:`!is_On_allowed` method that we should not check the state machine when the Tango command is initially called. If the command is not long running, then this decorator should not be used, nor should we allocate a long running command with the call to :py:meth:`~ska_tango_base.long_running_commands.mixin.AbstractLRCMixin.allocate_lrc()`. Although the implementation above of :py:meth:`!execute_On` will not, incorrectly, check the state machine when the command is submitted, it will also not, correctly, check the state machine when the task is about to be executed, unless the component manager's :py:meth:`!on` method explicitly does this. This can be remedied by pulling the task submission machinery from the component manager into the Tango device using :py:meth:`~ska_tango_base.long_running_commands.mixin.AbstractLRCMixin.submit_lrc_task()`: .. code:: python import ska_tango_base as stb ... class MyComponentManager( stb.executor.TaskExecutorComponentManager, stb.base.BaseComponentManager ): ... def do_on( self, task_callback: stb.type_hints.TaskCallbackType, task_abort_event: threading.Event, ) -> None: ... # Now unused, as we are submitting the task from the Tango device def on( self, task_callback: stb.type_hints.TaskCallbackType | None = None ) -> tuple[scm.TaskStatus, str]: return self._task_executor.submit(self.do_on, task_callback=task_callback) class MyDevice(stb.SKABaseDevice[MyComponentManager]): ... @stb.long_running_commands.mark_long_running def execute_On(self) -> stb.type_hints.DevVarLongStringArrayType: command_id, task_callback = self.allocate_lrc("On") status, message = self.submit_lrc_task("On", self.component_manager.do_on, task_callback) return self.convert_submission_result_to_lrc_return(command_id, status, message) As this is still quite a lot of boiler plate, ska-tango-base provides the :py:func:`@submit_lrc_task ` decorator, which does the same as above: .. code:: python import ska_tango_base as stb ... class MyDevice(stb.SKABaseDevice[MyComponentManager]): ... @stb.long_running_commands.submit_lrc_task def execute_On(self) -> stb.type_hints.TaskFunctionType: return self.component_manager.do_on Here we are returning the component manager method to be scheduled as a long running command task. If our command accepts arguments, we need to wrap the component manager method in a closure to store the arguments. The decorator :py:func:`@validate_json_args ` can be used to create a method that accepts a single JSON string from a method that accepts multiple keyword arguments. For example, the following overrides the behaviour of the :py:func:`!AssignResources` command, which accepts its :py:obj:`!resources` arguments in a JSON encoded object: .. code:: python class MyDevice(stb.SKASubarray[MyComponentManager]): ... AssignResources_SCHEMA: dict[str, stb.type_hints.JSONData] = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://skao.int/ska-tango-base/ReferenceSkaSubarray_AssignResources.json", "title": "ska-tango-base ReferenceSkaSubarray AssignResources schema", "description": "Schema for ska-tango-base ReferenceSkaSubarray AssignResources command", "type": "object", "properties": { "resources": { "description": "Resources to assign", "type": "array", "items": {"type": "string"}, }, }, "required": ["resources"], } @stb.long_running_commands.submit_lrc_task @stb.validators.validate_json_args def execute_AssignResources( self, resources: list[str] ) -> stb.type_hints.TaskFunctionType: def task( task_callback: stb.type_hints.TaskCallbackType, task_abort_event: threading.Event, ) -> None: self.component_manager.do_assign( set(resources), task_callback=task_callback, task_abort_event=task_abort_event, ) return task Alternatively, for the :py:func:`!On()`, :py:func:`!Off()`, :py:func:`!Standby()` and :py:func:`!Reset()` commands you can remove these from the Tango interface when using :py:class:`~ska_tango_base.base.base_device.SKABaseDevice` or one of its subclasses by assigning :code:`None` to them at the class scope. For example, the following device disables its :py:func:`!Reset()` command: .. code:: python import ska_tango_base as stb ... class MyDevice(stb.SKASubarray[MyComponentManager]): Reset = None ... InitCommand command """"""""""""""""""" To acknowledge the :py:class:`!InitCommand` deprecation, users of :py:class:`~ska_tango_base.base.base_device.SKABaseDevice` must assign :py:class:`!InitCommand` to :code:`None` at class scope and provide an override for :py:func:`!init_device`. The :py:func:`!init_device` override must call :py:func:`~ska_tango_base.base.base_interface.BaseInterface.init_completed` once initialisation has finished. For example, the following device does the minimum required to acknowledge the deprecation and suppress the warning: .. code:: python import ska_tango_base as stb ... class MyDevice(stb.SKASubarray[MyComponentManager]): InitCommand = None def init_device(self) -> None: super().init_device() self.init_completed() .. warning:: When migrating your :py:meth:`!InitCommand.do` method, note that you will be asking the Tango device as :code:`self._device`, however, in the :py:meth:`!init_device()` method the Tango device is just :code:`self`. Abort command """"""""""""" A deprecation warning will be emitted for subclasses that are overriding the :py:func:`!Abort` command using the command object mechanism. Classes wishing to modify the behaviour of abort should now override the :py:meth:`~ska_tango_base.long_running_commands.mixin.AbstractLRCMixin.schedule_abort_task` method. This method receives a :py:func:`!task_callback` constructed via :py:func:`~ska_tango_base.long_running_commands.mixin.AbstractLRCMixin.allocate_lrc` and must start the abort process asynchronously and return :py:obj:`!TaskStatus.IN_PROGRESS` immediately. The abort process must include a call to :code:`self.task_executor.abort`. When using :py:class:`~ska_tango_base.base.base_device.SKABaseDevice` the default implementation of :py:meth:`~ska_tango_base.base.base_device.SKABaseDevice.schedule_abort_task` will call the component manager's :py:meth:`~ska_tango_base.base.base_component_manager.BaseComponentManager.abort` method. Typically, in this case, all that is required to change the behaviour of the :py:func:`!Abort` command is to override this method on the component manager. User-defined Tango commands """"""""""""""""""""""""""" For user-defined commands, the :py:func:`@long_running_command ` decorator can be used instead of :py:func:`!@tango.server.command` to create a long running command from a task. For example, the following device defines the long running command :py:func:`!Qux()`: .. code:: python import ska_tango_base as stb class MyDevice(SKABaseDevice[MyComponentManager]): ... Qux_SCHEMA: dict[str, JSONData] = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "artefact.skao.int/mylrc.schema.json", "title": "Qux schema", "description": "Validates the keyword arguments", "type": "object", "properties": { "foo": {"type": "string"}, "bar": {"type": "number"}, }, } @stb.long_running_commands.long_running_command @stb.validators.validate_json_arg def Qux(self, foo: str, bar: int) -> TaskFunctionType: def task(task_callback, task_abort_event) -> int: return self.component_manager.do_qux( foo, bar, task_callback=task_callback, task_abort_event=task_abort_event ) return task When using the :py:class:`~ska_tango_base.subarray.subarray_interface.SubarrayInterface` or its child :py:class:`~ska_tango_base.subarray.subarray_device.SKASubarray`, the :py:attr:`~ska_tango_base.subarray.subarray_interface.AbstractSubarrayInterface.obs_state_model` actions to transition the :py:class:`!ObsState` are performed automatically at the start and end of each command when using the :py:func:`~ska_tango_base.long_running_commands.decorators.submit_lrc_task`/:py:func:`~ska_tango_base.long_running_commands.decorators.long_running_command` decorators. .. note :: The component-oriented actions must still be called explicitly at the appropriate time in the :py:func:`!execute_` implementations if a component manager is not used to update component states. The following device does not use a component manager and overrides the :py:func:`!AssignResources` command, using the :py:func:`@TaskExecutor.task ` decorator to simplify the task implementation. The task calls :py:func:`~ska_tango_base.subarray.subarray_interface.AbstractSubarrayInterface.component_resourced()` to indicate to the obs state machine that the :py:func:`!AssignResources` command successfully assigned resources: .. code:: python import ska_tango_base as stb import ska_control_model as scm class MyDevice(stb.subarray.SubarrayInterface): ... AssignResources_SCHEMA: dict[str, stb.type_hints.JSONData] = { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://skao.int/ska-tango-base/ReferenceSkaSubarray_AssignResources.json", "title": "ska-tango-base ReferenceSkaSubarray AssignResources schema", "description": "Schema for ska-tango-base ReferenceSkaSubarray AssignResources command", "type": "object", "properties": { "resources": { "description": "Resources to assign", "type": "array", "items": {"type": "string"}, }, }, "required": ["resources"], } @stb.long_running_commands.submit_lrc_task @stb.validators.validate_json_args def execute_AssignResources(self, resources: list[str]) -> stb.type_hints.TaskFunctionType: @stb.executor.TaskExecutor.task def task( progress_callback: stb.type_hintsProgressCallbackType, task_abort_event: threading.Event ) -> tuple[ResultCode, str]: ... self.component_resourced() return scm.ResultCode.OK, "AssignResources successful" return task Module restructuring -------------------- The 1.4.0 release has reorganised the module structure of ska-tango-base. All previously valid import statements are still valid, however, some modules have been deprecated and will emit a deprecation warning when imported. In addition, importing some objects from particular modules is deprecated and will emit a deprecation warning. An overview of all the deprecated modules can be found in :ref:`deprecated-modules`. Each deprecation warning provides directions as to where the object can now be found. In some cases, the object has a new name and this will be explained in the deprecation warning. In the case of :py:class:`~ska_tango_base.base.command_tracker.CommandTracker` and :py:class:`!LrcSubscriptions` the deprecation warning only provides protocols as alternatives. These protocols are intended to be used in type annotations and are designed to hide the concrete type that is created by ska-tango-base. In a future release, the concrete types will become private and changes to them will not be considered breaking. .. note :: The public :py:class:`~ska_tango_base.base.command_tracker.CommandTracker` concrete implementation is, in fact, no longer used internally for this release of ska-tango-base. Instead there is already an alternative private :py:class:`!_CommandTracker` implementation that is being used instead. This new implementation models the same :py:class:`~ska_tango_base.type_hints.CommandTrackerProtocol`, however, it has different construction requirements so the original :py:class:`~ska_tango_base.base.command_tracker.CommandTracker` is provided for compatibility, in case teams were using the concrete object themselves. In the case of the :py:class:`~ska_tango_base.long_running_commands_api.LrcSubscriptions` class, we now use a new :py:class:`!_LRCSubscriptions` name internally, but provide the :py:class:`~ska_tango_base.long_running_commands_api.LrcSubscriptions` name as an alias for backwards compatibility. Rationale ^^^^^^^^^ As the ska-tango-base package has evolved the organisation of modules has become somewhat messy and the restructuring is an effort to improve this. The use of protocols for :py:class:`~ska_tango_base.base.command_tracker.CommandTracker` and :py:class:`~ska_tango_base.long_running_commands_api.LrcSubscriptions` is motivated by the desire to avoid excess coupling between ska-tango-base and users of ska-tango-base and to express more clearly what use cases of the object are intended to be supported going forwards. This, in turn, allows the evolution of ska-tango-base in the future without breaking users. Preparing for removal ^^^^^^^^^^^^^^^^^^^^^ To prepare for the removal of the public :py:class:`~ska_tango_base.base.command_tracker.CommandTracker` and :py:class:`~ska_tango_base.long_running_commands_api.LrcSubscriptions` update type annotations to use the protocols :py:class:`ska_tango_base.type_hints.CommandTrackerProtocol` and :py:class:`ska_tango_base.type_hints.LRCSubscriptionsProtocol` respectively. If you have a use case other than a type annotation, please get in touch with the maintainers of ska-tango-base and we can provide a mechanism to support your use case. Extending attributes -------------------- The 1.4.0 release introduced a prescribed way to change the behaviour of most standard attributes provided by the base classes. The attributes which allow this are namely: - :py:attr:`BaseInterface.adminMode ` - :py:attr:`BaseInterface.commandedState ` - :py:attr:`BaseInterface.healthState ` - :py:attr:`SKABaseDevice.controlMode ` - :py:attr:`SKABaseDevice.simulationMode ` - :py:attr:`SKABaseDevice.testMode ` - :py:attr:`ControllerInterface.availableCapabilities ` - :py:attr:`ControllerInterface.maxCapabilities ` - :py:attr:`ObsInterface.obsState ` - :py:attr:`ObsInterface.commandedObsState ` - :py:attr:`ObsInterface.obsMode ` - :py:attr:`ObsInterface.configurationProgress ` - :py:attr:`ObsInterface.configurationDelayExpected ` - :py:attr:`SubarrayInterface.activationTime ` Before 1.4.0, it was possible to override standard attributes and call the "super" implementation by accessing the superclass's version of the attribute. This pattern is uncommon and was never officially documented anywhere. For example: .. code:: python import tango import ska_tango_base as stb class MyDevice(stb.SKABaseDevice): ... @tango.server.attribute def adminMode(self) -> AdminMode: """Only overriding in order to override the setter.""" return stb.SKABaseDevice.adminMode.fget(self) @adminMode.write def adminMode(self, value: AdminMode) -> None: """Set the Admin Mode of the device.""" stb.SKABaseDevice.adminMode.fset(self, value) # Do something else whenever the adminMode is changed ... Doing the above will not work with release 1.4.0 for technical reasons we will not cover here. As such, it is considered an unintentional breaking change. Fortunately, release 1.4.0 introduced a simpler, official mechanism to extend the behaviour of these attributes by overriding their corresponding :meth:`!read_` or, if provided, :meth:`!write_` methods. As this is standard method overriding, the usual ``super().read_()`` method is available. For example, to achieve the above behaviour with 1.4.0: .. code:: python import ska_tango_base as stb class MyDevice(stb.SKABaseDevice): ... def write_adminMode(self, value: AdminMode) -> None: """Set the Admin Mode of the device.""" super().write_adminMode(value) # Do something else whenever the adminMode is changed ... .. note:: If you need to completely override an attribute with a custom implementation, please get in touch with the maintainers of ska-tango-base so we can consider supporting your use case. Manually pushing attributes events ---------------------------------- Since before the 1.4.0 release, the :py:class:`~ska_tango_base.base.base_device.SKABaseDevice` has provided several :py:meth:`!_update_` methods for the subclass to call whenever they wish to update the corresponding attribute. Notably, these are: - :py:meth:`BaseInterface._update_admin_mode ` - :py:meth:`BaseInterface._update_state ` - :py:meth:`SKABaseDevice._update_health_state ` - :py:meth:`ObsInterface._update_obs_state ` Each of these methods updates the corresponding python attribute and pushes Tango events. Although it was intended that subclasses would call these functions to update the attributes, they unfortunately never appeared in the public documentation until release 1.4.2. In the 1.4.0 release, we changed how events were pushed for these attributes so that writing to the corresponding python attributes e.g. :py:attr:`BaseInterface._health_state ` results in events being pushed automatically. Unfortunately, this change means that events can end up being pushed multiple times after updating to ska-tango-base 1.4.0 in the following scenario: 1. The device does not use the :py:meth:`!_update_` method and instead pushes events itself manually. 2. The device calls :py:meth:`~tango.LatestDeviceImpl.set_change_event`/:py:meth:`~tango.LatestDeviceImpl.set_archive_event` itself with :code:`detect=False`. If neither of the above is true, then events will still be pushed normally. If your device satisfies both the above conditions then you can resolve the issue by updating to use the :py:meth:`!_update_` method.