Repository: rytilahti/python-miio (language: python)

Issue:
A guide how to communicate with MIOT devices -> Plugin?
I was able to get the data from an unsupported device (HeatCold Heating Floor Controller - cubee.airrtc.th123e) and to set the required parameters from a Terminal on Mac. Can someone help with converting this into a working plugin for Home Assistant and then HomeKit?

Here is my step-by-step guide for all newcomers who want to play with their devices:
1) Install python-miio on Mac. Enter this in Terminal app on Mac:
`sudo pip3 install python-miio`

2) Get model name of your device ("cubee.airrtc.th123e" in my case), IP address (like "192.168.1.152") and Token (like "b110204d86732b019d3d6axxxxb9ad3a"). The easiest way for me was to use this integration for Home Assistant - https://github.com/AlexxIT/XiaomiGateway3

3) Check that your device is accessible by entering the following in a Terminal app on Mac:
`miiocli -d device --ip 192.168.1.152 --token b110204d86732b019d3d6axxxxb9ad3a info`
Note 1: You need to put your device's IP and Token here.
Note 2: "-d" is for Debug and can be excluded for a shorter output.
In my case I got something like this:
"Model: cubee.airrtc.th123e
Hardware version: esp32
Firmware version: 2.1.7"
Device is responding! Nice! Going to the next step.

4) Find your device in the list of MIOT devices: 
http://miot-spec.org/miot-spec-v2/instances?status=all
I searched for "cubee.airrtc.th123e" and found the following line:
`{"status":"released","model":"cubee.airrtc.th123e","version":1,"type":**"urn:miot-spec-v2:device:thermostat:0000A031:cubee-th123e:1"**},`

5) Copy the "urn" part after this url:
https://miot-spec.org/miot-spec-v2/instance?type=
in my case: https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:thermostat:0000A031:cubee-th123e:1
You will see an unformatted JSON text of the device's specs

6) Put this JSON text to any JSON Formatter to make it more human readable. For example here: http://json.parser.online.fr/

7) You will see a hierarchy with a list of Services (later used as "siid"), each of which has a list of Properties ("piid"):
<img width="411" alt="miot_json" src="https://user-images.githubusercontent.com/17302232/103826006-09ca6c00-5087-11eb-875a-e4b7dbd9fffb.png">

8) You need to find some property in the list which you want to get from the device and which is readable 
(in my case siid 1 didn't return the value, so you may want to check other services like "siid 2" and other properties)
I wanted to receive the current "Target Temperature". This is "siid":2 and "piid":5:
<img width="423" alt="miot_json_temp" src="https://user-images.githubusercontent.com/17302232/103826979-f9b38c00-5088-11eb-86a7-f690f9afae3f.png">

9) Try to get a response from your device by entering this in Terminal:
`miiocli -d device --ip 192.168.1.152 --token b110204d86732b019d3d6axxxxb9ad3a raw_command get_properties "[{'did': 'MYDID', 'siid': 2, 'piid': 5 }]"`
Note: replace IP, token, siid and piid with your values
I got a response the last line of which was:
"[{'did': 'MYDID', 'siid': 2, 'piid': 5, 'code': 0, **'value': 29**}]"
This was an actual Target Temperature set on my device: 29 degrees celsius! It works!

10) Now let's try to change some properties. Make sure it has a "write" access (look at the the field "access" for a particular piid in the JSON file mentioned above).
I want to set 28 degrees and here is the command for that:
`miiocli -d device --ip 192.168.1.152 --token b110204d86732b019d3d6axxxxb9ad3a raw_command set_properties "[{'did': 'MYDID', 'siid': 2, 'piid': 5, 'value' : 28 }]"`
The new temperature was set on my device and was also updated in MiHome app! 

11) Check that the value / setting was set on an actual device and redo step 9 to confirm that the new value is successfully returned by the device.

That's all! Now you can retrieve and set values from a Terminal. 
This was my part. I would be glad if someone describes how to further make a Home Assistant plugin based on this knowledge :)

_Originally posted by @SpeedFire0 in https://github.com/rytilahti/python-miio/issues/543#issuecomment-755767331_
babai.curtain.190812
add my babai.curtain.190812


Discussion / hints:
What is `'did': 'MYDID'` though?
> What is `'did': 'MYDID'` though?

I'm not sure, to be honest. In my case the word "MYDID" did the job (so I haven't replaced it with any other word or number).
The 'did' (device id, most likely) is something that gets returned back by the device for responses, but you can put any value to it. The current miot implementations are using that as a key to a user-friendly property name.

Thanks for the guide, the current documentation on that front is definitely lacking. There is a tool under `devtools` directory, which allows downloading the relevant json file based on the URN (that could be improved to allow downloading by the model):
```
$ python miottemplate.py download urn:miot-spec-v2:device:thermostat:0000A031:cubee-th123e:1
Saving data to urn:miot-spec-v2:device:thermostat:0000A031:cubee-th123e:1.json
```
And printing out the information:
```
$ python miottemplate.py print urn:miot-spec-v2:device:thermostat:0000A031:cubee-th123e:1.json
Device 'urn:miot-spec-v2:device:thermostat:0000A031:cubee-th123e:1': Thermostat with 3 services

* Service siid 1: (Device Information): 4 props, 0 actions

        ## Properties ##
                siid 1: piid: 1 (Device Manufacturer): (string, unit: None) (acc: ['read'])
                siid 1: piid: 2 (Device Model): (string, unit: None) (acc: ['read'])
                siid 1: piid: 3 (Device Serial Number): (string, unit: None) (acc: ['read'])
                siid 1: piid: 4 (Current Firmware Version): (string, unit: None) (acc: ['read'])

* Service siid 2: (Thermostat): 6 props, 0 actions

        ## Properties ##
                siid 2: piid: 1 (Switch Status): (bool, unit: None) (acc: ['read', 'write', 'notify'])
                siid 2: piid: 2 (Status): (uint8, unit: None) (acc: ['read', 'notify'])
                        {'value': 0, 'description': '未加热状态'}
                        {'value': 1, 'description': '加热中'}
                siid 2: piid: 3 (Device Fault): (uint8, unit: None) (acc: ['read', 'notify'])
                        {'value': 1, 'description': '传感器错误'}
                        {'value': 0, 'description': '无错误'}
                        {'value': 2, 'description': '高温保护'}
                        {'value': 3, 'description': '低温保护'}
                siid 2: piid: 4 (Mode): (uint8, unit: none) (acc: ['read', 'write', 'notify'])
                        {'value': 0, 'description': 'Manual'}
                        {'value': 1, 'description': 'Home'}
                        {'value': 2, 'description': 'Away'}
                        {'value': 3, 'description': 'Smart'}
                        {'value': 4, 'description': 'Sleep'}
                siid 2: piid: 5 (Target Temperature): (float, unit: celsius) (acc: ['read', 'write', 'notify'])
                        Range: [0, 90, 1]
                siid 2: piid: 7 (Temperature): (float, unit: celsius) (acc: ['read', 'notify'])
                        Range: [-30, 100, 1]

* Service siid 4: (): 7 props, 0 actions

        ## Properties ##
                siid 4: piid: 1 (): (bool, unit: None) (acc: ['read', 'write', 'notify'])
                siid 4: piid: 2 (): (uint8, unit: None) (acc: ['read', 'notify', 'write'])
                        {'value': 0, 'description': '内置传感器'}
                        {'value': 1, 'description': '外置传感器'}
                        {'value': 2, 'description': '内外置传感器'}
                siid 4: piid: 3 (): (uint8, unit: None) (acc: ['read', 'notify', 'write'])
                        Range: [1, 9, 1]
                siid 4: piid: 4 (): (int8, unit: None) (acc: ['read', 'notify', 'write'])
                        Range: [-9, 9, 1]
                siid 4: piid: 5 (): (int8, unit: None) (acc: ['read', 'notify'])
                        Range: [0, 100, 1]
                siid 4: piid: 6 (): (uint8, unit: celsius) (acc: ['read', 'notify'])
                        Range: [35, 90, 1]
                siid 4: piid: 7 (): (uint8, unit: celsius) (acc: ['read', 'notify'])
                        Range: [0, 30, 1]
```
I just modified the miottemplate to download the mapping file, if not available, to allow using `download` with only the model as input. `python miottemplate.py download <some model>` followed by `python miottemplate.py print <json file>` for the downloaded json file should do the trick and be much simpler than it used to be.
If you don't mind, please check out the linked PR and try `miiocli miotdevice get_property_by` and `set_property_by` commands. I cannot test these as I have no test devices anymore.

