Skip to content

Commit fb47587

Browse files
committed
Added new camera features, preparing for undocumented API
1 parent c12b608 commit fb47587

5 files changed

Lines changed: 172 additions & 70 deletions

File tree

lnetatmo.py

Lines changed: 116 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,37 @@ def getParameter(key, default):
8181
_GETEVENTSUNTIL_REQ = _BASE_URL + "api/geteventsuntil"
8282

8383

84+
#TODO# Undocumented (but would be very usefull) API : Access currently forbidden (403)
85+
86+
# _POST_UPDATE_HOME_REQ = _BASE_URL + "/api/updatehome"
87+
88+
# For presence setting (POST BODY):
89+
# _PRES_BODY_REC_SET = "home_id=%s&presence_settings[presence_record_%s]=%s" # (HomeId, DetectionKind, DetectionSetup.index)
90+
_PRES_DETECTION_KIND = ("humans", "animals", "vehicles", "movements")
91+
_PRES_DETECTION_SETUP = ("ignore", "record", "record & notify")
92+
93+
# _PRES_BODY_ALERT_TIME = "home_id=%s&presence_settings[presence_notify_%s]=%s" # (HomeID, "from"|"to", "hh:mm")
94+
95+
# Regular (documented) commands (both cameras)
96+
97+
_PRES_CDE_GET_SNAP = "/live/snapshot_720.jpg"
98+
99+
#TODO# Undocumented (taken from https://github.com/KiboOst/php-NetatmoCameraAPI/blob/master/class/NetatmoCameraAPI.php)
100+
# Work with local_url only (undocumented scope control probably)
101+
102+
# For Presence camera
103+
104+
_PRES_CDE_GET_LIGHT = "/command/floodlight_get_config"
105+
# Not working yet, probably due to scope restriction
106+
#_PRES_CDE_SET_LIGHT = "/command/floodlight_set_config?config=mode:%s" # "auto"|"on"|"off"
107+
108+
109+
# For all cameras
110+
111+
_CAM_CHANGE_STATUS = "/command/changestatus?status=%s" # "on"|"off"
112+
# Not working yet
113+
#_CAM_FTP_ACTIVE = "/command/ftp_set_config?config=on_off:%s" # "on"|"off"
114+
84115

85116
class NoDevice( Exception ):
86117
pass
@@ -106,7 +137,7 @@ def __init__(self, clientId=_CLIENT_ID,
106137
clientSecret=_CLIENT_SECRET,
107138
username=_USERNAME,
108139
password=_PASSWORD,
109-
scope="read_station read_camera access_camera read_presence access_presence"):
140+
scope="read_station read_camera access_camera write_camera read_presence access_presence write_presence"):
110141

111142
postParams = {
112143
"grant_type" : "password",
@@ -325,27 +356,31 @@ def __init__(self, authData):
325356
}
326357
resp = postRequest(_GETHOMEDATA_REQ, postParams)
327358
self.rawData = resp['body']
359+
# Collect homes
328360
self.homes = { d['id'] : d for d in self.rawData['homes'] }
329361
if not self.homes : raise NoDevice("No home available")
362+
self.default_home = list(self.homes.values())[0]['name']
363+
# Split homes data by category
330364
self.persons = dict()
331365
self.events = dict()
332366
self.cameras = dict()
333367
self.lastEvent = dict()
334368
for i in range(len(self.rawData['homes'])):
335-
nameHome=self.rawData['homes'][i]['name']
369+
curHome = self.rawData['homes'][i]
370+
nameHome = curHome['name']
336371
if nameHome not in self.cameras:
337-
self.cameras[nameHome]=dict()
338-
for p in self.rawData['homes'][i]['persons']:
372+
self.cameras[nameHome] = dict()
373+
for p in curHome['persons']:
339374
self.persons[ p['id'] ] = p
340-
for e in self.rawData['homes'][i]['events']:
375+
for e in curHome['events']:
341376
if e['camera_id'] not in self.events:
342377
self.events[ e['camera_id'] ] = dict()
343378
self.events[ e['camera_id'] ][ e['time'] ] = e
344-
for c in self.rawData['homes'][i]['cameras']:
379+
for c in curHome['cameras']:
345380
self.cameras[nameHome][ c['id'] ] = c
381+
c["home_id"] = curHome['id']
346382
for camera in self.events:
347-
self.lastEvent[camera]=self.events[camera][sorted(self.events[camera])[-1]]
348-
self.default_home = list(self.homes.values())[0]['name']
383+
self.lastEvent[camera] = self.events[camera][sorted(self.events[camera])[-1]]
349384
if not self.cameras[self.default_home] : raise NoDevice("No camera available in default home")
350385
self.default_camera = list(self.cameras[self.default_home].values())[0]
351386

