Skip to content

Commit fc5b1d1

Browse files
steinbachtillsteinbach
authored andcommitted
Add support for getting images from the API and saving those to files or ASCII strings on the commandline
1 parent 313cf48 commit fc5b1d1

File tree

6 files changed

+229
-13
lines changed

6 files changed

+229
-13
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44
## [Unreleased]
55
- No unreleased changes so far
66

7+
## [0.12.0] - 2021-07-05
8+
### Added
9+
- Possibility to retrieve images and save attributes to files on disk
10+
711
## [0.11.1] - 2021-07-03
812
### Fixed
913
- Addressing of statuses
@@ -130,7 +134,8 @@ Minor fix in observer interface
130134
## [0.1.0] - 2021-05-26
131135
Initial release
132136

133-
[unreleased]: https://github.com/tillsteinbach/WeConnect-python/compare/v0.11.1...HEAD
137+
[unreleased]: https://github.com/tillsteinbach/WeConnect-python/compare/v0.12.0...HEAD
138+
[0.12.0]: https://github.com/tillsteinbach/WeConnect-python/releases/tag/v0.12.0
134139
[0.11.1]: https://github.com/tillsteinbach/WeConnect-python/releases/tag/v0.11.1
135140
[0.11.0]: https://github.com/tillsteinbach/WeConnect-python/releases/tag/v0.11.0
136141
[0.10.0]: https://github.com/tillsteinbach/WeConnect-python/releases/tag/v0.10.0

requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
requests>=2.23.0
1+
requests>=2.23.0
2+
pillow>=8.3.0
3+
ascii_magic>=1.5.5

weconnect/addressable.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from enum import Enum, IntEnum, Flag, auto
44
from typing import Dict, List
55

6-
from .util import toBool
6+
from PIL import Image
7+
import ascii_magic
8+
9+
from .util import toBool, imgToASCIIArt
710

811
LOG = logging.getLogger("weconnect")
912

@@ -181,11 +184,47 @@ def isLeaf(self): # pylint: disable=R0201
181184
def getLeafChildren(self):
182185
return [self]
183186

187+
def saveToFile(self, filename):
188+
if filename.endswith(('.txt', '.TXT', '.text')):
189+
with open(filename, mode='w') as file:
190+
if self.valueType == Image.Image:
191+
file.write(imgToASCIIArt(self.value, columns=120, mode=ascii_magic.Modes.ASCII))
192+
else:
193+
file.write(str(self))
194+
elif filename.endswith(('.htm', '.HTM', '.html', '.HTML')):
195+
with open(filename, mode='w') as file:
196+
if self.valueType == Image.Image:
197+
html = """<!DOCTYPE html><head><title>ASCII art</title></head><body><pre style="display: inline-block; border-width: 4px 6px;
198+
border-color: black; border-style: solid; background-color:black; font-size: 8px;">"""
199+
file.write(html)
200+
file.write(imgToASCIIArt(self.value, columns=240, mode=ascii_magic.Modes.HTML))
201+
file.write('<pre/></body></html>')
202+
else:
203+
file.write(str(self))
204+
elif filename.endswith(('.png', '.PNG')):
205+
with open(filename, mode='wb') as file:
206+
if self.valueType == Image.Image:
207+
self.value.save(fp=file, format='PNG') # pylint: disable=no-member
208+
else:
209+
raise ValueError('Attribute is no image and cannot be converted to one')
210+
elif filename.endswith(('.jpg', '.JPG', '.jpeg', '.JPEG')):
211+
with open(filename, mode='wb') as file:
212+
if self.valueType == Image.Image:
213+
if self.value.mode in ("RGBA", "P"): # pylint: disable=no-member
214+
raise ValueError('Image contains transparency and thus cannot be saved as jpeg-file')
215+
self.value.save(fp=file, format='JPEG') # pylint: disable=no-member
216+
else:
217+
raise ValueError('Attribute is no image and cannot be converted to one')
218+
else:
219+
raise ValueError('I cannot recognize the target file extension')
220+
184221
def __str__(self):
185222
if isinstance(self.value, Enum):
186223
return str(self.value.value) # pylint: disable=no-member
187224
if isinstance(self.value, datetime):
188225
return self.value.isoformat() # pylint: disable=no-member
226+
if isinstance(self.value, Image.Image):
227+
return imgToASCIIArt(self.value) # pylint: disable=no-member
189228
return str(self.value)
190229