`get_property_by [siid] [piid]` and `set_property_by [siid] [piid] [value]` with an optional `[value_type]` (if the value needs to be other than a string).
@rytilahti `get_property_by` works but I'm getting only `'hello'` string for everything under `siid: 1` (Device Information):
```
$ miiocli miotdevice ... get_property_by 1 1
[{'did': '1-1', 'siid': 1, 'piid': 1, 'code': 0, 'value': 'hello'}]
```

`set_property_by` sort of works but `miiocli` sends wrong payload.  
I tested with my pet waterer from #897.

When I turn indicator light on using `set_property_by 4 1 'True' 'bool'` it turns on. It was off before I started.  
If I turn it off using `set_property_by 4 1 'False' 'bool'` it doesn't turn off.

When I run `miiocli -d miotdevice ... set_property_by 4 1 'False' 'bool'` (while indicator light is on):
```
DEBUG:miio.miioprotocol:192.168.xxx.xxx:xxxxx >>: {'id': 1, 'method': 'set_properties', 'params': [{'did': 'set-4-1', 'siid': 4, 'piid': 1, 'value': True}]}
```
If I use `set_property_by 4 1 'True' 'bool'` while indicator is on it doesn't turn it off.

Also there's number of "actions" in miot specs but I don't see how can I use them using `miiocli`.
I have found and modified an example of a simple switch for Home Assistant.
Here is the repo for an example in the guide: https://github.com/SpeedFire0/xiaomi_miot_heatcold
Device is successfully switches on/off both in Home Assistant and HomeKit. 
> @rytilahti `get_property_by` works but I'm getting only `'hello'` string for everything under `siid: 1` (Device Information):