@@ -386,6 +421,8 @@ def cameraUrls(self, camera=None, home=None, cid=None):
386421
"""
387422
Return the vpn_url and the local_url (if available) of a given camera
388423
in order to access to its live feed
424+
Can't use the is_local property which is mostly false in case of operator
425+
dynamic IP change after presence start sequence
389426
"""
390427
local_url = None
391428
vpn_url = None
@@ -395,13 +432,18 @@ def cameraUrls(self, camera=None, home=None, cid=None):
395432
camera_data=self.cameraByName(camera=camera, home=home)
396433
if camera_data:
397434
vpn_url = camera_data['vpn_url']
398-
resp = postRequest('{0}/command/ping'.format(camera_data['vpn_url']),dict())
435+
resp = postRequest(vpn_url + '/command/ping')
399436
temp_local_url=resp['local_url']
400-
resp = postRequest('{0}/command/ping'.format(temp_local_url),dict())
401-
if temp_local_url == resp['local_url']:
437+
resp = postRequest(temp_local_url + '/command/ping',timeout=1)
438+
if resp and temp_local_url == resp['local_url']:
402439
local_url = temp_local_url
403440
return vpn_url, local_url
404441

442+
def url(self, camera=None, home=None, cid=None):
443+
vpn_url, local_url = self.cameraUrls(camera, home, cid)
444+
# Return local if available else vpn
445+
return local_url or vpn_url
446+
405447
def personsAtHome(self, home=None):
406448
"""
407449
Return the list of known persons who are currently at home
@@ -533,6 +575,47 @@ def motionDetected(self, home=None, camera=None):
533575
return True
534576
return False
535577

578+
def presenceUrl(self, camera=None, home=None, cid=None, setting=None):
579+
camera = self.cameraByName(home=home, camera=camera) or self.cameraById(cid=cid)
580+
if camera["type"] != "NOC": return None # Not a presence camera
581+
vpnUrl, localUrl = self.cameraUrls(cid=camera["id"])
582+
return localUrl
583+
584+
def presenceLight(self, camera=None, home=None, cid=None, setting=None):
585+
url = self.presenceUrl(home=home, camera=camera) or self.cameraById(cid=cid)
586+
if not url or setting not in ("on", "off", "auto"): return None
587+
if setting : return "Currently unsupported"
588+
return cameraCommand(url, _PRES_CDE_GET_LIGHT)["mode"]
589+
# Not yet supported
590+
#if not setting: return cameraCommand(url, _PRES_CDE_GET_LIGHT)["mode"]
591+
#else: return cameraCommand(url, _PRES_CDE_SET_LIGHT, setting)
592+
593+
def presenceStatus(self, mode, camera=None, home=None, cid=None):
594+
url = self.presenceUrl(home=home, camera=camera) or self.cameraById(cid=cid)
595+
if not url or mode not in ("on", "off") : return None
596+
r = cameraCommand(url, _CAM_CHANGE_STATUS, mode)
597+
return mode if r["status"] == "ok" else None
598+
599+
def presenceSetAction(self, camera=None, home=None, cid=None,
600+
eventType=_PRES_DETECTION_KIND[0], action=2):
601+
return "Currently unsupported"
602+
if eventType not in _PRES_DETECTION_KIND or \
603+
action not in _PRES_DETECTION_SETUP : return None
604+
camera = self.cameraByName(home=home, camera=camera) or self.cameraById(cid=cid)
605+
postParams = { "access_token" : self.getAuthToken,
606+
"home_id" : camera["home_id"],
607+
"presence_settings[presence_record_%s]" % eventType : _PRES_DETECTION_SETUP.index(action)
608+
}
609+
resp = postRequest(_POST_UPDATE_HOME_REQ, postParams)
610+
self.rawData = resp['body']
611+
612+
def getLiveSnapshot(self, camera=None, home=None, cid=None):
613+
camera = self.cameraByName(home=home, camera=camera) or self.cameraById(cid=cid)
614+
vpnUrl, localUrl = self.cameraUrls(cid=camera["id"])
615+
url = localUrl or vpnUrl
616+
return cameraCommand(url, _PRES_CDE_GET_SNAP)
617+
618+
536619
class WelcomeData(HomeData):
537620
"""
538621
This class is now deprecated. Use HomeData instead
@@ -544,17 +627,29 @@ class WelcomeData(HomeData):
544627

