Source code for sense_emu.sense_hat

# 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()