Great! Yes, the siid1 (which is there for all miot devices, `miot_info` command is still missing to output that) was also hard-coded on the test device I had at one point (https://github.com/rytilahti/python-miio/pull/672#issuecomment-615930765). That could be the sdk default and vendors haven't changed it for a reason or another.

> If I turn it off using `set_property_by 4 1 'False' 'bool'` it doesn't turn off.

Try `set_property_by 4 1 False bool` or `set_property_by 4 1 0 bool`. I think it detects `'False'` as a string -> which is true when converted to bool.

I'll try add a way to call an action, thanks for noticing & testing!
@SpeedFire0 I think xiaomi_raw from @syssi is the proper way for quick, stop-gap solutions. That is, as soon as it gets miot support. You can follow this issue for that: https://github.com/syssi/xiaomi_raw/issues/6

The problem with custom components is that they deem to break when the official integration gets updated to use APIs that are not available for the python-miio versions pinned by custom integrations. In turn, the less custom components one has, the easier it is to do the manual adjustments to make it work again.
> Try `set_property_by 4 1 False bool` or `set_property_by 4 1 0 bool`. I think it detects `'False'` as a string -> which is true when converted to bool.

Both didn't work
Ah, indeed. `bool('0')` equates to True (as the input itself is a string). I'll try to find a solution for that, at worst bools will need to be inputed as 0/1 in the command line, and it'll be converted using `bool(int(value))`.
@rytilahti there's method for that in [distutils](https://docs.python.org/3/distutils/apiref.html#distutils.util.strtobool)
I tried to get commands from a Xiaomi camera and it returned the following error:
`Error: {'code': -10000, 'message': 'user undefined error'}`
Probably this is some sort of protection from unauthorised access. Is it possible to define my user credentials (in MiHome app?) and send it to the device?
which camera? most cams don't use miot, only a few latest models.
> which camera? most cams don't use miot, only a few latest models.
Outdoor camera - "chuangmi.camera.ipc020"
Indoor camera - "chuangmi.camera.ipc021"

these models talk miio only (unless something changed in latest fw). all devices have miot specs, but older devices never really deploy it in firmware and they still use human readable miio.
> these models talk miio only (unless something changed in latest fw). all devices have miot specs, but older devices never really deploy it in firmware and they still use human readable miio.

I didn't find any way how to control them (even the simplest function like on/off). Is it possible?

This is an example of "info" response:
`DEBUG:miio.miioprotocol:192.168.1.92:54321 >>: {'id': 1, 'method': 'miIO.info', 'params': []}
DEBUG:miio.miioprotocol:192.168.1.92:54321 (ts: 1970-02-20 21:26:43, id: 1) << {'id': 1, 'result': {'life': 4397203, 'model': 'chuangmi.camera.ipc016', 'token': '396a67493638696a6552324f6356664e', 'ipflag': 1, 'miio_ver': '0.0.8', 'mac': '78:8B:2A:9F:EA:02', 'fw_ver': '16.4.0.9_0213', 'hw_ver': 'Linux', 'bootloader_ver': 'a97a650aea304f0d2b08da11cab16439', 'miio_client_ver': '4.0.10', 'VmPeak': 6820, 'VmRSS': 1588, 'MemFree': 1988, 'ap': {'ssid': 'PointerDacha', 'bssid': '52:FF:20:5E:75:C0', 'rssi': '-53', 'freq': 2457}, 'netif': {'localIp': '192.168.1.92', 'mask': '255.255.255.0', 'gw': '192.168.1.1'}, 'miio_times': [4397197, 7, 5486, 4391660]}, 'exe_time': 2}
Model: chuangmi.camera.ipc016
Hardware version: Linux
Firmware version: 16.4.0.9_0213
`
https://github.com/rytilahti/python-miio/blob/master/miio/chuangmi_camera.py
> https://github.com/rytilahti/python-miio/blob/master/miio/chuangmi_camera.py

OK. But I feel myself as a complete idiot because I can't figure out how to use this in Home Assistant :(
Maybe someone will help with a guide in the future. 
> @rytilahti there's method for that in [distutils](https://docs.python.org/3/distutils/apiref.html#distutils.util.strtobool)

It's weird to add an import for this single function, but I have now fixed this by simply checking of the value is either 'true' or '1'. Feel free to test it again, the newest commit also adds the ability to call actions using `call_action_by`.
@rytilahti it works now. As I mentioned earlier, I use "Indicator light" property to test and `set_property_by 4 1 false bool` now works fine for this case.  
`call_action_by 6 1` also works fo me (it is an action to reset time-to-clean).
Thanks for testing @gudvinr! I'll write some tests for the new functionality when I'll find some free time again, and then it can be merged.
@SpeedFire0 For the plugin you may try https://github.com/ha0y/xiaomi_miot_raw. The author of xiaomi_raw didn't implement it for so long time, so I did it myself.
@ha0y You could help to improve `xiaomi_raw`. I'm happy about any contribution.
i got this:
DEBUG:miio.miioprotocol:192.168.154.160:54321 (ts: 1970-01-01 01:40:03, id: 1) << {'id': 1, 'error': {'code': -9999, 'message': 'user ack timeout'}, 'exe_time': 4010}
DEBUG:miio.click_common:Exception: {'code': -9999, 'message': 'user ack timeout'}
...
  File "c:\users\lenovo\appdata\local\programs\python\python37\lib\site-packages\miio\miioprotocol.py", line 214, in send
    self._handle_error(payload["error"])
  File "c:\users\lenovo\appdata\local\programs\python\python37\lib\site-packages\miio\miioprotocol.py", line 274, in _handle_error
    raise DeviceError(error)
miio.exceptions.DeviceError: {'code': -9999, 'message': 'user ack timeout'}
Error: {'code': -9999, 'message': 'user ack timeout'}
i dont know how to solve it,and i run the miioprotocal.py,the error is  No module named '__main__.exceptions'.
@rytilahti I am trying to use `miottemplate.py` as you suggested, but it's complaining that it can't find the module `requests`. Any idea how to fix this? 
@AlexKalopsia `pip install requests` will install the required lib.
Also, regarding to that linting error, you can run `pre-commit run -a` or `tox -e lint` to run all the linters locally.

Fix patch:
diff --git a/README.rst b/README.rst
index c9a3a8dda..0db12969c 100644
--- a/README.rst
+++ b/README.rst
@@ -9,12 +9,13 @@ This library (and its accompanying cli tool) can be used to interface with devic
 Getting started
 ---------------
 
-If you already have a token for your device and the device type, you can directly start using `miiocli` tool.
-If you don't have a token for your device, refer to `Getting started <https://python-miio.readthedocs.io/en/latest/discovery.html>`__ section of `the manual <https://python-miio.readthedocs.io>`__ for instructions how to obtain it.
+The ``miiocli`` command allows controlling supported devices from the command line,
+given that you know their IP addresses and tokens.
+You can use ``miiocli cloud`` command to obtain this information from the cloud.
+Refer to `Getting started <https://python-miio.readthedocs.io/en/latest/discovery.html>`__ section of `the manual <https://python-miio.readthedocs.io>`__ for more detailed instructions.
 
-The `miiocli` is the main way to execute commands from command line.
-You can always use `--help` to get more information about the available commands.
-For example, executing it without any extra arguments will print out options and available commands::
+You can always use ``--help`` to get more information about available commands, subcommands, and their options.
+For example, to print out options and available commands::
 
     $ miiocli --help
     Usage: miiocli [OPTIONS] COMMAND [ARGS]...
@@ -28,7 +29,7 @@ For example, executing it without any extra arguments will print out options and
       airconditioningcompanion
       ..
 
-You can get some information from any miIO/MIoT device, including its device model, using the `info` command::
+You can get some information from any miIO/MIoT device, including its device model, using the ``info`` command::
 
     miiocli device --ip <ip> --token <token> info
 
@@ -38,8 +39,28 @@ You can get some information from any miIO/MIoT device, including its device mod
     Network: {'localIp': '<ip>', 'mask': '255.255.255.0', 'gw': '<ip>'}
     AP: {'rssi': -73, 'ssid': '<nnetwork>', 'primary': 11, 'bssid': '<bssid>'}
 
-Different devices are supported by their corresponding modules (e.g., `roborockvacuum` or `fan`).
-You can get the list of available commands for any given module by passing `--help` argument to it::
+
+Controlling MIoT devices
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+MiOT devices are supported by the ``genericmiot`` integration which provides basic support for all MiOT devices.
+Internally, it downloads ``miot-spec`` files to find out about supported features.
+All features of supported devices are available using these common commands::
+
+-  ``miiocli genericmiot status`` to print the device status information, including settings (prefixed with ``[S]``).
+-  ``miiocli genericmiot set`` to change settings.
+-  ``miiocli genericmiot actions`` to list available actions.
+-  ``miiocli genericmiot call`` to execute actions.
+
+Use ``miiocli genericmiot --help`` for more available commands.
+
+
+Controlling other devices
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Older devices are mainly supported by their corresponding modules (e.g., ``roborockvacuum`` or ``fan``).
+
+You can get the list of available commands for any given module by passing ``--help`` argument to it::
 
     $ miiocli roborockvacuum --help
 
@@ -63,25 +84,30 @@ You can avoid this by specifying the model manually::
 
 API usage
 ---------
-All functionality is accessible through the `miio` module::
+All functionalities of this library are accessible through the ``miio`` module.
+While you can initialize individual integration classes manually,
+the simplest way to obtain a device instance is to use ``DeviceFactory``::
 
-    from miio import RoborockVacuum
+    from miio import DeviceFactory
 
-    vac = RoborockVacuum("<ip address>", "<token>")
-    vac.start()
+    dev = DeviceFactory.create("<ip address>", "<token>")
+    dev.info()
 
-Each separate device type inherits from `miio.Device`
-(and in case of MIoT devices, `miio.MiotDevice`) which provides a common API.
 
-Each command invocation will automatically detect (and cache) the device model necessary for some actions
-by querying the device.
-You can avoid this by specifying the model manually::
+This will perform an ``info`` query to the device to detect its model information,
+which is crucial especially for MiOT devices.
 
-    from miio import RoborockVacuum
+Introspecting supported features
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-    vac = RoborockVacuum("<ip address>", "<token>", model="roborock.vacuum.s5")
+You can introspect device classes using the following methods::
 
-Please refer to `API documentation <https://python-miio.readthedocs.io/en/latest/api/miio.html>`__ for more information.
+-  ``actions()`` to return information about available device actions.
+-  ``settings()`` to obtain information about available settings that can be changed.
+-  ``sensors()`` to obtain information about sensors.
+
+Each of these return `device descriptor objects <https://python-miio.readthedocs.io/en/latest/api/miio.descriptors.html>`__,
+which contain the necessary metadata about the available features to allow constructing generic interfaces.
 
 
 Troubleshooting
@@ -90,6 +116,7 @@ You can find some solutions for the most common problems can be found in `Troubl
 
 If you have any questions, or simply want to join up for a chat, check `our Matrix room <https://matrix.to/#/#python-miio-chat:matrix.org>`__.
 
+
 Contributing
 ------------
 
@@ -100,11 +127,14 @@ To ease the process of setting up a development environment we have prepared `a
 Supported devices
 -----------------
 
+While all MIoT devices are supported through the ``genericmiot`` integration,
+this library supports also the following devices::
+
 -  Xiaomi Mi Robot Vacuum V1, S4, S4 MAX, S5, S5 MAX, S6 Pure, M1S, S7
 -  Xiaomi Mi Home Air Conditioner Companion
 -  Xiaomi Mi Smart Air Conditioner A (xiaomi.aircondition.mc1, mc2, mc4, mc5)
 -  Xiaomi Mi Air Purifier 2, 3H, 3C, 4, Pro, Pro H, 4 Pro (zhimi.airpurifier.m2, mb3, mb4, mb5, v7, vb2, va2), 4 Lite
--  Xiaomi Mi Air (Purifier) Dog X3, X5, X7SM (airdog.airpurifier.x3, airdog.airpurifier.x5, airdog.airpurifier.x7sm)
+-  Xiaomi Mi Air (Purifier) Dog X3, X5, X7SM (airdog.airpurifier.x3, x5, x7sm)
 -  Xiaomi Mi Air Humidifier
 -  Smartmi Air Purifier
 -  Xiaomi Aqara Camera
@@ -139,9 +169,8 @@ Supported devices
 -  Xiaomi Smart WiFi Speaker
 -  Xiaomi Mi WiFi Repeater 2
 -  Xiaomi Mi Smart Rice Cooker
--  Xiaomi Smartmi Fresh Air System VA2 (zhimi.airfresh.va2), VA4 (zhimi.airfresh.va4),
-   A1 (dmaker.airfresh.a1), T2017 (dmaker.airfresh.t2017)
--  Yeelight lights (basic support, we recommend using `python-yeelight <https://gitlab.com/stavros/python-yeelight/>`__)
+-  Xiaomi Smartmi Fresh Air System VA2 (zhimi.airfresh.va2), VA4 (va4), T2017 (t2017), A1 (dmaker.airfresh.a1)
+-  Yeelight lights (see also `python-yeelight <https://gitlab.com/stavros/python-yeelight/>`__)
 -  Xiaomi Mi Air Dehumidifier
 -  Xiaomi Tinymu Smart Toilet Cover
 -  Xiaomi 16 Relays Module
diff --git a/miio/__init__.py b/miio/__init__.py
index 79b474eb8..8789776d8 100644
--- a/miio/__init__.py
+++ b/miio/__init__.py
@@ -47,6 +47,7 @@
     AirPurifierMiot,
 )
 from miio.integrations.fan import Fan, Fan1C, FanLeshow, FanMiot, FanP5, FanZA5