545628
# Utilities routines
546629

547-
def postRequest(url, params):
630+
def cameraCommand(cameraUrl, commande, parameters=None, timeout=3):
631+
url = cameraUrl + ( commande % parameters if parameters else commande)
632+
return postRequest(url, timeout=timeout)
633+
634+
def postRequest(url, params=None, timeout=10):
548635
if version_info.major == 3:
549636
req = urllib.request.Request(url)
550-
req.add_header("Content-Type","application/x-www-form-urlencoded;charset=utf-8")
551-
params = urllib.parse.urlencode(params).encode('utf-8')
552-
resp = urllib.request.urlopen(req, params)
637+
if params:
638+
req.add_header("Content-Type","application/x-www-form-urlencoded;charset=utf-8")
639+
params = urllib.parse.urlencode(params).encode('utf-8')
640+
try:
641+
resp = urllib.request.urlopen(req, params, timeout=timeout) if params else urllib.request.urlopen(req, timeout=timeout)
642+
except urllib.error.URLError:
643+
return None
553644
else:
554-
params = urlencode(params)
555-
headers = {"Content-Type" : "application/x-www-form-urlencoded;charset=utf-8"}
556-
req = urllib2.Request(url=url, data=params, headers=headers)
557-
resp = urllib2.urlopen(req)
645+
if params:
646+
params = urlencode(params)
647+
headers = {"Content-Type" : "application/x-www-form-urlencoded;charset=utf-8"}
648+
req = urllib2.Request(url=url, data=params, headers=headers) if params else urllib2.Request(url)
649+
try:
650+
resp = urllib2.urlopen(req, timeout=timeout)
651+
except urllib2.URLError:
652+
return None
558653
data = b""
559654
for buff in iter(lambda: resp.read(65535), b''): data += buff
560655
# Return values in bytes if not json data to handle properly camera images
@@ -618,7 +713,8 @@ def getStationMinMaxTH(station=None, module=None):
618713

619714

620715
try:
621-
Homes = HomeData(authorization)
716+
homes = HomeData(authorization)
717+
with open("t.jpg", "wb") as f: f.write(homes.getLiveSnapshot(camera="LachPresence1"))
622718
except NoDevice :
623719
if stdout.isatty():
624720
print("lnetatmo.py : warning, no home available for testing")

