Repository: jabesq/netatmo-api-python

Issue:
Smoke Detectors
Implement smoke detector support

Discussion:

Patch:
diff --git a/fixtures/camera_home_data.json b/fixtures/camera_home_data.json
index 075fb8c..962c3b8 100644
--- a/fixtures/camera_home_data.json
+++ b/fixtures/camera_home_data.json
@@ -84,7 +84,20 @@
                         "light_mode_status": "auto"
                     }
                 ],
-                "smokedetectors": [],
+                "smokedetectors": [
+                    {
+                        "id": "12:34:56:00:8b:a2",
+                        "type": "NSD",
+                        "last_setup": 1567261859,
+                        "name": "Hall"
+                    },
+                    {
+                        "id": "12:34:56:00:8b:ac",
+                        "type": "NSD",
+                        "last_setup": 1567262759,
+                        "name": "Kitchen"
+                    }
+                ],
                 "events": [
                     {
                         "id": "a1b2c3d4e5f6abcdef123456",
@@ -176,6 +189,24 @@
                         "video_id": "12345678-1234-46cb-ad8f-23d89387409a",
                         "video_status": "available",
                         "message": "<b>Bewegung</b> erkannt"
+                    },
+                    {
+                        "id": "a1b2c3d4e5f6abcdef12345c",
+                        "type": "sound_test",
+                        "time": 1560506210,
+                        "camera_id": "12:34:56:00:8b:a2",
+                        "device_id": "12:34:56:00:8b:a2",
+                        "sub_type": 0,
+                        "message": "Hall: Alarmton erfolgreich getestet"
+                    },
+                    {
+                        "id": "a1b2c3d4e5f6abcdef12345d",
+                        "type": "wifi_status",
+                        "time": 1560506220,
+                        "camera_id": "12:34:56:00:8b:a2",
+                        "device_id": "12:34:56:00:8b:a2",
+                        "sub_type": 1,
+                        "message": "Hall:WLAN-Verbindung erfolgreich hergestellt"
                     }
                 ]
             },
@@ -202,6 +233,17 @@
                     }
                 ],
                 "smokedetectors": []
+            },
+            {
+                "id": "91763b24c43d3e344f424e8d",
+                "persons": [],
+                "place": {
+                    "city": "Frankfurt",
+                    "country": "DE",
+                    "timezone": "Europe/Berlin"
+                },
+                "cameras": [],
+                "smokedetectors": []
             }
         ],
         "user": {
diff --git a/src/pyatmo/__main__.py b/src/pyatmo/__main__.py
index 5ce3605..8fa2801 100644
--- a/src/pyatmo/__main__.py
+++ b/src/pyatmo/__main__.py
@@ -33,7 +33,11 @@ def main():
         clientSecret=CLIENT_SECRET,
         username=USERNAME,
         password=PASSWORD,
-        scope="read_station read_camera access_camera write_camera read_thermostat write_thermostat read_presence access_presence read_homecoach",
+        scope=(
+            "read_station read_camera access_camera read_thermostat "
+            "write_thermostat read_presence access_presence read_homecoach "
+            "read_smokedetector"
+        ),
     )
 
     try:
diff --git a/src/pyatmo/auth.py b/src/pyatmo/auth.py
index 599cde8..b18bd87 100644
--- a/src/pyatmo/auth.py
+++ b/src/pyatmo/auth.py
@@ -27,6 +27,7 @@ class ClientAuth:
             read_presence: to retrieve Presence data (Gethomedata, Getcamerapicture)
             access_presence: to access the live stream, any video stored on the SD card and to retrieve Presence's lightflood status
             read_homecoach: to retrieve Home Coache data (Gethomecoachsdata)
+            read_smokedetector: to read the smoke detector status (Gethomedata)
             Several value can be used at the same time, ie: 'read_station read_camera'
     """
 
diff --git a/src/pyatmo/camera.py b/src/pyatmo/camera.py
index 6a3145a..d161029 100644
--- a/src/pyatmo/camera.py
+++ b/src/pyatmo/camera.py
@@ -15,7 +15,7 @@ _SETPERSONSHOME_REQ = _BASE_URL + "api/setpersonshome"
 class CameraData:
     """
     List the Netatmo camera informations
