from __future__ import annotations
import traceback
from typing import Any, TypeVar, final
from collections.abc import Callable, Mapping
import json
import pydantic
import copy
import weakref
import threading
from jsonschema import validate
import warnings
from . import host
from .datapoints import (
    DataPoint,
    NumericDataPoint,
    StringDataPoint,
    ObjectDataPoint,
    BytesDataPoint,
)

# type alias


DeviceClassTypeVar = TypeVar("DeviceClassTypeVar", bound="Device")


_devices_list_lock = threading.RLock()

all_devices: dict[str, weakref.ref[Device]] = {}
_all_devices: dict[str, weakref.ref[Device]] = {}


def _key_to_title(k: str) -> str:
    title = k
    if title.startswith("device."):
        title = title.split("device.", 1)[-1]
    title = title.replace("_", " ").title()
    return title


@final
class DataRequest:
    pass


class Device:
    """represents exactly one "device".
    should not be used to represent an interface to a large collection, use
    one instance per device.


    Note that this is meant to be subclassed twice.
    Once by the actual driver, and again by the
    host application, to detemine how to handle calls
    made by the driver.
    """

    device_type: str = "Device"
    """Every device must have a unique device_type name"""

    # This represents either a long text readme or an absolute path beginning with / to such
    readme: str = ""

    config_schema: dict[str, Any] = {}
    """Schema defining the config"""

    upgrade_legacy_config_keys: dict[str, str] = {}
    """__init__ uses this to auto rename old config keys to new ones
        if your device renames things.  They are type coerced according
        to the schema too.
    """

    def __init__(
        self,
        config: dict[str, Any],
        **kw: Any,
    ):  # pylint: disable=unused-argument
        """

        The base class __init__ does nothing if
        called a second time, to simplify the complex
        inheritance.

        Attributes:

            title:
                Taken from config['title'] if possible, otherwise it is the name.

            config_schema:
                The JSON schema for the device, or else an empty dict.
                If a device uses the legacy config style, this will
                be autogenerated for you.

                Does not have to include non-device specific fields
                like "name" or "type".  Use get_full_schema() to get a
                full schema suitable for directly generating a UI.

            config:
                The current configuration of the device


        Args:
            config:
                Must contain a "type" field that matches the device type.
                The idea is that the config object has everything needed to make
                the device.

                Must contain a "name" field that is the name of the device

                Subdevice configuration must have is_subdevice: True in
                save files so the host does not try to create it by itself.


                All other fields must be optional, and a blank
                unconfigured device should be creatable with only the defaults
                in the schema.

                Reserved keys:
                    name
                    type
                    is_subdevice
                    title
                    description

                    extensions:
                        Frameworks may add stuff here that the device itself
                        is supposed to just ignore. Devices themselves should leave
                        this alone.

        """
        self.title = ""

        # Protects config data and subdevices list
        self.__config_lock = threading.RLock()

        self.__closing = False

        self.host: host.Host = host.get_host()

        self.host_data: dict[str, Any] = {}

        self.name = config["name"]

        self._config = copy.deepcopy(config)

        self.datapoint_getter_functions: dict[str, Callable] = {}

        # here is where we keep track of our list of
        # sub-devices for each device.
        # Sub-devices will always have a name like
        # ParentDevice.ChildDevice
        self._subdevices: dict[str, Device] = {}

        self.metadata: dict[str, Any] = {}
        """This allows us to show large amounts of data that
        do not warrant a datapoint, as it is unlikely anyone
        would want to show them in a main listing,
        and nobody wants to see them clutter up anything
        or slow down the system when they change.
        Putting data here should have no side effects."""

        # Raise error on bad data.
        json.dumps(config)

        # Use the defaults
        if self.config_schema:
            validate(config, self.get_full_schema())

        self._config: Mapping[str, Any] = config
        """This dict is the signle source of truth for the configuration.
            Device and host must both be aware that the other side may change
            this.

            Must be immutable!
        """

        self.title: str = _key_to_title(
            self._config.get("title", "").strip() or self.name
        )
        """Title for UI display"""

        self.datapoints: dict[str, DataPoint] = {}

        """Device instanes all have unique names not shared with anything
        else in that host."""

        with _devices_list_lock:
            global all_devices
            _all_devices[self.name] = weakref.ref(self)
            all_devices = copy.deepcopy(_all_devices)

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__} {self.title} ({self.name})>"

    @property
    def subdevices(self) -> Mapping[str, Device]:
        """Immutable snapshot of subdevices.  Any changes will not
        affect the device itself"""
        return dict(self._subdevices)
    
    @final
    @property
    def config(self) -> Mapping[str, Any]:
        """Immutable snapshot of config.  Any changes will not
        affect the device itself, you must use update_config for that.
        """
        return copy.deepcopy(self._config)

    @property
    def is_subdevice(self) -> bool:
        """True if this is a subdevice, as determine by the is_subdevice key in the config"""

        x = self._config.get("is_subdevice", False)
        if not isinstance(x, bool):
            warnings.warn("is_subdevice must be boolean!", DeprecationWarning)
        return x in (
            True,
            "true",
            "yes",
            "on",
            "enable",
            "active",
            "enabled",
            "1",
            "True",
        )

    @final
    def get_full_schema(self) -> dict[str, Any]:
        """Returns a full normalized schema of the device. Including
        generic things all devices should have.
        """
        d = copy.deepcopy(self.config_schema)
        if "properties" not in d:
            d["properties"] = {}
        assert "properties" in d
        assert isinstance(d["properties"], dict)

        d["type"] = "object"

        if not self.config_schema and hasattr(self, "config"):
            for i in self._config:
                if i not in d["properties"]:
                    v = self._config[i]
                    title = _key_to_title(i)
                    if isinstance(v, bool):
                        d["properties"][i] = {"type": "boolean", "title": title}
                    elif isinstance(v, (int, float)):
                        d["properties"][i] = {"type": "integer", "title": title}
                    elif isinstance(v, str):
                        d["properties"][i] = {"type": "string", "title": title}
                    elif isinstance(v, list):
                        d["properties"][i] = {"type": "array", "title": title}
                    else:
                        d["properties"][i] = {"type": "object", "title": title}

        if "is_subdevice" in self._config and self._config["is_subdevice"]:
            d["properties"]["is_subdevice"] = {"type": "boolean", "const": True}
        else:
            d["properties"].pop("is_subdevice", None)  # type: ignore
        d["properties"]["name"] = {
            "type": "string",
            "title": "Name",
            "propertyOrder": -1,
        }
        d["properties"]["type"] = {
            "type": "string",
            "title": "Type",
            "readOnly": True,
            "propertyOrder": -1,
        }
        d["properties"]["title"] = {
            "type": "string",
            "title": "Title",
            "propertyOrder": -1,
        }
        d["properties"]["extensions"] = {
            "type": "object",
            "title": "Extensions",
            "options": {"collapsed": True},
            "propertyOrder": 1000000,
        }

        d["properties"]["notes"] = {
            "type": "string",
            "format": "markdown",
            "title": "Notes on this specific device instance",
            "propertyOrder": 1000000,
        }
        return d

    @final
    def close_subdevice(self, name: str):
        """Close and deletes a subdevice without permanently deleting
        any config associated with it.  Should only be called by the
        device itself.
        """
        self._subdevices[name].close()
        with self.__config_lock:
            del self.subdevices[name]

    @final
    def create_subdevice(
        self, cls: type[DeviceClassTypeVar], name: str, config: dict[str, Any]
    ) -> DeviceClassTypeVar:
        """Creates a subdevice
        Args:
            cls: The class used to make the device
            name: The base name of the device.  The full name will be parent.basename
                but you only supply the base name here.

            config: The config as would be passed to any other device, which the host may override.

        Returns:
            The device object


        Allows a device to create it's own subdevices.

        The host implementation must take the class, make whatever subclass
        is needed based on it, Then instantiate it as if the other parameters were given straight to
        the device, overriding them with any user config that is known by the host.

        The host will put the device object into the parent device's subdevice
        dict. Alll subdevices must be closed before the parent.

        The host will rename the device to reflect that it is a subdevice.
        It's full name will be parent.basename.

        The host will allow configuration of the device like any other device.
        It will override whatever config that you give this function
        with the user config.

        Once the subdevice exists, the host cannot close it, that is the responsibility
        of the main device.  The host can only close the parent device.

        it must update the config in place if the user wants to make changes,
        using update_config()

        When closing a device, the device must close all of it's subdevices and
        clean up after itself.  The default close() does this for you.

        The entry in self.subdevices will always be exactly as given
        to this function, referred to as the base name

        The host will add is_subdevice=True to the config dict.
        """

        with self.__config_lock:
            if name in self._subdevices:
                raise ValueError(f"Subdevice {name} already exists")
            # Placeholder to reserve the name
            self._subdevices[name] = None

        try:
            fn = self.name
            config = copy.deepcopy(config)

            # Mostly just there for quick scripts
            if "subdevice_config" in self._config:
                if name in self._config["subdevice_config"]:
                    config.update(copy.deepcopy(self._config["subdevice_config"][name]))

            config["name"] = fn + "/" + name
            config["is_subdevice"] = True
            config["type"] = cls.device_type
            config["is_subdevice"] = True

            sd = self.host.add_device_from_class(cls, config, parent=self)
            with self.__config_lock:
                self._subdevices[name] = sd.device

            return sd.device
        except Exception:
            with self.__config_lock:
                # Delete placeholder so we can try again
                if name in self._subdevices and self._subdevices[name] is None:
                    del self._subdevices[name]
            raise

    @final
    def get_config_folder(self, create: bool = True):
        """
        Devices may, in some frameworks, have their own folder in which they can place additional
        configuration, allowing for advanced features that depend on user content.

        Returns:
            An absolute path
        """

        return self.host.get_config_folder(self, create)

    @staticmethod
    def discover_devices(
        config: dict[str, Any],  # pylint: disable=unused-argument
        current_device: object | None = None,  # pylint: disable=unused-argument
        intent: str = "",  # pylint: disable=unused-argument
        **kwargs: Any,  # pylint: disable=unused-argument
    ) -> dict[str, dict[str, Any]]:
        """
        Discover a set of suggested configs that could be used to build a new device.

        Not required to be implemented and may just return {}

        ***********************
        Discovered suggestions MUST NOT have any passwords or secrets
        if the suggestion would cause them to be tried somewhere
        other than what the user provided them for,
        unless the protocol does not actually reveal the secrets
        to the server.

        You do not want to autosuggest trying the same credentials
        at bad.com that the user gave for example.com.


        The suggested UI semantics for discover commands is
        "Add a similar device" and "Reconfigure this device".

        Reconfiguration should always be available as the user
        might always want to take an existing device object and
        swap out the actual physical device it connects to.

        Kwargs is reserved for further hints on what kinds of
        devices should be discovered.

        Args:
            config: You may pass a partial config, or a completed
                config to find other
                similar devices. The device should reuse as much
                of the given config as possible and logical,
                discarding anything that wouldn't  work with the
                selected device.

            current_device: May be set to the current version of a
                device, if it is being used in a UI along the lines of
                suggesting how to further set up a partly configured
                device, or suggesting ways to add another
                similar device.

            kwargs: is reserved for further hints on what kinds
                of devices should be discovered.


            intent: may be a hint as to what kind of config you are
                    looking for.
                If it is "new", that means the host wants to add
                another similar device.  If it is "replace",
                the host wants to keep the same config
                but point at a different physical device.

                If it is "configure",  the host wants to look
                for alternate configurations available for the
                same exact device.

                If it is "step", the user wants to refine
                the existing config.

        Returns:
            A dict of device data dicts that could be used
            to create a new device, indexed by a descriptive name.

        """

        return {}

    @final
    def set_config_default(self, key: str, value: str):
        """sets an top-level option in self.config if it does not exist or is blank."""
        with self.__config_lock:
            if (
                key not in self._config
                or (
                    isinstance(self._config[key], str) and not self._config[key].strip()
                )
                or self._config[key] is None
            ):
                self.set_config_option(key, value)

    @final
    def set_config_option(self, key: str, value: Any):
        """sets an top-level option in self.config."""

        json.dumps(value)

        with self.__config_lock:
            x = dict(copy.deepcopy(self._config))
            x[key] = value

        if self.config_schema:
            validate(x, self.get_full_schema())

        self.update_config(x)

    def wait_ready(self, timeout: float = 15):  # pylint: disable=unused-argument
        """Call this to block for up to timeout seconds for the device to be fully initialized.
        Use this in quick scripts with a devices that readies itself asynchronously.

        May be implemented by the device, but is not required.
        """
        warnings.warn("This device does not implement wait_ready", DeprecationWarning)

    def print(self, s: str, title: str = ""):
        """used by the device to print to the hosts live device message feed, if such a thing should happen to exist"""
        self.host.on_device_print(self.get_host_container(), s, title)

    @final
    def handle_error(self, s: str, title: str = ""):
        """like print but specifically marked as error. may get special notification.  should not be used for brief network loss"""
        self.host.on_device_error(self.get_host_container(), s)

    @final
    def handle_exception(self):
        "Helper function that just calls handle_error with a traceback."
        try:
            self.host.on_device_exception(self.get_host_container())
        except Exception:
            print(traceback.format_exc())  # pragma: no cover

    def handle_event(self, event: str, data: Any | None):
        "Handle arbitrary messages from the host"

    @final
    @pydantic.validate_call
    def numeric_data_point(
        self,
        name: str,
        *,
        min: float | None = None,
        max: float | None = None,
        hi: float | None = None,  # pylint: disable=unused-argument
        lo: float | None = None,  # pylint: disable=unused-argument
        default: float | None = None,
        description: str = "",  # pylint: disable=unused-argument
        unit: str = "",  # pylint: disable=unused-argument
        handler: Callable[[float, float, Any], Any] | None = None,
        interval: float = 0,  # pylint: disable=unused-argument
        subtype: str = "",  # pylint: disable=unused-argument
        writable: bool = True,  # pylint: disable=unused-argument
        dashboard: bool = True,  # pylint: disable=unused-argument
        on_request: Callable[[DataRequest], Any] | None = None,
        **kwargs: Any,  # pylint: disable=unused-argument
    ) -> NumericDataPoint:
        """Register a new numeric data point with the given properties.

        Handler will be called when it changes.
        self.datapoints[name] will start out with tha value of None

        The intent is that you can subclass this and have your own implementation of data points,
        such as exposing an MQTT api or whatever else.

        Most fields are just extra annotations to the host.

        Args:
            min: The min value the point can take on
            max: The max value the point can take on

            hi: A value the point can take on that would be
                considered excessive
            lo: A value the point can take on that would be
                considered excessively low

            description: Free text

            unit: A unit of measure, such as "degC" or "MPH"

            default: If unset default value is None,
                or may be framework defined. Default does not trigger handler.

            handler: A function taking the value,timestamp,
                and annotation on changes.  Must be threadsafe.

            interval :annotates the default data rate the point
                will produce, for use in setting default poll
                rates by the host, if the host wants to poll.
                It does not mean the host SHOULD poll this,
                it only suggest a rate to poll at if the host
                has an interest in this data.

            writable:  is purely for a host that might subclass
                this, to determine if it should allow writing to the point.

            subtype: A string further describing the data
                type of this value, as a hint to UI generation.

            dashboard: Whether to show this data point in overview displays.

            on_request: If set, will be called when the host
                requests the value of this datapoint.  Must be threadsafe.

        """

        if min is None:
            minval: float = -(10**24)
        else:
            minval = min

        if max is None:
            maxval: float = 10**24
        else:
            maxval = max

        self.host.numeric_data_point(
            self.name,
            name,
            min=minval,
            max=maxval,
            default=default,
            description=description,
            unit=unit,
            handler=handler,
            interval=interval,
            subtype=subtype,
            writable=writable,
            dashboard=dashboard,
            **kwargs,
        )

        if on_request is not None:
            self.datapoint_getter_functions[name] = on_request

        dp = NumericDataPoint(self, name)
        self.datapoints[name] = dp
        return dp

    @final
    @pydantic.validate_call
    def string_data_point(
        self,
        name: str,
        *,
        description: str = "",  # pylint: disable=unused-argument
        unit: str = "",  # pylint: disable=unused-argument
        handler: Callable[[str, float, Any], Any] | None = None,
        default: str | None = None,
        interval: float = 0,  # pylint: disable=unused-argument
        writable: bool = True,  # pylint: disable=unused-argument
        subtype: str = "",  # pylint: disable=unused-argument
        dashboard: bool = True,  # pylint: disable=unused-argument
        on_request: Callable[[DataRequest], Any] | None = None,
        **kwargs: Any,  # pylint: disable=unused-argument
    ) -> StringDataPoint:
        """Register a new string data point with the given properties.

        Handler will be called when it changes.
        self.datapoints[name] will start out with tha value of None

        Interval annotates the default data rate the point will produce, for use in setting default poll
        rates by the host, if the host wants to poll.

        Most fields are just extra annotations to the host.


        Args:
            description: Free text

            default: If unset default value is None, or may be framework defined. Default does not trigger handler.

            handler: A function taking the value,timestamp, and annotation on changes. Must be threadsafe.

            interval: annotates the default data rate the point will produce, for use in setting default poll
                rates by the host if the host wants to poll.

                It does not mean the host SHOULD poll this,
                it only suggest a rate to poll at if the host has an interest in this data.

            writable:  is purely for a host that might subclass this, to determine if it should allow writing to the point.

            subtype: A string further describing the data type of this value, as a hint to UI generation.

            dashboard: Whether to show this data point in overview displays.

            on_request: If set, will be called when the host
                requests the value of this datapoint.  Must be threadsafe."""

        self.host.string_data_point(
            self.name,
            name,
            default=default,
            description=description,
            unit=unit,
            handler=handler,
            interval=interval,
            subtype=subtype,
            writable=writable,
            dashboard=dashboard,
            **kwargs,
        )

        if on_request is not None:
            self.datapoint_getter_functions[name] = on_request
        dp = StringDataPoint(self, name)
        self.datapoints[name] = dp
        return dp

    @final
    @pydantic.validate_call
    def object_data_point(
        self,
        name: str,
        *,
        description: str = "",  # pylint: disable=unused-argument
        unit: str = "",  # pylint: disable=unused-argument
        handler: Callable[[Mapping[str, Any], float, Any], Any] | None = None,
        interval: float = 0,  # pylint: disable=unused-argument
        writable: bool = True,  # pylint: disable=unused-argument
        subtype: str = "",  # pylint: disable=unused-argument
        dashboard: bool = True,  # pylint: disable=unused-argument
        default: Mapping[str, Any] | None = None,
        on_request: Callable[[DataRequest], Any] | None = None,
        **kwargs: Any,  # pylint: disable=unused-argument
    ) -> ObjectDataPoint:
        """Register a new object data point with the given properties.   Here "object"
        means a JSON-like object.

        Handler will be called when it changes.
        self.datapoints[name] will start out with tha value of None

        Interval annotates the default data rate the point will produce, for use in setting default poll
        rates by the host, if the host wants to poll.

        Most fields are just extra annotations to the host.

        Args:
            description: Free text

            handler: A function taking the value,timestamp, and annotation on changes. Must be thread safe.

            interval :annotates the default data rate the point will produce, for use in setting default poll
                rates by the host, if the host wants to poll.  It does not mean the host SHOULD poll this,
                it only suggest a rate to poll at if the host has an interest in this data.

            writable:  is purely for a host that might subclass this, to determine if it should allow writing to the point.

            subtype: A string further describing the data type of this value, as a hint to UI generation.

            on_request: If set, will be called when the host
                requests the value of this datapoint.  Must be threadsafe.
        """
        self.host.object_data_point(
            self.name,
            name,
            handler=handler,
            description=description,
            unit=unit,
            interval=interval,
            subtype=subtype,
            writable=writable,
            dashboard=dashboard,
            **kwargs,
        )

        if on_request is not None:
            self.datapoint_getter_functions[name] = on_request

        dp = ObjectDataPoint(self, name)
        self.datapoints[name] = dp
        return dp

    @final
    @pydantic.validate_call
    def bytestream_data_point(
        self,
        name: str,
        *,
        description: str = "",  # pylint: disable=unused-argument
        unit: str = "",  # pylint: disable=unused-argument
        handler: Callable[[bytes, float, Any], Any] | None = None,
        writable: bool = True,  # pylint: disable=unused-argument
        dashboard: bool = True,  # pylint: disable=unused-argument
        on_request: Callable[[DataRequest], Any] | None = None,
        **kwargs: Any,  # pylint: disable=unused-argument
    ):
        """register a new bytestream data point with the
        given properties. handler will be called when it changes.
        only meant to be called from within __init__.

        Bytestream data points do not store data,
        they only push it through.

        Despite the name, buffers of bytes may not be broken up or combined, this is buffer oriented,

        on_request: If set, will be called when the host
            requests the value of this datapoint.  Must be threadsafe.
        """

        self.host.bytestream_data_point(
            self.name,
            name,
            handler=handler,
            description=description,
            unit=unit,
            writable=writable,
            dashboard=dashboard,
            **kwargs,
        )

        if on_request is not None:
            self.datapoint_getter_functions[name] = on_request

        dp = BytesDataPoint(self, name)
        self.datapoints[name] = dp
        return dp

    @final
    def push_bytes(self, name: str, value: bytes):
        """Same as set_data_point but for bytestream data"""
        self.set_data_point(name, value)

    @final
    def set_data_point(
        self,
        name: str,
        value: int | float | str | bytes | Mapping[str, Any] | list[Any],
        timestamp: float | None = None,
        annotation: Any | None = None,
    ):
        """Callable by the device or by the host, thread safe."""
        self.datapoints[name].set(value, timestamp, annotation)

    def request_data_point(self, name: str):
        """Callable by the device or by the host, thread safe.
        Request that the data be updated at some future time.
        """
        self.datapoints[name].request()

    @pydantic.validate_call
    def set_alarm(
        self,
        name: str,
        datapoint: str,
        expression: str,
        priority: str = "info",
        trip_delay: float = 0,
        auto_ack: bool = False,
        release_condition: str | None = None,
        **kw: Any,
    ):
        """declare an alarm on a certain data point.
        means we should consider the data point to be in an
        alarm state whenever the expression is true.

        used by the device itself to tell the host what
        it considers to be an alarm condition.

        the expression must look like "value > 90", where
        the operator can be any of the common comparision
        operators(<,>,<=,>=,==,!= )

        you may set the trip delay to require it to stay
        tripped for a certain time,
        polling during that time and resettig if it is not
        tripped.

        the alarm remains in the alarm state till the release
        condition is met, by default it's just when the trip
        condition is inactive.  at which point it will need
        to be acknowledged by the user.


        these alarms should be considered "presets" that
        the user can override if possible.
        by default this function could just be a no-op,
        it's here because of kaithem_automation's alarm support,
        but many applications may be better off with full manual
        alarming.

        in kaithem the expression is arbitrary,
        but for this lowest common denominator definition
        it's likely best to
        limit it to easily semantically parsible strings.

        """
        self.host.set_alarm(
            self,
            name,
            f"{self.name}.{datapoint}",
            expression,
            priority,
            trip_delay,
            auto_ack,
            release_condition,
            **kw,
        )

    @final
    def close(self):
        """Close all subdevices and tell the host to remove this device"""
        with self.__config_lock:
            if self.__closing:
                return

            self.__closing = True

        try:
            self.on_before_close()
        except Exception:
            self.host.on_device_exception(self.get_host_container())

        with self.__config_lock:
            x = list(self._subdevices.keys())

        "Release all resources and clean up"
        for i in x:
            try:
                self._subdevices[i].close()
                with self.__config_lock:
                    del self._subdevices[i]
            except Exception:
                self.host.on_device_exception(self.get_host_container())
        del x

        self.host.close_device(self.name)

    def on_before_close(self):
        """
        Subclass defined cleanup handler.
        """

    def on_delete(self):
        """
        release all persistent resources, used by the host
        app to tell the user the device is being permanently
        deleted.
        may be used to delete any files automatically created.
        """

    @final
    def _update_config(self, config: dict[str, Any]):
        if not config["name"] == self.name:
            raise ValueError(
                f"Device name changed from {self.name} to {config['name']}"
            )

        if not config["type"] == self.device_type:
            raise ValueError(
                f"Device type changed from {self.device_type} to {config['type']}"
            )

        if self.config_schema:
            validate(config, self.get_full_schema())

        with self.__config_lock:
            if config == self._config:
                return
            config = copy.deepcopy(config)
            self._config = config
            self.title = self._config.get("title", "").strip() or self.name

        self.host.on_config_changed(self.get_host_container(), config)

    # Never call this under config lock
    @pydantic.validate_call
    def update_config(self, config: dict[str, Any]):
        """Update the config dynamically at runtime.
        May be subclassed by the device to respond to config changes.
        """
        self._update_config(config)

    @final
    def get_host_container(self):
        """Get the Host Data Container.  Only the host should call this,
        not the device itself, which should not need to know about
        the container objects.

        The only time the device should need to call this is when
        passing the value as a black box to the host.
        """
        return self.host.get_container_for_device(self)


class UnusedSubdevice(Device):
    description = "Someone created configuration for a subdevice that is no longer in use or has not yet loaded"
    device_type = "UnusedSubdevice"

    def warn(self):
        self.handle_error("This device's parent never properly set it up.'")

    def __init__(self, data):
        super().__init__(data)


__all__ = ["Device", "UnusedSubdevice", "DeviceClassTypeVar"]
