# vim: set et sw=4 sts=4 fileencoding=utf-8:
#
# Raspberry Pi Sense HAT Emulator library for the Raspberry Pi
# Copyright (c) 2016 Raspberry Pi Foundation <info@raspberrypi.org>
#
# This package is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation; either version 2.1 of the License, or (at your option)
# any later version.
#
# This package is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
#!/usr/bin/python
from __future__ import (
unicode_literals,
absolute_import,
print_function,
division,
)
nstr = str
str = type('')
import struct
import os
import sys
import math
import time
import numpy as np
import shutil
import glob
import array
import struct
import subprocess
import warnings
from PIL import Image # pillow
from copy import deepcopy
from . import RTIMU
from .lock import EmulatorLock
from .stick import SenseStick
from .screen import init_screen, GAMMA_DEFAULT, GAMMA_LOW
[docs]class SenseHat(object):
"""
The main interface the Raspberry Pi Sense HAT.
This class provides properties to query the various sensors on the Sense
HAT (:attr:`temp`, :attr:`pressure`, :attr:`humidity`, :attr:`gyro`, etc.)
and methods to control the LED "screen" on the HAT (:meth:`set_pixel`,
:meth:`set_pixels`).
The *imu_settings_file* parameter specifies the base name of the
configuration file used to calibrate the sensors on the HAT. An ".ini"
suffix will be implicitly added to this filename. If a file with the
resulting name is present in :file:`~/.config/sense_hat`, it will be used
in the configuration. Otherwise, the file will be located within
:file:`/etc`, and will be copied to :file:`~/.config/sense_hat` before use.
The *text_assets* parameter provides the base name of the PNG image and
text file which will be used to define the font used by the
:meth:`show_message` method.
"""
SENSE_HAT_FB_NAME = 'RPi-Sense FB'
SENSE_HAT_FB_FBIOGET_GAMMA = 61696
SENSE_HAT_FB_FBIOSET_GAMMA = 61697
SENSE_HAT_FB_FBIORESET_GAMMA = 61698
SENSE_HAT_FB_GAMMA_DEFAULT = 0
SENSE_HAT_FB_GAMMA_LOW = 1
SENSE_HAT_FB_GAMMA_USER = 2
SETTINGS_HOME_PATH = '.config/sense_hat'
def __init__(
self,
imu_settings_file='RTIMULib',
text_assets='sense_hat_text'
):
lock = EmulatorLock('sense_emu')
if not lock.wait(1):
warnings.warn(Warning('No emulator detected; spawning sense_emu_gui'))
subprocess.Popen(['sense_emu_gui'])
self._fb_device = self._get_fb_device()
if self._fb_device is None:
raise OSError('Cannot detect %s device' % self.SENSE_HAT_FB_NAME)
# 0 is With B+ HDMI port facing downwards
pix_map0 = np.array([
[0, 1, 2, 3, 4, 5, 6, 7],
[8, 9, 10, 11, 12, 13, 14, 15],
[16, 17, 18, 19, 20, 21, 22, 23],
[24, 25, 26, 27, 28, 29, 30, 31],
[32, 33, 34, 35, 36, 37, 38, 39],
[40, 41, 42, 43, 44, 45, 46, 47],
[48, 49, 50, 51, 52, 53, 54, 55],
[56, 57, 58, 59, 60, 61, 62, 63]
], int)
pix_map90 = np.rot90(pix_map0)
pix_map180 = np.rot90(pix_map90)
pix_map270 = np.rot90(pix_map180)
self._pix_map = {
0: pix_map0,
90: pix_map90,
180: pix_map180,
270: pix_map270
}
self._rotation = 0
# Load text assets
dir_path = os.path.dirname(__file__)
self._load_text_assets(
os.path.join(dir_path, '%s.png' % text_assets),
os.path.join(dir_path, '%s.txt' % text_assets)
)
# Load IMU settings and calibration data
self._imu_settings = self._get_settings_file(imu_settings_file)
self._imu = RTIMU.RTIMU(self._imu_settings)
self._imu_init = False # Will be initialised as and when needed
self._pressure = RTIMU.RTPressure(self._imu_settings)
self._pressure_init = False # Will be initialised as and when needed
self._humidity = RTIMU.RTHumidity(self._imu_settings)
self._humidity_init = False # Will be initialised as and when needed
self._last_orientation = {'pitch': 0, 'roll': 0, 'yaw': 0}
raw = {'x': 0, 'y': 0, 'z': 0}
self._last_compass_raw = deepcopy(raw)
self._last_gyro_raw = deepcopy(raw)
self._last_accel_raw = deepcopy(raw)
self._compass_enabled = False
self._gyro_enabled = False
self._accel_enabled = False
self._stick = SenseStick()
####
# Text assets
####
# Text asset files are rotated right through 90 degrees to allow blocks of
# 40 contiguous pixels to represent one 5 x 8 character. These are stored
# in a 8 x 640 pixel png image with characters arranged adjacently
# Consequently we must rotate the pixel map left through 90 degrees to
# compensate when drawing text
def _load_text_assets(self, text_image_file, text_file):
"""
Internal. Builds a character indexed dictionary of pixels used by the
show_message function below
"""
text_pixels = self.load_image(text_image_file, False)
with open(text_file, 'r') as f:
loaded_text = f.read()
self._text_dict = {}
for index, s in enumerate(loaded_text):
start = index * 40
end = start + 40
char = text_pixels[start:end]
self._text_dict[s] = char
def _trim_whitespace(self, char): # For loading text assets only
"""
Internal. Trims white space pixels from the front and back of loaded
text characters
"""
psum = lambda x: sum(sum(x, []))
if psum(char) > 0:
is_empty = True
while is_empty: # From front
row = char[0:8]
is_empty = psum(row) == 0
if is_empty:
del char[0:8]
is_empty = True
while is_empty: # From back
row = char[-8:]
is_empty = psum(row) == 0
if is_empty:
del char[-8:]
return char
def _get_settings_file(self, imu_settings_file):
"""
Internal. Logic to check for a system wide RTIMU ini file. This is
copied to the home folder if one is not already found there.
"""
ini_file = '%s.ini' % imu_settings_file
home_dir = os.path.expanduser('~')
home_path = os.path.join(home_dir, self.SETTINGS_HOME_PATH)
if not os.path.exists(home_path):
os.makedirs(home_path)
home_file = os.path.join(home_path, ini_file)
home_exists = os.path.isfile(home_file)
system_file = os.path.join('/etc', ini_file)
system_exists = os.path.isfile(system_file)
if system_exists and not home_exists:
shutil.copyfile(system_file, home_file)
return RTIMU.Settings(os.path.join(home_path, imu_settings_file)) # RTIMU will add .ini internally
def _get_fb_device(self):
"""
Internal. Finds the correct frame buffer device for the sense HAT
and returns its /dev name.
"""
fd = init_screen()
result = fd.name
fd.close()
return result
####
# Joystick
####
@property
def stick(self):
"""
A :class:`SenseStick` object representing the Sense HAT's joystick.
"""
return self._stick
####
# LED Matrix
####
@property
def rotation(self):
return self._rotation
@rotation.setter
def rotation(self, r):
self.set_rotation(r, True)
[docs] def set_rotation(self, r=0, redraw=True):
"""
Sets the LED matrix rotation for viewing, adjust if the Pi is upside
down or sideways. 0 is with the Pi HDMI port facing downwards
"""
if r in self._pix_map.keys():
if redraw:
pixel_list = self.get_pixels()
self._rotation = r
if redraw:
self.set_pixels(pixel_list)
else:
raise ValueError('Rotation must be 0, 90, 180 or 270 degrees')
def _pack_bin(self, pix):
"""
Internal. Encodes python list [R,G,B] into 16 bit RGB565
"""
r = (pix[0] >> 3) & 0x1F
g = (pix[1] >> 2) & 0x3F
b = (pix[2] >> 3) & 0x1F
bits16 = (r << 11) + (g << 5) + b
return struct.pack('H', bits16)
def _unpack_bin(self, packed):
"""
Internal. Decodes 16 bit RGB565 into python list [R,G,B]
"""
output = struct.unpack('H', packed)
bits16 = output[0]
r = (bits16 & 0xF800) >> 11
g = (bits16 & 0x7E0) >> 5
b = (bits16 & 0x1F)
return [int(r << 3), int(g << 2), int(b << 3)]
[docs] def flip_h(self, redraw=True):
"""
Flip LED matrix horizontal
"""
pixel_list = self.get_pixels()
flipped = []
for i in range(8):
offset = i * 8
flipped.extend(reversed(pixel_list[offset:offset + 8]))
if redraw:
self.set_pixels(flipped)
return flipped
[docs] def flip_v(self, redraw=True):
"""
Flip LED matrix vertical
"""
pixel_list = self.get_pixels()
flipped = []
for i in reversed(range(8)):
offset = i * 8
flipped.extend(pixel_list[offset:offset + 8])
if redraw:
self.set_pixels(flipped)
return flipped
[docs] def set_pixels(self, pixel_list):
"""
Accepts a list containing 64 smaller lists of ``[R,G,B]`` pixels and
updates the LED matrix. R,G,B elements must intergers between 0
and 255
"""
if len(pixel_list) != 64:
raise ValueError('Pixel lists must have 64 elements')
for index, pix in enumerate(pixel_list):
if len(pix) != 3:
raise ValueError('Pixel at index %d is invalid. Pixels must contain 3 elements: Red, Green and Blue' % index)
for element in pix:
if element > 255 or element < 0:
raise ValueError('Pixel at index %d is invalid. Pixel elements must be between 0 and 255' % index)
with open(self._fb_device, 'rb+') as f:
map = self._pix_map[self._rotation]
for index, pix in enumerate(pixel_list):
# Two bytes per pixel in fb memory, 16 bit RGB565
f.seek(map[index // 8][index % 8] * 2) # row, column
f.write(self._pack_bin(pix))
[docs] def get_pixels(self):
"""
Returns a list containing 64 smaller lists of ``[R,G,B]`` pixels
representing what is currently displayed on the LED matrix
"""
pixel_list = []
with open(self._fb_device, 'rb') as f:
map = self._pix_map[self._rotation]
for row in range(8):
for col in range(8):
# Two bytes per pixel in fb memory, 16 bit RGB565
f.seek(map[row][col] * 2) # row, column
pixel_list.append(self._unpack_bin(f.read(2)))
return pixel_list
[docs] def set_pixel(self, x, y, *args):
"""
Updates the single ``[R,G,B]`` pixel specified by x and y on the LED
matrix Top left = 0,0 Bottom right = 7,7
e.g. ap.set_pixel(x, y, r, g, b)
or
pixel = (r, g, b)
ap.set_pixel(x, y, pixel)
"""
pixel_error = 'Pixel arguments must be given as (r, g, b) or r, g, b'
if len(args) == 1:
pixel = args[0]
if len(pixel) != 3:
raise ValueError(pixel_error)
elif len(args) == 3:
pixel = args
else:
raise ValueError(pixel_error)
if x > 7 or x < 0:
raise ValueError('X position must be between 0 and 7')
if y > 7 or y < 0:
raise ValueError('Y position must be between 0 and 7')
for element in pixel:
if element > 255 or element < 0:
raise ValueError('Pixel elements must be between 0 and 255')
with open(self._fb_device, 'rb+') as f:
map = self._pix_map[self._rotation]
# Two bytes per pixel in fb memory, 16 bit RGB565
f.seek(map[y][x] * 2) # row, column
f.write(self._pack_bin(pixel))
[docs] def get_pixel(self, x, y):
"""
Returns a list of [R,G,B] representing the pixel specified by x and y
on the LED matrix. Top left = 0,0 Bottom right = 7,7
"""
if x > 7 or x < 0:
raise ValueError('X position must be between 0 and 7')
if y > 7 or y < 0:
raise ValueError('Y position must be between 0 and 7')
pix = None
with open(self._fb_device, 'rb') as f:
map = self._pix_map[self._rotation]
# Two bytes per pixel in fb memory, 16 bit RGB565
f.seek(map[y][x] * 2) # row, column
pix = self._unpack_bin(f.read(2))
return pix
[docs] def load_image(self, file_path, redraw=True):
"""
Accepts a path to an 8 x 8 image file and updates the LED matrix with
the image
"""
if not os.path.exists(file_path):
raise IOError('%s not found' % file_path)
img = Image.open(file_path).convert('RGB')
pixel_list = list(map(list, img.getdata()))
if redraw:
self.set_pixels(pixel_list)
return pixel_list
[docs] def clear(self, *args):
"""
Clears the LED matrix with a single colour, default is black / off
e.g. ap.clear()
or
ap.clear(r, g, b)
or
colour = (r, g, b)
ap.clear(colour)
"""
black = (0, 0, 0) # default
if len(args) == 0:
colour = black
elif len(args) == 1:
colour = args[0]
elif len(args) == 3:
colour = args
else:
raise ValueError('Pixel arguments must be given as (r, g, b) or r, g, b')
self.set_pixels([colour] * 64)
def _get_char_pixels(self, s):
"""
Internal. Safeguards the character indexed dictionary for the
show_message function below
"""
if len(s) == 1 and s in self._text_dict.keys():
return list(self._text_dict[s])
else:
return list(self._text_dict['?'])
[docs] def show_message(
self,
text_string,
scroll_speed=.1,
text_colour=[255, 255, 255],
back_colour=[0, 0, 0]
):
"""
Scrolls a string of text across the LED matrix using the specified
speed and colours
"""
# We must rotate the pixel map left through 90 degrees when drawing
# text, see _load_text_assets
previous_rotation = self._rotation
self._rotation -= 90
if self._rotation < 0:
self._rotation = 270
dummy_colour = [None, None, None]
string_padding = [dummy_colour] * 64
letter_padding = [dummy_colour] * 8
# Build pixels from dictionary
scroll_pixels = []
scroll_pixels.extend(string_padding)
for s in text_string:
scroll_pixels.extend(self._trim_whitespace(self._get_char_pixels(s)))
scroll_pixels.extend(letter_padding)
scroll_pixels.extend(string_padding)
# Recolour pixels as necessary
coloured_pixels = [
text_colour if pixel == [255, 255, 255] else back_colour
for pixel in scroll_pixels
]
# Shift right by 8 pixels per frame to scroll
scroll_length = len(coloured_pixels) // 8
for i in range(scroll_length - 8):
start = i * 8
end = start + 64
self.set_pixels(coloured_pixels[start:end])
time.sleep(scroll_speed)
self._rotation = previous_rotation
[docs] def show_letter(
self,
s,
text_colour=[255, 255, 255],
back_colour=[0, 0, 0]
):
"""
Displays a single text character on the LED matrix using the specified
colours
"""
if len(s) > 1:
raise ValueError('Only one character may be passed into this method')
# We must rotate the pixel map left through 90 degrees when drawing
# text, see _load_text_assets
previous_rotation = self._rotation
self._rotation -= 90
if self._rotation < 0:
self._rotation = 270
dummy_colour = [None, None, None]
pixel_list = [dummy_colour] * 8
pixel_list.extend(self._get_char_pixels(s))
pixel_list.extend([dummy_colour] * 16)
coloured_pixels = [
text_colour if pixel == [255, 255, 255] else back_colour
for pixel in pixel_list
]
self.set_pixels(coloured_pixels)
self._rotation = previous_rotation
@property
def gamma(self):
with open(self._fb_device, 'rb') as f:
f.seek(128)
return struct.unpack(nstr('32B'), f.read(32))
@gamma.setter
def gamma(self, buffer):
if len(buffer) is not 32:
raise ValueError('Gamma array must be of length 32')
if not all(b <= 31 for b in buffer):
raise ValueError('Gamma values must be bewteen 0 and 31')
if not isinstance(buffer, array.array):
buffer = array.array(nstr('B'), buffer)
with open(self._fb_device, 'rb+') as f:
f.seek(128)
f.write(struct.pack(nstr('32B'), *buffer))
[docs] def gamma_reset(self):
"""
Resets the LED matrix gamma correction to default
"""
self.gamma = GAMMA_DEFAULT
@property
def low_light(self):
return self.gamma == tuple(GAMMA_LOW)
@low_light.setter
def low_light(self, value):
if value:
self.gamma = GAMMA_LOW
else:
self.gamma = GAMMA_DEFAULT
####
# Environmental sensors
####
def _init_humidity(self):
"""
Internal. Initialises the humidity sensor via RTIMU
"""
if not self._humidity_init:
self._humidity_init = self._humidity.humidityInit()
if not self._humidity_init:
raise OSError('Humidity Init Failed')
def _init_pressure(self):
"""
Internal. Initialises the pressure sensor via RTIMU
"""
if not self._pressure_init:
self._pressure_init = self._pressure.pressureInit()
if not self._pressure_init:
raise OSError('Pressure Init Failed')
[docs] def get_humidity(self):
"""
Returns the percentage of relative humidity
"""
self._init_humidity() # Ensure humidity sensor is initialised
humidity = 0
data = self._humidity.humidityRead()
if (data[0]): # Humidity valid
humidity = data[1]
return humidity
@property
def humidity(self):
return self.get_humidity()
[docs] def get_temperature_from_humidity(self):
"""
Returns the temperature in Celsius from the humidity sensor
"""
self._init_humidity() # Ensure humidity sensor is initialised
temp = 0
data = self._humidity.humidityRead()
if (data[2]): # Temp valid
temp = data[3]
return temp
[docs] def get_temperature_from_pressure(self):
"""
Returns the temperature in Celsius from the pressure sensor
"""
self._init_pressure() # Ensure pressure sensor is initialised
temp = 0
data = self._pressure.pressureRead()
if (data[2]): # Temp valid
temp = data[3]
return temp
[docs] def get_temperature(self):
"""
Returns the temperature in Celsius
"""
return self.get_temperature_from_humidity()
@property
def temp(self):
return self.get_temperature_from_humidity()
@property
def temperature(self):
return self.get_temperature_from_humidity()
[docs] def get_pressure(self):
"""
Returns the pressure in Millibars
"""
self._init_pressure() # Ensure pressure sensor is initialised
pressure = 0
data = self._pressure.pressureRead()
if (data[0]): # Pressure valid
pressure = data[1]
return pressure
@property
def pressure(self):
return self.get_pressure()
####
# IMU Sensor
####
def _init_imu(self):
"""
Internal. Initialises the IMU sensor via RTIMU
"""
if not self._imu_init:
self._imu_init = self._imu.IMUInit()
if self._imu_init:
self._imu_poll_interval = self._imu.IMUGetPollInterval() * 0.001
# Enable everything on IMU
self.set_imu_config(True, True, True)
else:
raise OSError('IMU Init Failed')
[docs] def set_imu_config(self, compass_enabled, gyro_enabled, accel_enabled):
"""
Enables and disables the gyroscope, accelerometer and/or magnetometer
input to the orientation functions
"""
# If the consuming code always calls this just before reading the IMU
# the IMU consistently fails to read. So prevent unnecessary calls to
# IMU config functions using state variables
self._init_imu() # Ensure imu is initialised
if (not isinstance(compass_enabled, bool)
or not isinstance(gyro_enabled, bool)
or not isinstance(accel_enabled, bool)):
raise TypeError('All set_imu_config parameters must be of boolean type')
if self._compass_enabled != compass_enabled:
self._compass_enabled = compass_enabled
self._imu.setCompassEnable(self._compass_enabled)
if self._gyro_enabled != gyro_enabled:
self._gyro_enabled = gyro_enabled
self._imu.setGyroEnable(self._gyro_enabled)
if self._accel_enabled != accel_enabled:
self._accel_enabled = accel_enabled
self._imu.setAccelEnable(self._accel_enabled)
def _read_imu(self):
"""
Internal. Tries to read the IMU sensor three times before giving up
"""
self._init_imu() # Ensure imu is initialised
attempts = 0
success = False
while not success and attempts < 3:
success = self._imu.IMURead()
attempts += 1
time.sleep(self._imu_poll_interval)
return success
def _get_raw_data(self, is_valid_key, data_key):
"""
Internal. Returns the specified raw data from the IMU when valid
"""
result = None
if self._read_imu():
data = self._imu.getIMUData()
if data[is_valid_key]:
raw = data[data_key]
result = {
'x': raw[0],
'y': raw[1],
'z': raw[2]
}
return result
[docs] def get_orientation_radians(self):
"""
Returns a dictionary object to represent the current orientation in
radians using the aircraft principal axes of pitch, roll and yaw
"""
raw = self._get_raw_data('fusionPoseValid', 'fusionPose')
if raw is not None:
raw['roll'] = raw.pop('x')
raw['pitch'] = raw.pop('y')
raw['yaw'] = raw.pop('z')
self._last_orientation = raw
return deepcopy(self._last_orientation)
@property
def orientation_radians(self):
return self.get_orientation_radians()
[docs] def get_orientation_degrees(self):
"""
Returns a dictionary object to represent the current orientation
in degrees, 0 to 360, using the aircraft principal axes of
pitch, roll and yaw
"""
orientation = self.get_orientation_radians()
for key, val in orientation.items():
deg = math.degrees(val) # Result is -180 to +180
orientation[key] = deg + 360 if deg < 0 else deg
return orientation
def get_orientation(self):
return self.get_orientation_degrees()
@property
def orientation(self):
return self.get_orientation_degrees()
[docs] def get_compass(self):
"""
Gets the direction of North from the magnetometer in degrees
"""
self.set_imu_config(True, False, False)
orientation = self.get_orientation_degrees()
if type(orientation) is dict and 'yaw' in orientation.keys():
return orientation['yaw']
else:
return None
@property
def compass(self):
return self.get_compass()
[docs] def get_compass_raw(self):
"""
Magnetometer x y z raw data in uT (micro teslas)
"""
raw = self._get_raw_data('compassValid', 'compass')
if raw is not None:
self._last_compass_raw = raw
return deepcopy(self._last_compass_raw)
@property
def compass_raw(self):
return self.get_compass_raw()
[docs] def get_gyroscope(self):
"""
Gets the orientation in degrees from the gyroscope only
"""
self.set_imu_config(False, True, False)
return self.get_orientation_degrees()
@property
def gyro(self):
return self.get_gyroscope()
@property
def gyroscope(self):
return self.get_gyroscope()
[docs] def get_gyroscope_raw(self):
"""
Gyroscope x y z raw data in radians per second
"""
raw = self._get_raw_data('gyroValid', 'gyro')
if raw is not None:
self._last_gyro_raw = raw
return deepcopy(self._last_gyro_raw)
@property
def gyro_raw(self):
return self.get_gyroscope_raw()
@property
def gyroscope_raw(self):
return self.get_gyroscope_raw()
[docs] def get_accelerometer(self):
"""
Gets the orientation in degrees from the accelerometer only
"""
self.set_imu_config(False, False, True)
return self.get_orientation_degrees()
@property
def accel(self):
return self.get_accelerometer()
@property
def accelerometer(self):
return self.get_accelerometer()
[docs] def get_accelerometer_raw(self):
"""
Accelerometer x y z raw data in Gs
"""
raw = self._get_raw_data('accelValid', 'accel')
if raw is not None:
self._last_accel_raw = raw
return deepcopy(self._last_accel_raw)
@property
def accel_raw(self):
return self.get_accelerometer_raw()
@property
def accelerometer_raw(self):
return self.get_accelerometer_raw()