-        (Homes, cameras, modules, events, persons)
+        (Homes, cameras, smoke detectors, modules, events, persons)
     Args:
         authData (ClientAuth):
             Authentication information with a working access Token
@@ -26,17 +26,18 @@ class CameraData:
         postParams = {"access_token": self.getAuthToken, "size": size}
         resp = postRequest(_GETHOMEDATA_REQ, postParams)
         if resp is None:
-            raise URLError("No camera data returned by Netatmo server")
+            raise URLError("No device data returned by Netatmo server")
         self.rawData = resp["body"].get("homes")
         if not self.rawData:
-            raise NoDevice("No camera data available")
+            raise NoDevice("No device data available")
         self.homes = {d["id"]: d for d in self.rawData}
         if not self.homes:
-            raise NoDevice("No camera available")
+            raise NoDevice("No device available")
         self.persons = {}
         self.events = {}
         self.outdoor_events = {}
         self.cameras = {}
+        self.smokedetectors = {}
         self.modules = {}
         self.lastEvent = {}
         self.outdoor_lastEvent = {}
@@ -44,6 +45,7 @@ class CameraData:
         self.default_home = None
         self.default_home_id = None
         self.default_camera = None
+        self.default_smokedetector = None
         for item in self.rawData:
             homeId = item.get("id")
             nameHome = item.get("name")
@@ -55,13 +57,16 @@ class CameraData:
                 continue
             if homeId not in self.cameras:
                 self.cameras[homeId] = {}
+            if homeId not in self.smokedetectors:
+                self.smokedetectors[homeId] = {}
             if homeId not in self.types:
                 self.types[homeId] = {}
             for p in item["persons"]:
                 self.persons[p["id"]] = p
             if "events" in item:
-                self.default_home = item["name"]
-                self.default_home_id = item["id"]
+                if not self.default_home and not self.default_home_id:
+                    self.default_home = item["name"]
+                    self.default_home_id = item["id"]
                 for e in item["events"]:
                     if e["type"] == "outdoor":
                         if e["camera_id"] not in self.outdoor_events:
@@ -77,8 +82,12 @@ class CameraData:
                     for m in c["modules"]:
                         self.modules[m["id"]] = m
                         self.modules[m["id"]]["cam_id"] = c["id"]
+            for s in item["smokedetectors"]:
+                self.smokedetectors[homeId][s["id"]] = s
             for t in item["cameras"]:
                 self.types[homeId][t["type"]] = t
