# 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 io
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'))
with io.open(os.devnull, 'r+b') as devnull:
try:
setpgrp = os.setpgrp
except AttributeError:
setpgrp = None
# setpgrp is called to spawn a new process group, ensuring
# that signals from the interpreter (e.g. the user pressing
# Ctrl+C) don't get sent to the emulator too
subprocess.Popen(['sense_emu_gui'], preexec_fn=setpgrp,
stdin=devnull, stdout=devnull, stderr=devnull,
close_fds=True)
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()