Implemented serial handshake with HELO pins.

Implemented stop action on lid close.
Untested: statemachine rewind on lid close.
This commit is contained in:
jpunkt 2022-01-19 22:08:46 +01:00
parent 2670c3c714
commit 4f8d97a7ff
3 changed files with 132 additions and 67 deletions

View file

@ -1,6 +1,5 @@
import logging import logging
import functools
import threading
from time import sleep from time import sleep
from enum import Enum from enum import Enum
@ -13,7 +12,7 @@ import soundfile as sf
import pygame.mixer as mx import pygame.mixer as mx
from picamera import PiCamera from picamera import PiCamera
from gpiozero import Button from gpiozero import Button, DigitalOutputDevice, DigitalInputDevice
import serial import serial
@ -29,7 +28,8 @@ AUDIO_REC_SR = 44100 # Audio Recording Samplerate
SERIAL_DEV = '/dev/serial0' # Serial port to use SERIAL_DEV = '/dev/serial0' # Serial port to use
SERIAL_BAUDRATE = 115200 # Serial connection baud rate SERIAL_BAUDRATE = 115200 # Serial connection baud rate
SERIAL_CONN_TIMEOUT = 60 # Serial connection read timeout SERIAL_CONN_TIMEOUT = 0.2 # Serial connection read timeout
HELO_TIMEOUT = 20
class Lights(Enum): class Lights(Enum):
@ -47,6 +47,7 @@ class SerialCommands(Enum):
ALREADY_CONNECTED = b'\x01' ALREADY_CONNECTED = b'\x01'
ERROR = b'\x02' ERROR = b'\x02'
RECEIVED = b'\x03' RECEIVED = b'\x03'
ABORT = b'\x63' # 99 decimal
SET_MOVEMENT = b'M' SET_MOVEMENT = b'M'
SET_LIGHT = b'L' SET_LIGHT = b'L'
@ -64,6 +65,10 @@ class SerialCommands(Enum):
EOT = b'\n' EOT = b'\n'
class CommunicationError(Exception):
pass
class SerialCommunicationError(Exception): class SerialCommunicationError(Exception):
pass pass
@ -84,16 +89,43 @@ class PizzaHAL:
def __init__(self, serialdev: str = SERIAL_DEV, baudrate: int = SERIAL_BAUDRATE, timeout: float = SERIAL_CONN_TIMEOUT): def __init__(self, serialdev: str = SERIAL_DEV, baudrate: int = SERIAL_BAUDRATE, timeout: float = SERIAL_CONN_TIMEOUT):
self.serialcon = serial.Serial(serialdev, baudrate=baudrate, timeout=timeout) self.serialcon = serial.Serial(serialdev, baudrate=baudrate, timeout=timeout)
# Lid switch with pull-up. is_pressed = True when lid is open
self.lid_switch = Button(LID_SWITCH) self.lid_switch = Button(LID_SWITCH)
self.pin_helo1 = DigitalOutputDevice(HELO1)
self.pin_helo2 = DigitalInputDevice(HELO2)
self.camera = None self.camera = None
self.soundcache = {} self.soundcache = {}
self.connected = False self.connected = False
@property
def lid_open(self) -> bool:
"""
Returns True when the lid is open
"""
return self.lid_switch.is_pressed
def init_connection(self): def init_connection(self):
"""
Set HELO1 pin to `High`, wait for HELO2 to be set `High` by microcontroller.
Then perform serial handshake.
"""
self.pin_helo1.on()
timer = 0
while (not self.pin_helo2.value) and (timer < HELO_TIMEOUT):
sleep(0.1)
timer += 1
if not (timer % 100):
logger.info(f'Waiting for connection ({timer / 10}s)')
if not self.pin_helo2.value:
raise CommunicationError('Microcontroller did not respond to HELO pin.')
self.serialcon.write(SerialCommands.HELLO.value + SerialCommands.EOT.value) self.serialcon.write(SerialCommands.HELLO.value + SerialCommands.EOT.value)
resp = self.serialcon.read_until() resp = self.serialcon.read_until()
if resp == (SerialCommands.HELLO.value + SerialCommands.EOT.value): if resp == (SerialCommands.HELLO.value + SerialCommands.EOT.value):
self.serialcon.write(SerialCommands.ALREADY_CONNECTED.value + SerialCommands.EOT.value) self.serialcon.write(SerialCommands.ALREADY_CONNECTED.value + SerialCommands.EOT.value)
resp = self.serialcon.read_until() resp = self.serialcon.read_until()
@ -109,6 +141,7 @@ class PizzaHAL:
raise SerialCommunicationError('Timeout on initializing connection.') raise SerialCommunicationError('Timeout on initializing connection.')
else: else:
raise SerialCommunicationError(f'Serial Connection received invalid response to HELLO: {resp}') raise SerialCommunicationError(f'Serial Connection received invalid response to HELLO: {resp}')
self.connected = True self.connected = True
def init_sounds(self, sounds: List=None): def init_sounds(self, sounds: List=None):
@ -118,18 +151,15 @@ class PizzaHAL:
:param hal: :param hal:
:param sounds: A list of sound files :param sounds: A list of sound files
""" """
# if self.soundcache is None: if self.soundcache is None:
# self.soundcache = {} self.soundcache = {}
if not mx.get_init(): if not mx.get_init():
mx.init() mx.init()
# if sounds is not None: if sounds is not None:
# for sound in sounds: for sound in sounds:
# # Extract data and sampling rate from file self.soundcache[str(sound)] = mx.Sound(str(sound))
# # data, fs = sf.read(str(sound), dtype='float32')
# # self.soundcache[str(sound)] = (data, fs)
# self.soundcache[str(sound)] = mx.Sound(str(sound))
def init_camera(self): def init_camera(self):
if self.camera is None: if self.camera is None:
@ -143,28 +173,48 @@ class PizzaHAL:
if mx.get_busy(): if mx.get_busy():
mx.stop() mx.stop()
def send_cmd(self, command: SerialCommands, *options): def send_cmd(self, command: SerialCommands, *options, ignore_lid: bool=False):
""" """
Send a command and optional options. Options need to be encoded as bytes before passing. Send a command and optional options. Options need to be encoded as bytes before passing.
This function is blocking.
Returns the response from the microcontroller or `None` if the lid was closed and `ignore_lid` is `False`.
Raises a SerialCommunicationError if serial connection was not initialized or a response other than
SerialCommands.RECEIVED was received.
Raises a CommunicationError if the HELO2 pin goes low while waiting for response.
""" """
if not self.connected: if not self.connected:
raise SerialCommunicationError("Serial Communication not initialized. Call `init_connection()` before `send_cmd()`.") raise SerialCommunicationError("Serial Communication not initialized. Call `init_connection()` before `send_cmd()`.")
self.serialcon.write(command.value) self.serialcon.write(command.value)
for o in options: for o in options:
self.serialcon.write(o) self.serialcon.write(o)
self.serialcon.write(SerialCommands.EOT.value)
resp = self.serialcon.read_until()
while resp == b'': self.serialcon.write(SerialCommands.EOT.value)
resp = b''
while resp is b'':
# If serial communication timeout occurs, response is empty. # If serial communication timeout occurs, response is empty.
# Read again to allow for longer waiting times # Read again to allow for longer waiting times
if not self.pin_helo2.value:
raise CommunicationError('Pin HELO2 LOW. Microcontroller in error state or lost connection.')
if (not ignore_lid) and (not self.lid_open):
logger.info('Lid closed while processing command. Returning None.')
return None
resp = self.serialcon.read_until() resp = self.serialcon.read_until()
logger.debug(f'hal.send_cmd() received {resp}')
if not resp.startswith(SerialCommands.RECEIVED.value): if not resp.startswith(SerialCommands.RECEIVED.value):
raise SerialCommunicationError(f'Serial Communication received unexpected response: {resp}') raise SerialCommunicationError(f'Serial Communication received unexpected response: {resp}')
return resp return resp
def flush_serial(self):
self.serialcon.read_all()
def set_movement(hal: PizzaHAL, def set_movement(hal: PizzaHAL,
scroll: Scrolls, scroll: Scrolls,
@ -187,16 +237,16 @@ def rewind(hal: PizzaHAL, **kwargs):
Rewind both scrolls. Rewind both scrolls.
""" """
hal.send_cmd(SerialCommands.REWIND) hal.send_cmd(SerialCommands.REWIND, ignore_lid=True)
def turn_off(hal: PizzaHAL, **kwargs): def turn_off(hal: PizzaHAL, **kwargs):
""" """
Turn off the lights. Turn off the lights.
""" """
set_light(hal, Lights.BACKLIGHT, 0, 0, 0, 0, 0) set_light(hal, Lights.BACKLIGHT, 0, 0, 0, 0, 0, ignore_lid=True)
set_light(hal, Lights.FRONTLIGHT, 0, 0, 0, 0, 0) set_light(hal, Lights.FRONTLIGHT, 0, 0, 0, 0, 0, ignore_lid=True)
do_it(hal) do_it(hal, ignore_lid=True)
def wait_for_input(hal: PizzaHAL, def wait_for_input(hal: PizzaHAL,
@ -241,6 +291,13 @@ def wait_for_input(hal: PizzaHAL,
if sound is not None: if sound is not None:
hal.stop_sound() hal.stop_sound()
if resp is None:
# lid was closed by user
logger.info('Lid closed during wait_for_input(). Sending ABORT.')
hal.send_cmd(SerialCommands.ABORT, ignore_lid=True)
hal.flush_serial()
return
if len(resp) != 3: if len(resp) != 3:
raise SerialCommunicationError(f'USER_INTERACTION expects 3 bytes, received {resp}') raise SerialCommunicationError(f'USER_INTERACTION expects 3 bytes, received {resp}')
@ -278,14 +335,18 @@ def set_light(hal: PizzaHAL,
hal.send_cmd(SerialCommands.SET_LIGHT, hal.send_cmd(SerialCommands.SET_LIGHT,
int(light.value).to_bytes(1, 'little'), int(light.value).to_bytes(1, 'little'),
int(color).to_bytes(4, 'little'), int(color).to_bytes(4, 'little'),
int(fade * 1000).to_bytes(4, 'little')) int(fade * 1000).to_bytes(4, 'little'),
ignore_lid=kwargs.get('ignore_lid', False))
def do_it(hal: PizzaHAL): def do_it(hal: PizzaHAL, ignore_lid: bool=False, **kwargs):
""" """
Execute set commands Execute set commands
""" """
hal.send_cmd(SerialCommands.DO_IT) if hal.send_cmd(SerialCommands.DO_IT, ignore_lid=ignore_lid) is None:
logger.info('Lid closed during do_it(). Sending ABORT.')
hal.send_cmd(SerialCommands.ABORT, ignore_lid=True)
hal.flush_serial()
def play_sound(hal: PizzaHAL, sound: Any, **kwargs): def play_sound(hal: PizzaHAL, sound: Any, **kwargs):
@ -298,10 +359,13 @@ def play_sound(hal: PizzaHAL, sound: Any, **kwargs):
# Extract data and sampling rate from file # Extract data and sampling rate from file
try: try:
hal.play_sound(str(sound)) hal.play_sound(str(sound))
while mx.get_busy(): while mx.get_busy() and hal.lid_open:
pass pass
if not hal.lid_open:
hal.stop_sound()
except KeyboardInterrupt: except KeyboardInterrupt:
mx.stop() hal.stop_sound()
logger.debug('skipped playback') logger.debug('skipped playback')

View file

@ -1,3 +1,8 @@
import sys
import logging
logging.basicConfig(level=logging.DEBUG, stream=sys.stdout)
from . import hal_serial from . import hal_serial
hal = hal_serial.PizzaHAL() hal = hal_serial.PizzaHAL()

View file

@ -5,18 +5,21 @@ from enum import Enum, auto
from pizzactrl import fs_names from pizzactrl import fs_names
from .storyboard import Language, Storyboard from .storyboard import Language, Storyboard
from .hal_serial import SerialCommunicationError, PizzaHAL, wait_for_input, play_sound, turn_off from .hal_serial import SerialCommunicationError, CommunicationError, PizzaHAL, wait_for_input, play_sound, turn_off
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class FileSystemException(Exception):
pass
class State(Enum): class State(Enum):
POWER_ON = auto() POWER_ON = auto()
POST = auto() POST = auto()
IDLE_START = auto() IDLE_START = auto()
LANGUAGE_SELECT = auto() LANGUAGE_SELECT = auto()
PLAY = auto() PLAY = auto()
PAUSE = auto()
POST_PROCESS = auto() POST_PROCESS = auto()
REWIND = auto() REWIND = auto()
IDLE_END = auto() IDLE_END = auto()
@ -43,7 +46,16 @@ class Statemachine:
self.test = test self.test = test
self.loop = loop self.loop = loop
self.state = State.POWER_ON self.state = State.POWER_ON
def _next_state(self):
"""
Set `self.state` to the next state
"""
try:
self.state = State(self.state.value + 1)
except ValueError:
pass
def run(self): def run(self):
logger.debug(f'Starting Statemachine...') logger.debug(f'Starting Statemachine...')
@ -62,7 +74,14 @@ class Statemachine:
while (self.state is not State.ERROR) and \ while (self.state is not State.ERROR) and \
(self.state is not State.SHUTDOWN): (self.state is not State.SHUTDOWN):
logger.debug(f'Run(state={self.state})') logger.debug(f'Run(state={self.state})')
choice[self.state]() try:
choice[self.state]()
except (CommunicationError, SerialCommunicationError) as e:
self.state = State.ERROR
logger.error('Communication with microcontroller failed.', e)
except Exception as e:
self.state = State.ERROR
logger.error(e)
if self.state is State.ERROR: if self.state is State.ERROR:
logger.debug('An error occurred. Trying to notify user...') logger.debug('An error occurred. Trying to notify user...')
@ -75,63 +94,38 @@ class Statemachine:
self._shutdown() self._shutdown()
def _lid_open(self):
# TODO implement
pass
def _lid_closed(self):
# TODO implement
pass
def _power_on(self): def _power_on(self):
""" """
Initialize hal callbacks, load sounds Initialize hal callbacks, load sounds
""" """
# TODO enable lid sensor
self.hal.lid_switch.when_pressed = self._lid_open
self.hal.lid_switch.when_released = self._lid_closed
self.hal.init_sounds() self.hal.init_sounds()
self.hal.init_camera() self.hal.init_camera()
self.state = State.POST self._next_state()
def _post(self): def _post(self):
""" """
Power on self test. Power on self test.
""" """
if (not self.test) and (not os.path.exists(fs_names.USB_STICK)): if (not self.test) and (not os.path.exists(fs_names.USB_STICK)):
logger.warning('USB-Stick not found.') raise FileSystemException('USB Stick not present!')
self.state = State.ERROR
return
# TODO set RPi_HELO pins, wait for response
# Callback for start when blue button is held
# self.hal.btn_start.when_activated = self._start_or_rewind
# logger.debug('start button callback activated')
try:
self.hal.init_connection()
except SerialCommunicationError as e:
self.state = State.ERROR
logger.exception(e)
return
self.hal.init_connection()
# play a sound if everything is alright # play a sound if everything is alright
play_sound(self.hal, fs_names.SFX_POST_OK) play_sound(self.hal, fs_names.SFX_POST_OK)
if self.test: if self.test:
self.state = State.LANGUAGE_SELECT self.state = State.LANGUAGE_SELECT
else: else:
self.state = State.IDLE_START self._next_state()
def _idle_start(self): def _idle_start(self):
""" """
Device is armed. Wait for user to press start button Device is armed. Wait for user to open the lid
""" """
pass if self.hal.lid_open:
self._next_state()
def _lang_select(self): def _lang_select(self):
""" """
@ -158,7 +152,7 @@ class Statemachine:
self.story.language = self.lang self.story.language = self.lang
logger.debug(f'User selected language={self.lang}') logger.debug(f'User selected language={self.lang}')
self.state = State.PLAY self._next_state()
def _play(self): def _play(self):
""" """
@ -170,7 +164,7 @@ class Statemachine:
self.story.play_chapter() self.story.play_chapter()
self.story.advance_chapter() self.story.advance_chapter()
self.state = State.POST_PROCESS self._next_state()
def _post_process(self): def _post_process(self):
""" """
@ -181,7 +175,7 @@ class Statemachine:
# cmdstring = f'MP4Box -add {fs_names.REC_DRAW_CITY} {fs_names.REC_MERGED_VIDEO}' # cmdstring = f'MP4Box -add {fs_names.REC_DRAW_CITY} {fs_names.REC_MERGED_VIDEO}'
# call([cmdstring], shell=True) # call([cmdstring], shell=True)
self.state = State.REWIND self._next_state()
def _rewind(self): def _rewind(self):
""" """
@ -193,16 +187,18 @@ class Statemachine:
if self.loop: if self.loop:
self.state = State.IDLE_START self.state = State.IDLE_START
else: else:
self.state = State.IDLE_END self._next_state()
def _idle_end(self): def _idle_end(self):
""" """
Initialize shutdown Initialize shutdown
""" """
self.state = State.SHUTDOWN self._next_state()
def _shutdown(self): def _shutdown(self):
""" """
Clean up, end execution Clean up, end execution
""" """
self.hal.pin_helo1.off()
del self.hal del self.hal
del self.story