+            for t in item["smokedetectors"]:
+                self.types[homeId][t["type"]] = t
         for camera in self.events:
             self.lastEvent[camera] = self.events[camera][
                 sorted(self.events[camera])[-1]
@@ -118,8 +127,6 @@ class CameraData:
             home = self.default_home
         for key, value in self.homes.items():
             if value["name"] == home:
-                LOG.debug(self.homes[key]["id"])
-                LOG.debug(self.default_home)
                 return self.homes[key]["id"]
         raise InvalidHome("Invalid Home %s" % home)
 
@@ -178,6 +185,41 @@ class CameraData:
                 return self.modules[key]
         return None
 
+    def smokedetectorById(self, sid):
+        for home, sd in self.smokedetectors.items():
+            if sid in self.smokedetectors[home]:
+                return self.smokedetectors[home][sid]
+        return None
+
+    def smokedetectorByName(self, smokedetector=None, home=None, home_id=None):
+        if home_id is None:
+            if home is None:
+                hid = self.default_home_id
+            else:
+                try:
+                    hid = self.homeByName(home)["id"]
+                except InvalidHome:
+                    LOG.debug("Invalid home %s", home)
+                    return None
+        else:
+            hid = home_id
+        if smokedetector is None and home is None and home_id is None:
+            return self.default_smokedetector
+        elif not (home_id or home) and smokedetector:
+            for h_id, cam_ids in self.smokedetectors.items():
+                for cam_id in cam_ids:
+                    if self.smokedetectors[h_id][cam_id]["name"] == smokedetector:
+                        return self.smokedetectors[h_id][cam_id]
+        elif hid and smokedetector:
+            hid = self.homeByName(home)["id"]
+            if hid not in self.smokedetectors:
+                return None
+            for cam_id in self.smokedetectors[hid]:
+                if self.smokedetectors[hid][cam_id]["name"] == smokedetector:
+                    return self.smokedetectors[hid][cam_id]
+        else:
+            return list(self.smokedetectors[hid].values())[0]
+
     def cameraType(self, camera=None, home=None, cid=None, home_id=None):
         """
         Return the type of a given camera.
@@ -250,7 +292,6 @@ class CameraData:
             "person_ids[]": person_ids,
         }
         resp = postRequest(_SETPERSONSHOME_REQ, postParams)
-        LOG.debug(resp)
         return resp
 
     def setPersonsAway(self, person_id, home_id):
@@ -263,7 +304,6 @@ class CameraData:
             "person_id": person_id,
         }
         resp = postRequest(_SETPERSONSAWAY_REQ, postParams)
-        LOG.debug(resp)
         return resp
 
     def getPersonId(self, name):
@@ -297,29 +337,31 @@ class CameraData:
                     return self.getCameraPicture(image_id, key)
         return None, None
 
-    def updateEvent(self, event=None, home=None, cameratype=None, home_id=None):
+    def updateEvent(self, event=None, home=None, devicetype=None, home_id=None):
         """
         Update the list of events
         """
         if not home_id:
             try:
+                if not home:
+                    home = self.default_home
                 home_id = self.gethomeId(home)
             except InvalidHome:
-                LOG.debug("No valid Home %s", home)
+                LOG.debug("Invalid Home %s", home)
                 return None
-        if cameratype == "NACamera":
+        if devicetype == "NACamera":
             # for the Welcome camera
             if not event:
-                # If not event is provided we need to retrieve the oldest of
+                # If no event is provided we need to retrieve the oldest of
                 # the last event seen by each camera
                 listEvent = {}
                 for cam_id in self.lastEvent:
                     listEvent[self.lastEvent[cam_id]["time"]] = self.lastEvent[cam_id]
                 event = listEvent[sorted(listEvent)[0]]
-        if cameratype == "NOC":
+        if devicetype == "NOC":
             # for the Presence camera
             if not event:
-                # If not event is provided we need to retrieve the oldest of
+                # If no event is provided we need to retrieve the oldest of
                 # the last event seen by each camera
                 listEvent = {}
                 for cam_id in self.outdoor_lastEvent:
@@ -327,6 +369,17 @@ class CameraData:
                         self.outdoor_lastEvent[cam_id]["time"]
                     ] = self.outdoor_lastEvent[cam_id]
                 event = listEvent[sorted(listEvent)[0]]
+        if devicetype == "NSD":
+            # for the smoke detector
+            if not event:
+                # If no event is provided we need to retrieve the oldest of
+                # the last event by each smoke detector
+                listEvent = {}
+                for sid in self.outdoor_lastEvent:
+                    listEvent[
+                        self.outdoor_lastEvent[sid]["time"]
+                    ] = self.outdoor_lastEvent[sid]
+                event = listEvent[sorted(listEvent)[0]]
 
         postParams = {
             "access_token": self.getAuthToken,
diff --git a/src/pyatmo/thermostat.py b/src/pyatmo/thermostat.py
index 77da36d..5f2ac9b 100644
--- a/src/pyatmo/thermostat.py
+++ b/src/pyatmo/thermostat.py
@@ -97,8 +97,6 @@ class HomeData:
             home = self.default_home
         for key, value in self.homes.items():
             if value["name"] == home:
-                LOG.debug(self.homes[key]["id"])
-                LOG.debug(self.default_home)
                 if "therm_schedules" in self.homes[key]:
                     return self.homes[key]["id"]
         raise InvalidHome("Invalid Home %s" % home)
@@ -198,7 +196,6 @@ class HomeStatus:
             self.default_relay = list(self.relays.values())[0]
         if self.thermostats != {}:
             self.default_thermostat = list(self.thermostats.values())[0]
-        LOG.debug(self.thermostats)
         if self.valves != {}:
             self.default_valve = list(self.valves.values())[0]
 
@@ -262,7 +259,6 @@ class HomeStatus:
         if not home_id:
             if not home:
                 home = self.home_data.default_home
-                LOG.debug(self.home_data.default_home)
             try:
                 home_id = self.home_data.gethomeId(home)
             except InvalidHome:
@@ -279,7 +275,6 @@ class HomeStatus:
         if not home_id:
             if not home:
                 home = self.home_data.default_home
-                LOG.debug(self.home_data.default_home)
             home_id = self.home_data.gethomeId(home)
         try:
             data = self.home_data.getSelectedschedule(home_id=home_id)
@@ -293,7 +288,6 @@ class HomeStatus:
         Return the measured temperature of a given room.
         """
         temperature = None
-        LOG.debug(rid)
         room_data = self.roomById(rid=rid)
         if room_data:
             temperature = room_data["therm_measured_temperature"]
@@ -301,7 +295,6 @@ class HomeStatus:
 
     def boilerStatus(self, rid=None):
         boiler_status = None
-        LOG.debug(rid)
         if rid:
             relay_status = self.thermostatById(rid=rid)
         else:
@@ -328,8 +321,7 @@ class HomeStatus:
             "home_id": home_id,
             "mode": mode,
         }