+from miio.integrations.genericmiot import GenericMiot
 from miio.integrations.humidifier import (
     AirHumidifier,
     AirHumidifierJsq,
diff --git a/miio/device.py b/miio/device.py
index caa1287ea..3d05a477b 100644
--- a/miio/device.py
+++ b/miio/device.py
@@ -145,7 +145,8 @@ def _fetch_info(self) -> DeviceInfo:
             self._info = devinfo
             _LOGGER.debug("Detected model %s", devinfo.model)
             cls = self.__class__.__name__
-            bases = ["Device", "MiotDevice"]
+            # Ignore bases and generic classes
+            bases = ["Device", "MiotDevice", "GenericMiot"]
             if devinfo.model not in self.supported_models and cls not in bases:
                 _LOGGER.warning(
                     "Found an unsupported model '%s' for class '%s'. If this is working for you, please open an issue at https://github.com/rytilahti/python-miio/",
diff --git a/miio/devicefactory.py b/miio/devicefactory.py
index e99f5de68..055cdab92 100644
--- a/miio/devicefactory.py
+++ b/miio/devicefactory.py
@@ -33,11 +33,12 @@ def register(cls, integration_cls: Type[Device]):
         for model in integration_cls.supported_models:  # type: ignore
             if model in cls._supported_models:
                 _LOGGER.debug(
-                    "Got duplicate of %s for %s, previously registered by %s",
+                    "Ignoring duplicate of %s for %s, previously registered by %s",
                     model,
                     integration_cls,
                     cls._supported_models[model],
                 )
+                continue
 
             _LOGGER.debug("  * %s => %s", model, integration_cls)
             cls._supported_models[model] = integration_cls
@@ -62,7 +63,11 @@ def class_for_model(cls, model: str):
         wildcard_models = {
             m: impl for m, impl in cls._supported_models.items() if m.endswith("*")
         }
-        for wildcard_model, impl in wildcard_models.items():
+        # We sort here to return the implementation with most specific prefix
+        sorted_by_longest_prefix = sorted(
+            wildcard_models.items(), key=lambda item: len(item[0]), reverse=True
+        )
+        for wildcard_model, impl in sorted_by_longest_prefix:
             m = wildcard_model.rstrip("*")
             if model.startswith(m):
                 _LOGGER.debug(
@@ -76,13 +81,27 @@ def class_for_model(cls, model: str):
         raise DeviceException("No implementation found for model %s" % model)
 
     @classmethod
-    def create(self, host: str, token: str, model: Optional[str] = None) -> Device:
+    def create(
+        self,
+        host: str,
+        token: str,
+        model: Optional[str] = None,
+        *,
+        force_generic_miot=False,
+    ) -> Device:
         """Return instance for the given host and token, with optional model override.
 
         The optional model parameter can be used to override the model detection.
         """
+        dev: Device
+        if force_generic_miot:  # TODO: find a better way to handle this.
+            from .integrations.genericmiot import GenericMiot
+
+            dev = GenericMiot(host, token, model=model)
+            dev.info()
+            return dev
         if model is None:
-            dev: Device = Device(host, token)
+            dev = Device(host, token)
             info = dev.info()
             model = info.model
 
diff --git a/miio/integrations/genericmiot/__init__.py b/miio/integrations/genericmiot/__init__.py
new file mode 100644
index 000000000..59f29d119
--- /dev/null
+++ b/miio/integrations/genericmiot/__init__.py
@@ -0,0 +1,3 @@
+from .genericmiot import GenericMiot
+
+__all__ = ["GenericMiot"]
diff --git a/miio/integrations/genericmiot/genericmiot.py b/miio/integrations/genericmiot/genericmiot.py
new file mode 100644
index 000000000..b2b42217b
--- /dev/null
+++ b/miio/integrations/genericmiot/genericmiot.py
@@ -0,0 +1,445 @@
+import logging
+from enum import Enum
+from functools import partial
+from typing import Dict, List, Optional, Union
+
+import click
+
+from miio import DeviceInfo, DeviceStatus, MiotDevice
+from miio.click_common import LiteralParamType, command, format_output
+from miio.descriptors import (
+    ActionDescriptor,
+    BooleanSettingDescriptor,
+    EnumSettingDescriptor,
+    NumberSettingDescriptor,
+    SensorDescriptor,
+    SettingDescriptor,
+)
+from miio.miot_cloud import MiotCloud
+from miio.miot_device import MiotMapping
+from miio.miot_models import DeviceModel, MiotAction, MiotProperty, MiotService
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def pretty_status(result: "GenericMiotStatus"):
+    """Pretty print status information."""
+    out = ""
+    props = result.property_dict()
+    for _name, prop in props.items():
+        pretty_value = prop.pretty_value
+
+        if "write" in prop.access:
+            out += "[S] "
+
+        out += f"{prop.description} ({prop.name}): {pretty_value}"
+
+        if prop.choices is not None:  # TODO: hide behind verbose flag?
+            out += (
+                " (from: "
+                + ", ".join([f"{c.description} ({c.value})" for c in prop.choices])
+                + ")"
+            )
+
+        if prop.range is not None:  # TODO: hide behind verbose flag?
+            out += (
+                f" (min: {prop.range[0]}, max: {prop.range[1]}, step: {prop.range[2]})"
+            )
+
+        out += "\n"
+
+    return out
+
+
+def pretty_actions(result: Dict[str, ActionDescriptor]):
+    """Pretty print actions."""
+    out = ""
+    for _, desc in result.items():
+        out += f"{desc.id}\t\t{desc.name}\n"
+
+    return out
+
+
+def pretty_settings(result: Dict[str, SettingDescriptor]):
+    """Pretty print settings."""
+    out = ""
+    for _, desc in result.items():
+        out += f"# {desc.id} ({desc.name})"
+        out += f'  urn: {repr(desc.extras["urn"])}\n'
+        out += f'  siid: {desc.extras["siid"]}\n'
+        out += f'  piid: {desc.extras["piid"]}\n'
+
+    return out
+
+
+class GenericMiotStatus(DeviceStatus):
+    """Generic status for miot devices."""
+
+    def __init__(self, response, dev):
+        self._model = dev._miot_model
+        self._dev = dev
+        self._data = {elem["did"]: elem["value"] for elem in response}
+
+    def __getattr__(self, item):
+        """Return attribute for name.
+
+        This is overridden to provide access to properties using (siid, piid) tuple.
+        """
+        # TODO: find a better way to encode the property information
+        serv, prop = item.split(":")
+        prop = self._model.get_property(serv, prop)
+        value = self._data[item]
+
+        # TODO: this feels like a wrong place to convert value to enum..
+        if prop.choices is not None:
+            for choice in prop.choices:
+                if choice.value == value:
+                    return choice.description
+
+            _LOGGER.warning(
+                "Unable to find choice for value: %s: %s", value, prop.choices
+            )
+
+        return self._data[item]
+
+    def property_dict(self) -> Dict[str, MiotProperty]:
+        """Return (siid, piid)-keyed dictionary of properties."""
+        res = {}
+        for did, value in self._data.items():
+            service, prop_name = did.split(":")
+            prop = self._model.get_property(service, prop_name)
+            prop.value = value
+            res[did] = prop
+
+        return res
+
+    def __repr__(self):
+        s = f"<{self.__class__.__name__}"
+        for name, value in self.property_dict().items():
+            s += f" {name}={value}"
+        s += ">"
+
+        return s
+
+
+class GenericMiot(MiotDevice):
+    _supported_models = [
+        "*"
+    ]  # we support all devices, if not, it is a responsibility of caller to verify that
+
+    def __init__(
+        self,
+        ip: str = None,
+        token: str = None,
+        start_id: int = 0,
+        debug: int = 0,
+        lazy_discover: bool = True,
+        timeout: int = None,
+        *,
+        model: str = None,
+        mapping: MiotMapping = None,
+    ):
+        super().__init__(
+            ip,
+            token,
+            start_id,
+            debug,
+            lazy_discover,
+            timeout,
+            model=model,
+            mapping=mapping,
+        )
+        self._model = model
+        self._miot_model: Optional[DeviceModel] = None
+
+        self._actions: Dict[str, ActionDescriptor] = {}
+        self._sensors: Dict[str, SensorDescriptor] = {}
+        self._settings: Dict[str, SettingDescriptor] = {}
+        self._properties: List[MiotProperty] = []
+
+    def initialize_model(self):
+        """Initialize the miot model and create descriptions."""
+        if self._miot_model is not None:
+            return
+
+        miotcloud = MiotCloud()
+        self._miot_model = miotcloud.get_device_model(self.model)
+        _LOGGER.debug("Initialized: %s", self._miot_model)
+        self._create_descriptors()
+
+    @command(default_output=format_output(result_msg_fmt=pretty_status))
+    def status(self) -> GenericMiotStatus:
+        """Return status based on the miot model."""
+        properties = []
+        for prop in self._properties:
+            if "read" not in prop.access:
+                _LOGGER.debug("Property has no read access, skipping: %s", prop)
+                continue
+
+            siid = prop.siid
+            piid = prop.piid
+            name = prop.name  # f"{prop.service.urn.name}:{prop.name}"
+            q = {"siid": siid, "piid": piid, "did": name}
+            properties.append(q)
+
+        # TODO: max properties needs to be made configurable (or at least splitted to avoid too large udp datagrams
+        #       some devices are stricter: https://github.com/rytilahti/python-miio/issues/1550#issuecomment-1303046286
+        response = self.get_properties(
+            properties, property_getter="get_properties", max_properties=10
+        )
+
+        return GenericMiotStatus(response, self)
+
+    def _create_action(self, act: MiotAction) -> Optional[ActionDescriptor]:
+        """Create action descriptor for miot action."""
+        if act.inputs:
+            # TODO: need to figure out how to expose input parameters for downstreams
+            _LOGGER.warning(
+                "Got inputs for action, skipping as handling is unknown: %s", act
+            )
+            return None
+
+        call_action = partial(self.call_action_by, act.siid, act.aiid)
+
+        id_ = act.name
+
+        # TODO: move extras handling to the model
+        extras = act.extras
+        extras["urn"] = act.urn
+        extras["siid"] = act.siid
+        extras["aiid"] = act.aiid
+
+        return ActionDescriptor(
+            id=id_,
+            name=act.description,
+            method=call_action,
+            extras=extras,
+        )
+
+    def _create_actions(self, serv: MiotService):
+        """Create action descriptors."""
+        for act in serv.actions:
+            act_desc = self._create_action(act)
+            if act_desc is None:  # skip actions we cannot handle for now..
+                continue
+
+            if (
+                act_desc.name in self._actions
+            ):  # TODO: find a way to handle duplicates, suffix maybe?
+                _LOGGER.warning("Got used name name, ignoring '%s': %s", act.name, act)
+                continue
+
+            self._actions[act_desc.name] = act_desc
+
+    def _create_sensor(self, prop: MiotProperty) -> SensorDescriptor:
+        """Create sensor descriptor for a property."""
+        property_name = prop.name
+
+        return SensorDescriptor(
+            id=property_name,
+            name=prop.description,
+            property=property_name,
+            type=prop.format,
+            extras=prop.extras,
+        )
+
+    def _create_sensors_and_settings(self, serv: MiotService):
+        """Create sensor and setting descriptors for a service."""
+        for prop in serv.properties:
+            if prop.access == ["notify"]:
+                _LOGGER.debug("Skipping notify-only property: %s", prop)
+                continue
+            if "read" not in prop.access:  # TODO: handle write-only properties
+                _LOGGER.warning("Skipping write-only: %s", prop)
+                continue
+
+            desc = self._descriptor_for_property(prop)
+            if isinstance(desc, SensorDescriptor):
+                self._sensors[prop.name] = desc
+            elif isinstance(desc, SettingDescriptor):
+                self._settings[prop.name] = desc
+            else:
+                raise Exception("unknown descriptor type")
+
+            self._properties.append(prop)
+
+    def _descriptor_for_property(self, prop: MiotProperty):
+        """Create a descriptor based on the property information."""
+        desc: SettingDescriptor
+        name = prop.description
+        property_name = prop.name
+
+        setter = partial(self.set_property_by, prop.siid, prop.piid, name=property_name)
+
+        # TODO: move extras handling to the model
+        extras = prop.extras
+        extras["urn"] = prop.urn
+        extras["siid"] = prop.siid
+        extras["piid"] = prop.piid
+
+        # Handle settable ranged properties
+        if prop.range is not None:
+            return self._create_range_setting(name, prop, property_name, setter, extras)
+
+        # Handle settable enums
+        elif prop.choices is not None:
+            # TODO: handle two-value enums as booleans?
+            return self._create_choices_setting(
+                name, prop, property_name, setter, extras
+            )
+
+        # Handle settable booleans
+        elif "write" in prop.access and prop.format == bool:
+            return BooleanSettingDescriptor(
+                id=property_name,
+                name=name,
+                property=property_name,
+                setter=setter,
+                unit=prop.unit,
+                extras=extras,
+            )
+
+        # Fallback to sensors
+        return self._create_sensor(prop)
+
+    def _create_choices_setting(
+        self, name, prop, property_name, setter, extras
+    ) -> Union[SensorDescriptor, EnumSettingDescriptor]:
+        """Create a descriptor for enum-based setting."""
+        try:
+            choices = Enum(
+                prop.description, {c.description: c.value for c in prop.choices}
+            )
+            _LOGGER.debug("Created enum %s", choices)
+        except ValueError as ex:
+            _LOGGER.error("Unable to create enum for %s: %s", prop, ex)
+            raise
+
+        desc = EnumSettingDescriptor(
+            id=property_name,
+            name=name,
+            property=property_name,
+            unit=prop.unit,
+            choices=choices,
+            extras=extras,
+        )
+        if "write" in prop.access:
+            desc.setter = setter
+            return desc
+        else:
+            return self._create_sensor(prop)
+
+    def _create_range_setting(self, name, prop, property_name, setter, extras):
+        """Create a descriptor for range-based setting."""
+        desc = NumberSettingDescriptor(
+            id=property_name,
+            name=name,
+            property=property_name,
+            min_value=prop.range[0],
+            max_value=prop.range[1],
+            step=prop.range[2],
+            unit=prop.unit,
+            extras=extras,
+        )
+        if "write" in prop.access:
+            desc.setter = setter
+            return desc
+        else:
+            return self._create_sensor(prop)
+
+    def _create_descriptors(self):
+        """Create descriptors based on the miot model."""
+        for serv in self._miot_model.services:
+            if serv.siid == 1:
+                continue  # Skip device details
+
+            self._create_actions(serv)
+            self._create_sensors_and_settings(serv)
+
+        _LOGGER.debug("Created %s actions", len(self._actions))
+        for act in self._actions.values():
+            _LOGGER.debug(f"\t{act}")
+        _LOGGER.debug("Created %s sensors", len(self._sensors))
+        for sensor in self._sensors.values():
+            _LOGGER.debug(f"\t{sensor}")
+        _LOGGER.debug("Created %s settings", len(self._settings))
+        for setting in self._settings.values():
+            _LOGGER.debug(f"\t{setting}")
+
+    def _get_action_by_name(self, name: str):
+        """Return action by name."""
+        # TODO: cache service:action?
+        for act in self._actions.values():
+            if act.id == name:
+                if act.method_name is not None:
+                    act.method = getattr(self, act.method_name)
+
+                return act
+
+        raise ValueError("No action with name/id %s" % name)
+
+    @command(
+        click.argument("name"),
+        click.argument("params", type=LiteralParamType(), required=False),
+        name="call",
+    )
+    def call_action(self, name: str, params=None):
+        """Call action by name."""
+        params = params or []
+        act = self._get_action_by_name(name)
+        return act.method(params)
+
+    @command(
+        click.argument("name"),
+        click.argument("params", type=LiteralParamType(), required=True),
+        name="set",
+    )
+    def change_setting(self, name: str, params=None):
+        """Change setting value."""
+        params = params if params is not None else []
+        # TODO: create a name/plain name getter to the device model
+        service, prop_name = name.split(":")
+        # prop = self._miot_model.get_property(service, prop)
+        setting = self._settings.get(name, None)
+        if setting is None:
+            raise ValueError("No setting found for name %s" % name)
+
+        return setting.setter(value=setting.cast_value(params))
+
+    def _fetch_info(self) -> DeviceInfo:
+        """Hook to perform the model initialization."""
+        info = super()._fetch_info()
+        self.initialize_model()
+
+        return info
+
+    @command(default_output=format_output(result_msg_fmt=pretty_actions))
+    def actions(self) -> Dict[str, ActionDescriptor]:
+        """Return available actions."""
+        return self._actions
+
+    @command()
+    def sensors(self) -> Dict[str, SensorDescriptor]:
+        """Return available sensors."""
+        return self._sensors
+
+    @command(default_output=format_output(result_msg_fmt=pretty_settings))
+    def settings(self) -> Dict[str, SettingDescriptor]:
+        """Return available settings."""
+        return self._settings
+
+    @property
+    def device_type(self) -> Optional[str]:
+        """Return device type."""
+        # TODO: this should be probably mapped to an enum
+        if self._miot_model is not None:
+            return self._miot_model.urn.type
+        return None
+
+    @classmethod
+    def get_device_group(cls):
+        """Return device command group.
+
+        TODO: insert the actions from the model for better click integration
+        """
+        return super().get_device_group()
diff --git a/miio/miot_device.py b/miio/miot_device.py
index 0a471c612..92c431f83 100644
--- a/miio/miot_device.py
+++ b/miio/miot_device.py
@@ -77,9 +77,6 @@ def __init__(
             ip, token, start_id, debug, lazy_discover, timeout, model=model
         )
 
-        if mapping is None and not hasattr(self, "mapping") and not self._mappings:
-            _LOGGER.warning("Neither the class nor the parameter defines the mapping")
-
         if mapping is not None:
             self.mapping = mapping
 
diff --git a/miio/miot_models.py b/miio/miot_models.py
index 509c6f888..6490334e1 100644
--- a/miio/miot_models.py
+++ b/miio/miot_models.py
@@ -17,6 +17,8 @@ class URN(BaseModel):
     model: str
     version: int
 
+    parent_urn: Optional["URN"] = Field(None, repr=False)
+
     @classmethod
     def __get_validators__(cls):
         yield cls.validate
@@ -37,9 +39,14 @@ def validate(cls, v):
             version=version,
         )
 
-    def __repr__(self):
+    @property
+    def urn_string(self) -> str:
+        """Return string presentation of the URN."""
         return f"urn:{self.namespace}:{self.type}:{self.name}:{self.internal_id}:{self.model}:{self.version}"
 
+    def __repr__(self):
+        return f"<URN {self.urn_string} parent:{self.parent_urn}>"
+
 
 class MiotFormat(type):
     """Custom type to convert textual presentation to python type."""
@@ -60,21 +67,6 @@ def convert_type(cls, input: str):
         return type_map[input]
 
 
-class MiotEvent(BaseModel):
-    """Presentation of miot event."""
-
-    eiid: int = Field(alias="iid")
-    urn: URN = Field(alias="type")
-    description: str
-
-    arguments: Any
-
-    service: Optional["MiotService"] = None  # backref to containing service
-
-    class Config:
-        extra = "forbid"
-
-
 class MiotEnumValue(BaseModel):
     """Enum value for miot."""
 
@@ -92,20 +84,21 @@ class Config:
         extra = "forbid"
 
 
-class MiotAction(BaseModel):
-    """Action presentation for miot."""
+class MiotBaseModel(BaseModel):
+    """Base model for all other miot models."""
 
-    aiid: int = Field(alias="iid")
     urn: URN = Field(alias="type")
     description: str
 
-    inputs: Any = Field(alias="in")
-    outputs: Any = Field(alias="out")
-
     extras: Dict = Field(default_factory=dict, repr=False)
-
     service: Optional["MiotService"] = None  # backref to containing service
 
+    def fill_from_parent(self, service: "MiotService"):
+        """Fill some information from the parent service."""
+        # TODO: this could be done using a validator
+        self.service = service
+        self.urn.parent_urn = service.urn
+
     @property
     def siid(self) -> Optional[int]:
         """Return siid."""
@@ -122,18 +115,33 @@ def plain_name(self) -> str:
     @property
     def name(self) -> str:
         """Return combined name of the service and the action."""
-        return f"{self.service.name}:{self.urn.name}"  # type: ignore
+        if self.service is not None and self.urn.name is not None:
+            return f"{self.service.name}:{self.urn.name}"  # type: ignore
+        return "unitialized"
+
+
+class MiotAction(MiotBaseModel):
+    """Action presentation for miot."""
+
+    aiid: int = Field(alias="iid")
+
+    inputs: Any = Field(alias="in")
+    outputs: Any = Field(alias="out")
+
+    def fill_from_parent(self, service: "MiotService"):
+        """Overridden to convert inputs and outputs to property references."""
+        super().fill_from_parent(service)
+        self.inputs = [service.get_property_by_id(piid) for piid in self.inputs]
+        self.outputs = [service.get_property_by_id(piid) for piid in self.outputs]
 
     class Config:
         extra = "forbid"
 
 
-class MiotProperty(BaseModel):
+class MiotProperty(MiotBaseModel):
     """Property presentation for miot."""
 
     piid: int = Field(alias="iid")
-    urn: URN = Field(alias="type")
-    description: str
 
     format: MiotFormat
     access: Any = Field(default=["read"])
@@ -142,32 +150,10 @@ class MiotProperty(BaseModel):
     range: Optional[List[int]] = Field(alias="value-range")
     choices: Optional[List[MiotEnumValue]] = Field(alias="value-list")
 
-    extras: Dict[str, Any] = Field(default_factory=dict, repr=False)
-
-    service: Optional["MiotService"] = None  # backref to containing service
-
     # TODO: currently just used to pass the data for miiocli
     #       there must be a better way to do this..
     value: Optional[Any] = None
 
-    @property
-    def siid(self) -> Optional[int]:
-        """Return siid."""
-        if self.service is not None:
-            return self.service.siid
-
-        return None
-
-    @property
-    def plain_name(self):
-        """Return plain name."""
-        return self.urn.name
-
-    @property
-    def name(self) -> str:
-        """Return combined name of the service and the property."""
-        return f"{self.service.name}:{self.urn.name}"  # type: ignore
-
     @property
     def pretty_value(self):
         value = self.value
@@ -201,6 +187,16 @@ class Config:
         extra = "forbid"
 
 
+class MiotEvent(MiotBaseModel):
+    """Presentation of miot event."""
+
+    eiid: int = Field(alias="iid")
+    arguments: Any
+
+    class Config:
+        extra = "forbid"
+
+
 class MiotService(BaseModel):
     """Service presentation for miot."""
 
@@ -212,19 +208,32 @@ class MiotService(BaseModel):
     events: List[MiotEvent] = Field(default_factory=list, repr=False)
     actions: List[MiotAction] = Field(default_factory=list, repr=False)
 
+    _property_by_id: Dict[int, MiotProperty] = PrivateAttr(default_factory=dict)
+    _action_by_id: Dict[int, MiotAction] = PrivateAttr(default_factory=dict)
+
     def __init__(self, *args, **kwargs):
         """Initialize a service.
 
-        Overridden to propagate the siid to the children.
+        Overridden to propagate the service to the children.
         """
         super().__init__(*args, **kwargs)
 
         for prop in self.properties:
-            prop.service = self
+            self._property_by_id[prop.piid] = prop
+            prop.fill_from_parent(self)
         for act in self.actions:
-            act.service = self
+            self._action_by_id[act.aiid] = act
+            act.fill_from_parent(self)
         for ev in self.events:
-            ev.service = self
+            ev.fill_from_parent(self)
+
+    def get_property_by_id(self, piid):
+        """Return property by id."""
+        return self._property_by_id[piid]
+
+    def get_action_by_id(self, aiid):
+        """Return action by id."""
+        return self._action_by_id[aiid]
 
     @property
     def name(self) -> str:


Test patch:
diff --git a/miio/tests/test_devicefactory.py b/miio/tests/test_devicefactory.py
index dd9a5a9e0..75b754f38 100644
--- a/miio/tests/test_devicefactory.py
+++ b/miio/tests/test_devicefactory.py
@@ -1,6 +1,6 @@
 import pytest
 
-from miio import Device, DeviceException, DeviceFactory, Gateway, MiotDevice
+from miio import Device, DeviceFactory, DeviceInfo, Gateway, GenericMiot, MiotDevice
 
 DEVICE_CLASSES = Device.__subclasses__() + MiotDevice.__subclasses__()  # type: ignore
 DEVICE_CLASSES.remove(MiotDevice)
@@ -37,6 +37,44 @@ class _DummyDevice(Device):
 
 
 def test_device_class_for_model_unknown():
-    """Test that unknown model raises an exception."""
-    with pytest.raises(DeviceException):
-        DeviceFactory.class_for_model("foo.foo.xyz")
+    """Test that unknown model returns genericmiot."""
+    assert DeviceFactory.class_for_model("foo.foo.xyz.invalid") == GenericMiot
+
+
+@pytest.mark.parametrize("cls", DEVICE_CLASSES)
+@pytest.mark.parametrize("force_model", [True, False])
+def test_create(cls, force_model, mocker):
+    """Test create for both forced and autodetected models."""
+    mocker.patch("miio.Device.send")
+
+    model = None
+    first_supported_model = next(iter(cls.supported_models))
+    if force_model:
+        model = first_supported_model
+
+    dummy_info = DeviceInfo({"model": first_supported_model})
+    info = mocker.patch("miio.Device.info", return_value=dummy_info)
+
+    device = DeviceFactory.create("127.0.0.1", 32 * "0", model=model)
+    device_class = DeviceFactory.class_for_model(device.model)
+    assert isinstance(device, device_class)
+
+    if force_model:
+        info.assert_not_called()
+    else:
+        info.assert_called()
+
+
+@pytest.mark.parametrize("cls", DEVICE_CLASSES)
+def test_create_force_miot(cls, mocker):
+    """Test that force_generic_miot works."""
+    mocker.patch("miio.Device.send")
+    mocker.patch("miio.Device.info")
+    class_for_model = mocker.patch("miio.DeviceFactory.class_for_model")
+
+    assert isinstance(
+        DeviceFactory.create("127.0.0.1", 32 * "0", force_generic_miot=True),
+        GenericMiot,
+    )
+
+    class_for_model.assert_not_called()
diff --git a/miio/tests/test_miot_models.py b/miio/tests/test_miot_models.py
index dcca795cc..f87f86f4c 100644
--- a/miio/tests/test_miot_models.py
+++ b/miio/tests/test_miot_models.py
@@ -13,6 +13,42 @@
     MiotService,
 )
 
+DUMMY_SERVICE = """
+    {
+        "iid": 1,
+        "description": "test service",
+        "type": "urn:miot-spec-v2:service:device-information:00000001:dummy:1",
+        "properties": [
+        {
+            "iid": 4,
+            "type": "urn:miot-spec-v2:property:firmware-revision:00000005:dummy:1",
+            "description": "Current Firmware Version",
+            "format": "string",
+            "access": [
+              "read"
+            ]
+        }
+        ],
+        "actions": [
+        {
+            "iid": 1,
+            "type": "urn:miot-spec-v2:action:start-sweep:00000004:dummy:1",
+            "description": "Start Sweep",
+            "in": [],
+            "out": []
+        }
+        ],
+        "events": [
+        {
+            "iid": 1,
+            "type": "urn:miot-spec-v2:event:low-battery:00000003:dummy:1",
+            "description": "Low Battery",
+            "arguments": []
+        }
+        ]
+    }
+"""
+
 
 def test_enum():
     """Test that enum parsing works."""
@@ -98,8 +134,9 @@ class Wrapper(BaseModel):
     assert urn.model == "dummy.model"
     assert urn.version == 1
 
-    # Check that the serialization works, too
-    assert repr(urn) == urn_string
+    # Check that the serialization works
+    assert urn.urn_string == urn_string
+    assert repr(urn) == f"<URN {urn_string} parent:None>"
 
 
 def test_service():
@@ -118,6 +155,32 @@ def test_service():
     assert serv.events == []
 
 
+@pytest.mark.parametrize("entity_type", ["actions", "properties", "events"])
+def test_service_back_references(entity_type):
+    """Check that backrefs are created correctly for properties, actions, and events."""
+    serv = MiotService.parse_raw(DUMMY_SERVICE)
+    assert serv.siid == 1
+    assert serv.urn.type == "service"
+
+    entities = getattr(serv, entity_type)
+    assert len(entities) == 1
+    entity_to_test = entities[0]
+
+    assert entity_to_test.service.siid == serv.siid
+
+
+@pytest.mark.parametrize("entity_type", ["actions", "properties", "events"])
+def test_entity_names(entity_type):
+    """Check that entity name consists of service name and entity's plain name."""
+    serv = MiotService.parse_raw(DUMMY_SERVICE)
+
+    entities = getattr(serv, entity_type)
+    assert len(entities) == 1
+    entity_to_test = entities[0]
+
+    assert entity_to_test.name == f"{serv.name}:{entity_to_test.plain_name}"
+
+
 def test_event():
     data = '{"iid": 1, "type": "urn:spect:event:example_event:00000001:dummymodel:1", "description": "dummy", "arguments": []}'
     ev = MiotEvent.parse_raw(data)
diff --git a/miio/tests/test_miotdevice.py b/miio/tests/test_miotdevice.py
index bca77505e..3f8fdb720 100644
--- a/miio/tests/test_miotdevice.py
+++ b/miio/tests/test_miotdevice.py
@@ -3,6 +3,7 @@
 import pytest
 
 from miio import Huizuo, MiotDevice
+from miio.integrations.genericmiot import GenericMiot
 from miio.miot_device import MiotValueType, _filter_request_fields
 
 MIOT_DEVICES = MiotDevice.__subclasses__()
@@ -17,18 +18,11 @@ def dev(module_mocker):
     device = MiotDevice(
         "127.0.0.1", "68ffffffffffffffffffffffffffffff", mapping=DUMMY_MAPPING
     )
-    device._model = "testmodel"
+    device._model = "test.model"
     module_mocker.patch.object(device, "send")
     return device
 
 
-def test_missing_mapping(caplog):
-    """Make sure ctor raises exception if neither class nor parameter defines the
-    mapping."""
-    _ = MiotDevice("127.0.0.1", "68ffffffffffffffffffffffffffffff")
-    assert "Neither the class nor the parameter defines the mapping" in caplog.text
-
-
 def test_ctor_mapping():
     """Make sure the constructor accepts the mapping parameter."""
     test_mapping = {}
@@ -115,13 +109,13 @@ def test_call_action_by(dev):
 @pytest.mark.parametrize(
     "model,expected_mapping,expected_log",
     [
-        ("some_model", {"x": {"y": 1}}, ""),
-        ("unknown_model", {"x": {"y": 1}}, "Unable to find mapping"),
+        ("some.model", {"x": {"y": 1}}, ""),
+        ("unknown.model", {"x": {"y": 1}}, "Unable to find mapping"),
     ],
 )
 def test_get_mapping(dev, caplog, model, expected_mapping, expected_log):
     """Test _get_mapping logic for fallbacks."""
-    dev._mappings["some_model"] = {"x": {"y": 1}}
+    dev._mappings["some.model"] = {"x": {"y": 1}}
     dev._model = model
     assert dev._get_mapping() == expected_mapping
 
@@ -145,6 +139,9 @@ def test_mapping_deprecation(cls):
 @pytest.mark.parametrize("cls", MIOT_DEVICES)
 def test_mapping_structure(cls):
     """Check that mappings are structured correctly."""
+    if cls == GenericMiot:
+        pytest.skip("Skipping genericmiot as it provides no mapping")
+
     assert cls._mappings
 
     model, contents = next(iter(cls._mappings.items()))
@@ -163,13 +160,15 @@ def test_mapping_structure(cls):
 @pytest.mark.parametrize("cls", MIOT_DEVICES)
 def test_supported_models(cls):
     assert cls.supported_models == list(cls._mappings.keys())
+    if cls == GenericMiot:
+        pytest.skip("Skipping genericmiot as it uses supported_models for now")
 
     # make sure that that _supported_models is not defined
     assert not cls._supported_models
 
 
 def test_call_action(dev):
-    dev._mappings["testmodel"] = {"test_action": {"siid": 1, "aiid": 1}}
+    dev._mappings["test.model"] = {"test_action": {"siid": 1, "aiid": 1}}
 
     dev.call_action("test_action")
 
@@ -188,7 +187,7 @@ def test_call_action(dev):
 def test_get_properties_for_mapping_readables(mocker, dev, props, included_in_request):
     base_props = {"readable_property": {"siid": 1, "piid": 1}}
     base_request = [{"did": k, **v} for k, v in base_props.items()]
-    dev._mappings["testmodel"] = mapping = {
+    dev._mappings["test.model"] = mapping = {
         **base_props,
         "property_under_test": {"siid": 1, "piid": 2, **props},
     }