191230

weconnect/elements.py

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from enum import Enum
55
from datetime import datetime, timedelta, timezone
66
from typing import Dict
7-
7+
import base64
8+
import io
89
import requests
10+
from PIL import Image
911

1012
from .util import robustTimeParse, toBool
1113
from .addressable import AddressableLeaf, AddressableObject, AddressableAttribute, AddressableDict, AddressableList, \
@@ -31,6 +33,8 @@ def __init__(
3133
parent,
3234
fromDict,
3335
fixAPI=True,
36+
updateCapabilities=True,
37+
updatePictures=True,
3438
):
3539
self.weConnect = weConnect
3640
super().__init__(localAddress=vin, parent=parent)
@@ -47,12 +51,16 @@ def __init__(
4751
self.controls = Controls(localAddress='controls', vehicle=self, parent=self)
4852
self.fixAPI = fixAPI
4953

50-
self.update(fromDict)
54+
self.__carImages = dict()
55+
self.pictures = AddressableDict(localAddress='pictures', parent=self)
56+
57+
self.update(fromDict, updateCapabilities=updateCapabilities, updatePictures=updatePictures)
5158

5259
def update( # noqa: C901 # pylint: disable=too-many-branches
5360
self,
5461
fromDict=None,
5562
updateCapabilities=True,
63+
updatePictures=True,
5664
force=False,
5765
):
5866
if fromDict is not None:
@@ -145,7 +153,8 @@ def update( # noqa: C901 # pylint: disable=too-many-branches
145153
LOG.warning('%s: Unknown attribute %s with value %s', self.getGlobalAddress(), key, value)
146154

147155
self.updateStatus(updateCapabilities=updateCapabilities, force=force)
148-
# self.test()
156+
if updatePictures:
157+
self.updatePictures()
149158

150159
def updateStatus(self, updateCapabilities=True, force=False): # noqa: C901 # pylint: disable=too-many-branches
151160
data = None
@@ -288,10 +297,145 @@ def updateStatus(self, updateCapabilities=True, force=False): # noqa: C901 # py
288297
parent=self.statuses,
289298
statusId='parkingPosition',
290299
fromDict=data['data'])
291-
return
292-
if 'parkingPosition' in self.statuses:
300+
elif 'parkingPosition' in self.statuses:
293301
del self.statuses['parkingPosition']
294302

303+
def updatePictures(self): # noqa: C901
304+
data = None
305+
cacheDate = None
306+
url = f'https://vehicle-images-service.apps.emea.vwapps.io/v2/vehicle-images/{self.vin.value}?resolution=2x'
307+
if self.weConnect.maxAge is not None and self.weConnect.cache is not None and url in self.weConnect.cache:
308+
data, cacheDateString = self.weConnect.cache[url]
309+
cacheDate = datetime.fromisoformat(cacheDateString)
310+
if data is None or self.weConnect.maxAge is None \
311+
or (cacheDate is not None and cacheDate < (datetime.utcnow() - timedelta(seconds=self.weConnect.maxAge))):
312+
imageResponse = self.weConnect.session.get(url, allow_redirects=False)
313+
if imageResponse.status_code == requests.codes['ok']:
314+
data = imageResponse.json()
315+
if self.weConnect.cache is not None:
316+
self.weConnect.cache[url] = (data, str(datetime.utcnow()))
317+
elif imageResponse.status_code == requests.codes['unauthorized']:
318+
LOG.info('Server asks for new authorization')
319+
self.weConnect.login()
320+
imageResponse = self.weConnect.session.get(url, allow_redirects=False)
321+
if imageResponse.status_code == requests.codes['ok']:
322+
data = imageResponse.json()
323+
if self.weConnect.cache is not None:
324+
self.weConnect.cache[url] = (data, str(datetime.utcnow()))
325+
else:
326+
raise RetrievalError('Could not retrieve data even after re-authorization.'
327+
f' Status Code was: {imageResponse.status_code}')
328+
raise RetrievalError(f'Could not retrieve data. Status Code was: {imageResponse.status_code}')
329+
if data is not None and 'data' in data: # pylint: disable=too-many-nested-blocks
330+
for image in data['data']:
331+
img = None
332+
cacheDate = None
333+
url = image['url']
334+
if self.weConnect.maxAge is not None and self.weConnect.cache is not None and url in self.weConnect.cache:
335+
img, cacheDateString = self.weConnect.cache[url]
336+
img = base64.b64decode(img)
337+
img = Image.open(io.BytesIO(img))
338+
cacheDate = datetime.fromisoformat(cacheDateString)
339+
if img is None or self.weConnect.maxAge is None \
340+
or (cacheDate is not None and cacheDate < (datetime.utcnow() - timedelta(days=1))):
341+
imageDownloadResponse = self.weConnect.session.get(url, stream=True)
342+
if imageDownloadResponse.status_code == requests.codes['ok']:
343+
img = Image.open(imageDownloadResponse.raw)
344+
if self.weConnect.cache is not None:
345+
buffered = io.BytesIO()
346+
img.save(buffered, format="PNG")
347+
imgStr = base64.b64encode(buffered.getvalue()).decode("utf-8")
348+
self.weConnect.cache[url] = (imgStr, str(datetime.utcnow()))
349+
elif imageDownloadResponse.status_code == requests.codes['unauthorized']:
350+
LOG.info('Server asks for new authorization')
351+
self.weConnect.login()
352+
imageDownloadResponse = self.weConnect.session.get(url, stream=True)
353+
if imageDownloadResponse.status_code == requests.codes['ok']:
354+
img = Image.open(imageDownloadResponse.raw)
355+
if self.weConnect.cache is not None:
356+
buffered = io.BytesIO()
357+
img.save(buffered, format="PNG")
358+
imgStr = base64.b64encode(buffered.getvalue()).decode("utf-8")
359+
self.weConnect.cache[url] = (imgStr, str(datetime.utcnow()))
360+
else:
361+
raise RetrievalError('Could not retrieve data even after re-authorization.'
362+
f' Status Code was: {imageDownloadResponse.status_code}')
363+
raise RetrievalError(f'Could not retrieve data. Status Code was: {imageDownloadResponse.status_code}')
364+
365+
if img is not None:
366+
self.__carImages[image['id']] = img
367+
if image['id'] == 'car_34view':
368+
if 'car' in self.pictures:
369+
self.pictures['car'].setValueWithCarTime(self.__carImages['car_34view'], lastUpdateFromCar=None, fromServer=True)
370+
else:
371+
self.pictures['car'] = AddressableAttribute(localAddress='car', parent=self.pictures, value=self.__carImages['car_34view'],
372+
valueType=Image.Image)
373+
374+
self.updateStatusPicture()
375+
376+
def updateStatusPicture(self): # noqa: C901
377+
img = self.__carImages['car_birdview']
378+
379+
if 'accessStatus' in self.statuses:
380+
accessStatus = self.statuses['accessStatus']
381+
for name, door in accessStatus.doors.items():
382+
doorNameMap = {'frontLeft': 'door_left_front',
383+
'frontRight': 'door_right_front',
384+
'rearLeft': 'door_left_back',
385+
'rearRight': 'door_right_back'}
386+
name = doorNameMap.get(name, name)
387+
doorImageName = None
388+
389+
if door.openState.value in (AccessStatus.Door.OpenState.OPEN, AccessStatus.Door.OpenState.INVALID):
390+
doorImageName = f'{name}_overlay'
391+
elif door.openState.value == AccessStatus.Door.OpenState.CLOSED:
392+
doorImageName = name
393+
394+
if doorImageName is not None and doorImageName in self.__carImages:
395+
doorImage = self.__carImages[doorImageName].convert("RGBA")
396+
img.paste(doorImage, (0, 0), doorImage)
397+
398+
for name, window in accessStatus.windows.items():
399+
windowNameMap = {'frontLeft': 'window_left_front',
400+
'frontRight': 'window_right_front',
401+
'rearLeft': 'window_left_back',
402+
'rearRight': 'window_right_back',
403+
'sunRoof': 'sunroof'}
404+
name = windowNameMap.get(name, name)
405+
windowImageName = None
406+
407+
if window.openState.value in (AccessStatus.Window.OpenState.OPEN, AccessStatus.Window.OpenState.INVALID):
408+
windowImageName = f'{name}_overlay'
409+
elif window.openState.value == AccessStatus.Window.OpenState.CLOSED:
410+
windowImageName = f'{name}'
411+
412+
if windowImageName is not None and windowImageName in self.__carImages:
413+
windowImage = self.__carImages[windowImageName].convert("RGBA")
414+
img.paste(windowImage, (0, 0), windowImage)
415+
416+
if 'lightsStatus' in self.statuses:
417+
lightsStatus = self.statuses['lightsStatus']
418+
for name, light in lightsStatus.lights.items():
419+
lightNameMap = {'frontLeft': 'door_left_front',
420+
'frontRight': 'door_right_front',
421+
'rearLeft': 'door_left_back',
422+
'rearRight': 'door_right_back'}
423+
name = lightNameMap.get(name, name)
424+
lightImageName = None
425+
426+
if light.status.value == LightsStatus.Light.LightState.ON:
427+
lightImageName = f'light_{name}'
428+
if lightImageName in self.__carImages:
429+
lightImage = self.__carImages[lightImageName].convert("RGBA")
430+
img.paste(lightImage, (0, 0), lightImage)
431+
432+
self.__carImages['status'] = img
433+
434+
if 'status' in self.pictures:
435+
self.pictures['status'].setValueWithCarTime(img, lastUpdateFromCar=None, fromServer=True)
436+
else:
437+
self.pictures['status'] = AddressableAttribute(localAddress='status', parent=self.pictures, value=img, valueType=Image.Image)
438+
295439
def __str__(self): # noqa: C901
296440
returnString = ''
297441
if self.vin.enabled:

weconnect/util.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import re
22
from datetime import datetime
33

4+
import shutil
5+
6+
from PIL import Image
7+
import ascii_magic
8+
49

510
def robustTimeParse(timeString):
611
timestring = timeString.replace('Z', '+00:00')
@@ -17,3 +22,24 @@ def toBool(value):
1722
if value in [False, 'False', 'false', 'no']:
1823
return False
1924
raise ValueError('Not a valid boolean value (True/False)')
25+
26+
27+
def imgToASCIIArt(img, columns=0, mode=ascii_magic.Modes.TERMINAL):
28+
bbox = img.getbbox()
29+
30+
# Crop the image to the contents of the bounding box
31+
image = img.crop(bbox)
32+
33+
# Determine the width and height of the cropped image
34+
(width, height) = image.size
35+
36+
# Create a new image object for the output image
37+
cropped_image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
38+
39+
# Paste the cropped image onto the new image
40+
cropped_image.paste(image, (0, 0))
41+
42+
if columns == 0:
43+
columns = shutil.get_terminal_size()[0]
44+
45+
return ascii_magic.from_image(cropped_image, columns=columns, mode=mode)

weconnect/weconnect.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -397,11 +397,11 @@ def __refreshToken(self):
397397
def vehicles(self):
398398
return self.__vehicles
399399

400-
def update(self, force=False):
401-
self.updateVehicles(force=force)
400+
def update(self, updateCapabilities=True, updatePictures=True, force=False):
401+
self.updateVehicles(updateCapabilities=updateCapabilities, updatePictures=updatePictures, force=force)
402402
self.updateChargingStations(force=force)
403403

404-
def updateVehicles(self, updateCapabilities=True, force=False): # noqa: C901
404+
def updateVehicles(self, updateCapabilities=True, updatePictures=True, force=False): # noqa: C901
405405
data = None
406406
cacheDate = None
407407
url = 'https://mobileapi.apps.emea.vwapps.io/vehicles'
@@ -436,10 +436,10 @@ def updateVehicles(self, updateCapabilities=True, force=False): # noqa: C901
436436
vins.append(vin)
437437
if vin not in self.__vehicles:
438438
vehicle = Vehicle(weConnect=self, vin=vin, parent=self.__vehicles, fromDict=vehicleDict,
439-
fixAPI=self.fixAPI)
439+
fixAPI=self.fixAPI, updateCapabilities=updateCapabilities, updatePictures=updatePictures)
440440
self.__vehicles[vin] = vehicle
441441
else:
442-
self.__vehicles[vin].update(fromDict=vehicleDict, updateCapabilities=updateCapabilities)
442+
self.__vehicles[vin].update(fromDict=vehicleDict, updateCapabilities=updateCapabilities, updatePictures=updatePictures)
443443
# delete those vins that are not anymore available
444444
for vin in [vin for vin in vins if vin not in self.__vehicles]:
445445
del self.__vehicles[vin]

0 commit comments

Comments
 (0)