-        resp = postRequest(_SETTHERMMODE_REQ, postParams)
-        LOG.debug(resp)
+        return postRequest(_SETTHERMMODE_REQ, postParams)
 
     def setroomThermpoint(self, home_id, room_id, mode, temp=None):
         postParams = {
diff --git a/src/pyatmo/weather_station.py b/src/pyatmo/weather_station.py
index 72db9fe..1039d22 100644
--- a/src/pyatmo/weather_station.py
+++ b/src/pyatmo/weather_station.py
@@ -18,6 +18,7 @@ class WeatherStationData:
     """
 
     def __init__(self, authData, urlReq=None):
+        """Initialize the weather station class."""
         self.urlReq = urlReq or _GETSTATIONDATA_REQ
         self.getAuthToken = authData.accessToken
         postParams = {"access_token": self.getAuthToken}
@@ -47,6 +48,7 @@ class WeatherStationData:
         self.default_station = list(self.stations.values())[0]["station_name"]
 
     def modulesNamesList(self, station=None, station_id=None):
+        """Return a list of all modules for a given or all stations."""
         res = set()
         station_data = None
         if station_id is not None:
@@ -64,7 +66,7 @@ class WeatherStationData:
         return list(res)
 
     def getModules(self, station=None, station_id=None):
-        """Return a dict for Home Assistant to consume."""
+        """Return a dict or modules for a given or all stations."""
         res = {}
         station_data = None
         if station_id is not None:
@@ -91,6 +93,7 @@ class WeatherStationData:
         return res
 
     def stationByName(self, station=None):
+        """Return station by name."""
         if not station:
             station = self.default_station
         for i, s in self.stations.items():
@@ -99,9 +102,11 @@ class WeatherStationData:
         return None
 
     def stationById(self, sid):
+        """Return station by id."""
         return None if sid not in self.stations else self.stations[sid]
 
     def moduleByName(self, module, station=None):
+        """Return module by name."""
         s = None
         if station:
             s = self.stationByName(station)
@@ -121,6 +126,7 @@ class WeatherStationData:
         return None
 
     def moduleById(self, mid, sid=None):
+        """Return module by id."""
         s = self.stationById(sid) if sid else None
         if mid in self.modules:
             if s:
@@ -131,10 +137,15 @@ class WeatherStationData:
                 return self.modules[mid]
 
     def monitoredConditions(self, module=None, moduleId=None):
+        """Return monitored conditions for given module(s)."""
         if moduleId:
             mod = self.moduleById(moduleId)
+            if not mod:
+                mod = self.stationById(moduleId)
         elif module:
             mod = self.moduleByName(module)
+            if not mod:
+                mod = self.stationByName(module)
         else:
             return None
         conditions = []
@@ -149,7 +160,7 @@ class WeatherStationData:
             elif cond == "Rain":
                 conditions.extend(["Rain", "sum_rain_24", "sum_rain_1"])
             else:
-                conditions.append(cond.lower())
+                conditions.append(cond)
         if mod["type"] in ["NAMain", "NHC"]:
             # the main module has wifi_status
             conditions.append("wifi_status")
@@ -170,6 +181,7 @@ class WeatherStationData:
         return conditions
 
     def lastData(self, station=None, exclude=0, byId=False):
+        """Return data for a given station and time frame."""
         key = "_id" if byId else "module_name"
         if station is not None:
             stations = [station]
@@ -211,6 +223,7 @@ class WeatherStationData:
         return lastD
 
     def checkNotUpdated(self, station=None, delay=3600):
+        """Check if a given station has not been updated."""
         res = self.lastData(station)
         ret = []
         for mn, v in res.items():
@@ -219,6 +232,7 @@ class WeatherStationData:
         return ret if ret else None
 
     def checkUpdated(self, station=None, delay=3600):
+        """Check if a given station has been updated."""
         res = self.lastData(station)
         ret = []
         for mn, v in res.items():


Test patch:
diff --git a/tests/test_pyatmo_camera.py b/tests/test_pyatmo_camera.py
index 66025e4..492c685 100644
--- a/tests/test_pyatmo_camera.py
+++ b/tests/test_pyatmo_camera.py
@@ -334,3 +334,53 @@ def test_CameraData_gethomeId(cameraHomeData):
     assert cameraHomeData.gethomeId("MYHOME") == "91763b24c43d3e344f424e8b"
     with pytest.raises(pyatmo.InvalidHome):
         assert cameraHomeData.gethomeId("InvalidName")
+
+
+@pytest.mark.parametrize(
+    "sid, expected",
+    [
+        ("12:34:56:00:8b:a2", "Hall"),
+        ("12:34:56:00:8b:ac", "Kitchen"),
+        ("None", None),
+        (None, None),
+    ],
+)
+def test_CameraData_smokedetectorById(cameraHomeData, sid, expected):
+    smokedetector = cameraHomeData.smokedetectorById(sid)
+    if smokedetector:
+        assert smokedetector["name"] == expected
+    else:
+        assert smokedetector is expected
+
+
+@pytest.mark.parametrize(
+    "name, home, home_id, expected",
+    [
+        ("Hall", None, None, "12:34:56:00:8b:a2"),
+        (None, None, None, None),
+        ("", None, None, "12:34:56:00:8b:a2"),
+        ("Hall", "MYHOME", None, "12:34:56:00:8b:a2"),
+        ("Hall", None, "91763b24c43d3e344f424e8b", "12:34:56:00:8b:a2"),
+        (None, None, "91763b24c43d3e344f424e8b", "12:34:56:00:8b:a2"),
+        (None, "MYHOME", None, "12:34:56:00:8b:a2"),
+        ("", "MYHOME", None, "12:34:56:00:8b:a2"),
+        ("Kitchen", "MYHOME", None, "12:34:56:00:8b:ac"),
+        (INVALID_NAME, None, None, None),
+        (None, INVALID_NAME, None, None),
+    ],
+)
+def test_CameraData_smokedetectorByName(cameraHomeData, name, home, home_id, expected):
+    if (
+        home == INVALID_NAME
+        or name == INVALID_NAME
+        or (name is None and home is None and home_id is None)
+    ):
+        assert cameraHomeData.smokedetectorByName(name, home, home_id) is None
+    elif home_id is None:
+        assert cameraHomeData.smokedetectorByName(name, home)["id"] == expected
+    elif home is None:
+        assert (
+            cameraHomeData.smokedetectorByName(name, home_id=home_id)["id"] == expected
+        )
+    else:
+        assert cameraHomeData.smokedetectorByName(name, home, home_id)["id"] == expected
diff --git a/tests/test_pyatmo_thermostat.py b/tests/test_pyatmo_thermostat.py
index 115391d..de930a2 100644
--- a/tests/test_pyatmo_thermostat.py
+++ b/tests/test_pyatmo_thermostat.py
@@ -1,6 +1,5 @@
 """Define tests for Thermostat module."""
 import json
-import logging
 
 import pytest
 
@@ -356,9 +355,11 @@ def test_HomeData_setThermmode(
         json=json_fixture,
         headers={"content-type": "application/json"},
     )
-    with caplog.at_level(logging.DEBUG):
-        homeStatus.setThermmode(home_id=home_id, mode=mode)
-        assert expected in caplog.text
+    res = homeStatus.setThermmode(home_id=home_id, mode=mode)
+    if "error" in res:
+        assert expected in res["error"]["message"]
+    else:
+        assert expected in res["status"]
 
 
 @pytest.mark.parametrize(
diff --git a/tests/test_pyatmo_weatherstation.py b/tests/test_pyatmo_weatherstation.py
index ac998eb..5072931 100644
--- a/tests/test_pyatmo_weatherstation.py
+++ b/tests/test_pyatmo_weatherstation.py
@@ -221,24 +221,41 @@ def test_WeatherStationData_moduleById(weatherStationData, mid, sid, expected):
 
 
 @pytest.mark.parametrize(
-    "module, expected",
+    "module, moduleId, expected",
     [
+        (
+            None,
+            "12:34:56:07:bb:3e",
+            [
+                "CO2",
+                "Humidity",
+                "Temperature",
+                "battery_percent",
+                "battery_vp",
+                "max_temp",
+                "min_temp",
+                "reachable",
+                "rf_status",
+            ],
+        ),
         (
             "Kitchen",
+            None,
             [
+                "CO2",
+                "Humidity",
+                "Temperature",
                 "battery_percent",
                 "battery_vp",
-                "co2",
-                "humidity",
                 "max_temp",
                 "min_temp",
                 "reachable",
                 "rf_status",
-                "temperature",
             ],
         ),
         (
             "Garden",
+            None,
             [
                 "battery_percent",
                 "battery_vp",
@@ -252,6 +269,7 @@ def test_WeatherStationData_moduleById(weatherStationData, mid, sid, expected):
         ),
         (
             "Yard",
+            None,
             [
                 "Rain",
                 "battery_percent",
@@ -264,37 +282,46 @@ def test_WeatherStationData_moduleById(weatherStationData, mid, sid, expected):
         ),
         (
             "NetatmoIndoor",
+            None,
             [
-                "co2",
-                "humidity",
+                "CO2",
+                "Humidity",
+                "Noise",
+                "Pressure",
+                "Temperature",
                 "max_temp",
                 "min_temp",
-                "noise",
-                "pressure",
                 "reachable",
-                "temperature",
                 "wifi_status",
             ],
         ),
         pytest.param(
             "12:34:56:07:bb:3e",
             None,
+            None,
             marks=pytest.mark.xfail(reason="Invalid module names are not handled yet."),
         ),
         pytest.param(
             "",
             None,
+            None,
             marks=pytest.mark.xfail(reason="Invalid module names are not handled yet."),
         ),
         pytest.param(
+            None,
             None,
             None,
             marks=pytest.mark.xfail(reason="Invalid module names are not handled yet."),
         ),
     ],
 )
-def test_WeatherStationData_monitoredConditions(weatherStationData, module, expected):
-    assert sorted(weatherStationData.monitoredConditions(module)) == expected
+def test_WeatherStationData_monitoredConditions(
+    weatherStationData, module, moduleId, expected
+):
+    assert (
+        sorted(weatherStationData.monitoredConditions(module=module, moduleId=moduleId))
+        == expected
+    )
 
 
 @freeze_time("2019-06-11")