samples/get_direct_camera_snapshot

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/python3
2+
3+
# Locate the camera name "MyCam" IP on the local LAN
4+
# to collect a snapshot of what the camera sees
5+
# If available use a local connection to save internet bandwith
6+
7+
8+
from sys import exit
9+
import lnetatmo
10+
11+
# The name I gave the camera in the Netatmo Security App
12+
MY_CAMERA = "MyCam"
13+
14+
# Authenticate (see authentication in documentation)
15+
# Note you will need the appropriate scope (read_welcome access_welcome or read_presence access_presence)
16+
# depending of the camera you are trying to reach
17+
# The default library scope ask for all aceess to all cameras
18+
authorization = lnetatmo.ClientAuth()
19+
20+
# Gather Home information (available cameras and other infos)
21+
homeData = lnetatmo.HomeData(authorization)
22+
23+
# Request a snapshot from the camera
24+
snapshot = homeData.getLiveSnapshot( camera=MY_CAMERA )
25+
26+
# If all was Ok, I should have an image, if None there was an error
27+
if not snapshot :
28+
# Decide what to do with an error situation (alert, log, ...)
29+
exit(1)
30+
31+
# You can then archive the snapshot, send it by mail, message App, ...
32+
# Example : Save the snapshot in a file
33+
with open("MyCamSnap.jpg", "wb") as f: f.write(snapshot)
34+
35+
exit(0)

samples/get_direct_camera_snapshot.py

Lines changed: 0 additions & 46 deletions
This file was deleted.

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
setup(
66
name='lnetatmo',
7-
version='1.3.5',
7+
version='1.4.1',
88
classifiers=[
99
'Development Status :: 5 - Production/Stable',
1010
'Intended Audience :: Developers',
@@ -17,7 +17,7 @@
1717
scripts=[],
1818
data_files=[],
1919
url='https://github.com/philippelt/netatmo-api-python',
20-
download_url='https://github.com/philippelt/netatmo-api-python/tarball/v1.3.5.tar.gz',
20+
download_url='https://github.com/philippelt/netatmo-api-python/tarball/v1.4.1.tar.gz',
2121
license='GPL V3',
2222
description='Simple API to access Netatmo weather station data from any python script.'
2323
)

usage.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,12 @@ Methods :
383383
* Output : camera dictionary or None
384384

385385
* **cameraUrls** (camera=None, home=None, cid=None) : return Urls to access camera live feed
386-
* Input : camera name and home name or cameraID to lookup (str)
387-
* Output : tuple with the vpn_url (for remote access) and local url to access the camera live feed
386+
* Input : camera name and optional home name or cameraID to lookup (str)
387+
* Output : tuple with the vpn_url (for remote access) and local url to access the camera (commands)
388+
389+
* **url** (camera=None, home=None, cid=None) : return the best url to access camera live feed
390+
* Input : camera name and optional home name or cameraID to lookup (str)
391+
* Output : the local url if available to reduce internet bandwith usage else the vpn url
388392

389393
* **personsAtHome** (home=None) : return the list of known persons who are at home
390394
* Input : home name to lookup (str)
@@ -409,6 +413,19 @@ Methods :
409413

410414
* **motionDetected** (home=None, camera=None) : Return true is a movement has been detected in the last event
411415

416+
* **presenceLight** (camera=None, home=None, cid=None, setting=None) : return or set the Presence camera lighting mode
417+
* Input : camera name and optional home name or cameraID to lookup (str), setting must be None|auto|on|off. *currently not supported*
418+
* Output : setting requested if supplied else current camera setting
419+
420+
* **presenceStatus** (mode, camera=None, home=None, cid=None) : set the camera on or off (current status in camera properties)
421+
* Input : mode (on|off) (str), camera name and optional home name or cameraID to lookup (str)
422+
* Output : requested mode if changed else None
423+
424+
* **getLiveSnapshot** (camera=None, home=None, cid=None) : Get a jpeg of current live view of the camera
425+
* Input : camera name and optional home name or cameraID to lookup (str)
426+
* Output : jpeg binary content
427+
428+
412429
#### 4-5 Utilities functions ####
413430

414431

0 commit comments

Comments
 (0)