Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CAN 2.0B migration #19

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion parsley/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from . import message_definitions
from . import message_types
from .parsley import (
parse_fields, parse, parse_board_id,
parse_fields, parse, parse_board_type_id, parse_board_inst_id,
parse_bitstring,
parse_live_telemetry,
parse_usb_debug,
Expand Down
84 changes: 43 additions & 41 deletions parsley/message_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
TIMESTAMP_2 = Numeric('time', 16, scale=1/1000, unit='s')
TIMESTAMP_3 = Numeric('time', 24, scale=1/1000, unit='s')

MESSAGE_TYPE = Enum('msg_type', 6, mt.adjusted_msg_type)
BOARD_ID = Enum('board_id', 5, mt.board_id)
MESSAGE_SID = Enum('msg_sid', MESSAGE_TYPE.length + BOARD_ID.length, {}) # used purely as a length constant
MESSAGE_PRIO = Enum('msg_prio', 2, mt.msg_prio)
MESSAGE_TYPE = Enum('msg_type', 9, mt.msg_type)
BOARD_TYPE_ID = Enum('board_type_id', 8, mt.board_type_id)
BOARD_INST_ID = Enum('board_inst_id', 8, mt.board_inst_id)
MESSAGE_SID = Enum('msg_sid', MESSAGE_PRIO.length + MESSAGE_TYPE.length + 2 + BOARD_TYPE_ID.length + BOARD_INST_ID.length, {}) # used purely as a length constant

BOARD_STATUS = {
'E_NOMINAL': [],
Expand All @@ -24,9 +26,9 @@
'E_13V_OVER_CURRENT': [],
'E_MOTOR_OVER_CURRENT': [],

'E_BOARD_FEARED_DEAD': [Enum('dead_board_id', 8, mt.board_id)],
'E_BOARD_FEARED_DEAD': [Enum('dead_board_id', 8, mt.board_type_id)],
'E_NO_CAN_TRAFFIC': [Numeric('err_time', 16)],
'E_MISSING_CRITICAL_BOARD': [Enum('missing_board_id', 8, mt.board_id)],
'E_MISSING_CRITICAL_BOARD': [Enum('missing_board_id', 8, mt.board_type_id)],
'E_RADIO_SIGNAL_LOST': [Numeric('err_time', 16)],

'E_ACTUATOR_STATE': [Enum('req_state', 8, mt.actuator_states), Enum('cur_state', 8, mt.actuator_states)],
Expand All @@ -47,43 +49,43 @@
# we parse BOARD_ID seperately from the CAN message (since we want to continue parsing even if BOARD_ID throws)
# but BOARD_ID is still here so that Omnibus has all the fields it needs when creating messages to send
MESSAGES = {
'GENERAL_CMD': [BOARD_ID, TIMESTAMP_3, Enum('command', 8, mt.gen_cmd)],
'ACTUATOR_CMD': [BOARD_ID, TIMESTAMP_3, Enum('actuator', 8, mt.actuator_id), Enum('req_state', 8, mt.actuator_states)],
'ALT_ARM_CMD': [BOARD_ID, TIMESTAMP_3, Enum('state', 4, mt.arm_states), Numeric('altimeter', 4)],
'RESET_CMD': [BOARD_ID, TIMESTAMP_3, Enum('reset_board_id', 8, mt.board_id)],

'DEBUG_MSG': [BOARD_ID, TIMESTAMP_3, Numeric('level', 4), Numeric('line', 12), ASCII('data', 24)],
'DEBUG_PRINTF': [BOARD_ID, ASCII('string', 64)],
'DEBUG_RADIO_CMD': [BOARD_ID, ASCII('string', 64)],
'ACT_ANALOG_CMD': [BOARD_ID, TIMESTAMP_3, Enum('actuator', 8, mt.actuator_id), Numeric('act_state', 8)],

'ACTUATOR_STATUS': [BOARD_ID, TIMESTAMP_3, Enum('actuator', 8, mt.actuator_id), Enum('cur_state', 8, mt.actuator_states), Enum('req_state', 8, mt.actuator_states)],
'ALT_ARM_STATUS': [BOARD_ID, TIMESTAMP_3, Enum('state', 4, mt.arm_states), Numeric('altimeter', 4), Numeric('drogue_v', 16), Numeric('main_v', 16)],
'GENERAL_BOARD_STATUS': [BOARD_ID, TIMESTAMP_3, Switch('status', 8, mt.board_status, BOARD_STATUS)],

'SENSOR_TEMP': [BOARD_ID, TIMESTAMP_3, Numeric('sensor_id', 8), Numeric('temperature', 24, scale=1/2**10, unit='°C', signed=True)],
'SENSOR_ALTITUDE': [BOARD_ID, TIMESTAMP_3, Numeric('altitude', 32, signed=True)],
'SENSOR_ACC': [BOARD_ID, TIMESTAMP_2, Numeric('x', 16, scale=8/2**16, unit='m/s²', signed=True), Numeric('y', 16, scale=8/2**16, unit='m/s²', signed=True), Numeric('z', 16, scale=8/2**16, unit='m/s²', signed=True)],
'SENSOR_ACC2': [BOARD_ID, TIMESTAMP_2, Numeric('x', 16, scale=16/2**16, unit='m/s²', signed=True), Numeric('y', 16, scale=16/2**16, unit='m/s²', signed=True), Numeric('z', 16, scale=16/2**16, unit='m/s²', signed=True)],
'SENSOR_GYRO': [BOARD_ID, TIMESTAMP_2, Numeric('x', 16, scale=2000/2**16, unit='°/s', signed=True), Numeric('y', 16, scale=2000/2**16, unit='°/s', signed=True), Numeric('z', 16, scale=2000/2**16, unit='°/s', signed=True)],

'STATE_EST_CALIB': [BOARD_ID, TIMESTAMP_3, Numeric('ack_flag', 8), Numeric('apogee', 16)],
'SENSOR_MAG': [BOARD_ID, TIMESTAMP_2, Numeric('x', 16, unit='µT', signed=True), Numeric('y', 16, unit='µT', signed=True), Numeric('z', 16, unit='µT', signed=True)],
'SENSOR_ANALOG': [BOARD_ID, TIMESTAMP_2, Enum('sensor_id', 8, mt.sensor_id), Numeric('value', 16, signed=True)],

'GPS_TIMESTAMP': [BOARD_ID, TIMESTAMP_3, Numeric('hrs', 8), Numeric('mins', 8), Numeric('secs', 8), Numeric('dsecs', 8)],
'GPS_LATITUDE': [BOARD_ID, TIMESTAMP_3, Numeric('degs', 8), Numeric('mins', 8), Numeric('dmins', 16), ASCII('direction', 8)],
'GPS_LONGITUDE': [BOARD_ID, TIMESTAMP_3, Numeric('degs', 8), Numeric('mins', 8), Numeric('dmins', 16), ASCII('direction', 8)],
'GPS_ALTITUDE': [BOARD_ID, TIMESTAMP_3, Numeric('altitude', 16), Numeric('daltitude', 8), ASCII('unit', 8)],
'GPS_INFO': [BOARD_ID, TIMESTAMP_3, Numeric('num_sats', 8), Numeric('quality', 8)],

'FILL_LVL': [BOARD_ID, TIMESTAMP_3, Numeric('level', 8), Enum('direction', 8, mt.fill_direction)],
'STATE_EST_DATA': [BOARD_ID, TIMESTAMP_3, Floating('data', big_endian=False), Enum('state_id', 8, mt.state_id)],

'LEDS_ON': [BOARD_ID],
'LEDS_OFF': [BOARD_ID]
'GENERAL_CMD': [BOARD_TYPE_ID, TIMESTAMP_3, Enum('command', 8, mt.gen_cmd)],
'ACTUATOR_CMD': [BOARD_TYPE_ID, TIMESTAMP_3, Enum('actuator', 8, mt.actuator_id), Enum('req_state', 8, mt.actuator_states)],
'ALT_ARM_CMD': [BOARD_TYPE_ID, TIMESTAMP_3, Enum('state', 4, mt.arm_states), Numeric('altimeter', 4)],
'RESET_CMD': [BOARD_TYPE_ID, TIMESTAMP_3, Enum('reset_board_type_id', 8, mt.board_type_id), Enum('reset_board_inst_id', 8, mt.board_inst_id)],

'DEBUG_MSG': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('level', 4), Numeric('line', 12), ASCII('data', 24)],
'DEBUG_PRINTF': [BOARD_TYPE_ID, ASCII('string', 64)],
'DEBUG_RADIO_CMD': [BOARD_TYPE_ID, ASCII('string', 64)],
'ACT_ANALOG_CMD': [BOARD_TYPE_ID, TIMESTAMP_3, Enum('actuator', 8, mt.actuator_id), Numeric('act_state', 8)],

'ACTUATOR_STATUS': [BOARD_TYPE_ID, TIMESTAMP_3, Enum('actuator', 8, mt.actuator_id), Enum('cur_state', 8, mt.actuator_states), Enum('req_state', 8, mt.actuator_states)],
'ALT_ARM_STATUS': [BOARD_TYPE_ID, TIMESTAMP_3, Enum('state', 4, mt.arm_states), Numeric('altimeter', 4), Numeric('drogue_v', 16), Numeric('main_v', 16)],
'GENERAL_BOARD_STATUS': [BOARD_TYPE_ID, TIMESTAMP_3, Switch('status', 8, mt.board_status, BOARD_STATUS)],

'SENSOR_TEMP': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('sensor_id', 8), Numeric('temperature', 24, scale=1/2**10, unit='°C', signed=True)],
'SENSOR_ALTITUDE': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('altitude', 32, signed=True)],
'SENSOR_ACC': [BOARD_TYPE_ID, TIMESTAMP_2, Numeric('x', 16, scale=8/2**16, unit='m/s²', signed=True), Numeric('y', 16, scale=8/2**16, unit='m/s²', signed=True), Numeric('z', 16, scale=8/2**16, unit='m/s²', signed=True)],
'SENSOR_ACC2': [BOARD_TYPE_ID, TIMESTAMP_2, Numeric('x', 16, scale=16/2**16, unit='m/s²', signed=True), Numeric('y', 16, scale=16/2**16, unit='m/s²', signed=True), Numeric('z', 16, scale=16/2**16, unit='m/s²', signed=True)],
'SENSOR_GYRO': [BOARD_TYPE_ID, TIMESTAMP_2, Numeric('x', 16, scale=2000/2**16, unit='°/s', signed=True), Numeric('y', 16, scale=2000/2**16, unit='°/s', signed=True), Numeric('z', 16, scale=2000/2**16, unit='°/s', signed=True)],

'STATE_EST_CALIB': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('ack_flag', 8), Numeric('apogee', 16)],
'SENSOR_MAG': [BOARD_TYPE_ID, TIMESTAMP_2, Numeric('x', 16, unit='µT', signed=True), Numeric('y', 16, unit='µT', signed=True), Numeric('z', 16, unit='µT', signed=True)],
'SENSOR_ANALOG': [BOARD_TYPE_ID, TIMESTAMP_2, Enum('sensor_id', 8, mt.sensor_id), Numeric('value', 16, signed=True)],

'GPS_TIMESTAMP': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('hrs', 8), Numeric('mins', 8), Numeric('secs', 8), Numeric('dsecs', 8)],
'GPS_LATITUDE': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('degs', 8), Numeric('mins', 8), Numeric('dmins', 16), ASCII('direction', 8)],
'GPS_LONGITUDE': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('degs', 8), Numeric('mins', 8), Numeric('dmins', 16), ASCII('direction', 8)],
'GPS_ALTITUDE': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('altitude', 16), Numeric('daltitude', 8), ASCII('unit', 8)],
'GPS_INFO': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('num_sats', 8), Numeric('quality', 8)],

'FILL_LVL': [BOARD_TYPE_ID, TIMESTAMP_3, Numeric('level', 8), Enum('direction', 8, mt.fill_direction)],
'STATE_EST_DATA': [BOARD_TYPE_ID, TIMESTAMP_3, Floating('data', big_endian=False), Enum('state_id', 8, mt.state_id)],

'LEDS_ON': [BOARD_TYPE_ID],
'LEDS_OFF': [BOARD_TYPE_ID]
}

# entire CAN message minus board_id
# board_id is parsed seperately because if it throws, we want to continue parsing
CAN_MESSAGE = Switch('msg_type', MESSAGE_TYPE.length, mt.adjusted_msg_type, MESSAGES)
CAN_MESSAGE = Switch('msg_type', MESSAGE_TYPE.length, mt.msg_type, MESSAGES)
150 changes: 84 additions & 66 deletions parsley/message_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,76 +3,94 @@

If canlib and this file differ, canlib is the source of truth.
"""

msg_prio = {
'HIGHEST': 0x0,
'HIGH': 0x1,
'MEDIUM': 0x2,
'LOW': 0x3
}

msg_type = {
'GENERAL_CMD': 0x060,
'ACTUATOR_CMD': 0x0C0,
'ALT_ARM_CMD': 0x140,
'RESET_CMD': 0x160,

'DEBUG_MSG': 0x180,
'DEBUG_PRINTF': 0x1E0,
'DEBUG_RADIO_CMD': 0x200,
'ACT_ANALOG_CMD': 0x220,
'GENERAL_CMD': 0x001,
'ACTUATOR_CMD': 0x002,
'ALT_ARM_CMD': 0x003,
'RESET_CMD': 0x004,

'DEBUG_MSG': 0x005,
'DEBUG_PRINTF': 0x006,
'DEBUG_RADIO_CMD': 0x007,
'ACT_ANALOG_CMD': 0x008,

'ALT_ARM_STATUS': 0x440,
'ACTUATOR_STATUS': 0x460,
'GENERAL_BOARD_STATUS': 0x520,

'SENSOR_TEMP': 0x540,
'SENSOR_ALTITUDE': 0x560,
'SENSOR_ACC': 0x580,
'SENSOR_ACC2': 0x5A0,
'SENSOR_GYRO': 0x5E0,

'STATE_EST_CALIB': 0x620,
'SENSOR_MAG': 0x640,
'SENSOR_ANALOG': 0x6A0,
'GPS_TIMESTAMP': 0x6C0,
'GPS_LATITUDE': 0x6E0,
'GPS_LONGITUDE': 0x700,
'GPS_ALTITUDE': 0x720,
'GPS_INFO': 0x740,

'FILL_LVL': 0x780,
'STATE_EST_DATA': 0x7A0,

'LEDS_ON': 0x7E0,
'LEDS_OFF': 0x7C0
'ALT_ARM_STATUS': 0x009,
'ACTUATOR_STATUS': 0x00A,
'GENERAL_BOARD_STATUS': 0x00B,

'SENSOR_TEMP': 0x00C,
'SENSOR_ALTITUDE': 0x00D,
'SENSOR_ACC': 0x00E,
'SENSOR_ACC2': 0x00F,
'SENSOR_GYRO': 0x010,

'STATE_EST_CALIB': 0x011,
'SENSOR_MAG': 0x012,
'SENSOR_ANALOG': 0x013,
'GPS_TIMESTAMP': 0x014,
'GPS_LATITUDE': 0x015,
'GPS_LONGITUDE': 0x016,
'GPS_ALTITUDE': 0x017,
'GPS_INFO': 0x018,

'FILL_LVL': 0x019,
'STATE_EST_DATA': 0x01A,

'LEDS_ON': 0x01B,
'LEDS_OFF': 0x01C
}

board_type_id = {
'ANY': 0x00,

'INJ_SENSOR': 0x01,
'CANARD_MOTOR': 0x02,
'CAMERA': 0x03,
'ROCKET_POWER': 0x04,
'LOGGER': 0x05,
'PROCESSOR': 0x06,
'TELEMETRY': 0x07,
'GPS': 0x08,
'SRAD_GNSS': 0x09,
'ALTIMETER': 0x0A,
'ARMING': 0x0B,

'PAY_SENSOR': 0x40,
'PAY_MOTOR': 0x41,

'RLCS_GLS': 0x80,
'RLCS_RELAY': 0x81,
'RLCS_HEATING': 0x82,
'DAQ': 0x83,
'CHARGING': 0x84,
'THERMOCOUPLE': 0x85,
'USB': 0x86
}

# canlib's msg_type is defined in 12-bit msg_sid form, so we need to
# right shift to get the adjusted (actual) 6-bit message type values
adjusted_msg_type = {k: v >> 5 for k, v in msg_type.items()}

board_id = {
'ANY': 0x00,
# Ground Side
'DAQ': 0x01,
'THERMOCOUPLE_1': 0x02,
'THERMOCOUPLE_2': 0x03,
'THERMOCOUPLE_3': 0x04,
'THERMOCOUPLE_4': 0x05,
# Injector/Fill Section
'PROPULSION_INJ': 0x06,
# Vent Section
'PROPULSION_VENT': 0x07,
'CAMERA_1': 0x08,
'CAMERA_2': 0x09,
# Airbrake Section
'CHARGING_AIRBRAKE': 0x0A,
# Payload Section
'CHARGING_PAYLOAD': 0x0B,
'VIBRATION': 0x0C,
# Recovery Electronics(RecElec) Sled
'CHARGING_CAN': 0x0D,
'LOGGER': 0x0E,
'PROCESSOR': 0x0F,
'GPS': 0x10,
'ARMING': 0x11,
'TELEMETRY': 0x12,
'CAMERA_3': 0x13,
# Debug
'USB': 0x14
board_inst_id = {
'ANY': 0x00,
'GENERIC': 0x01,
'ROCKET': 0x02,
'PAYLOAD': 0x03,
'INJ_A': 0x04,
'INJ_B': 0x05,
'VENT_A': 0x06,
'VENT_B': 0x07,
'VENT_C': 0x08,
'VENT_D': 0x09,
'RECOVERY': 0x0A,
'1': 0x0B,
'2': 0x0C,
'3': 0x0D,
'4': 0x0E
}

gen_cmd = {
Expand Down
47 changes: 35 additions & 12 deletions parsley/parsley.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from parsley.bitstring import BitString
from parsley.fields import Field, Switch
from parsley.message_definitions import CAN_MESSAGE, MESSAGE_TYPE, BOARD_ID, MESSAGE_SID
from parsley.message_definitions import CAN_MESSAGE, MESSAGE_PRIO, MESSAGE_TYPE, BOARD_TYPE_ID, BOARD_INST_ID, MESSAGE_SID

import parsley.message_types as mt
import parsley.parse_utils as pu
Expand All @@ -29,11 +29,17 @@ def parse(msg_sid: bytes, msg_data: bytes) -> dict:
Upon reading poorly formatted data, the error is caught and returned in the dictionary.
"""
bit_str_msg_sid = BitString(msg_sid, MESSAGE_SID.length)
encoded_msg_prio = bit_str_msg_sid.pop(MESSAGE_PRIO.length)
encoded_msg_type = bit_str_msg_sid.pop(MESSAGE_TYPE.length)
encoded_board_id = bit_str_msg_sid.pop(BOARD_ID.length)
bit_str_msg_sid.pop(2); # reserved field
encoded_board_type_id = bit_str_msg_sid.pop(BOARD_TYPE_ID.length)
encoded_board_inst_id = bit_str_msg_sid.pop(BOARD_INST_ID.length)

res = parse_board_type_id(encoded_board_type_id)
res['board_inst_id'] = parse_board_inst_id(encoded_board_inst_id)

res = parse_board_id(encoded_board_id)
try:
res['msg_prio'] = MESSAGE_PRIO.decode(encoded_msg_prio)
res['msg_type'] = MESSAGE_TYPE.decode(encoded_msg_type)
# we splice the first element since we've already manually parsed BOARD_ID
# if BOARD_ID threw an error, we want to try and parse the rest of the CAN message
Expand All @@ -50,13 +56,21 @@ def parse(msg_sid: bytes, msg_data: bytes) -> dict:
})
return res

def parse_board_id(encoded_board_id: bytes) -> dict:
def parse_board_type_id(encoded_board_type_id: bytes) -> dict:
try:
board_type_id = BOARD_TYPE_ID.decode(encoded_board_type_id)
except ValueError:
board_type_id = pu.hexify(encoded_board_type_id)
finally:
return {'board_type_id': board_type_id}

def parse_board_inst_id(encoded_board_inst_id: bytes) -> str:
try:
board_id = BOARD_ID.decode(encoded_board_id)
board_inst_id = BOARD_INST_ID.decode(encoded_board_inst_id)
except ValueError:
board_id = pu.hexify(encoded_board_id)
board_inst_id = pu.hexify(encoded_board_inst_id)
finally:
return {'board_id': board_id}
return board_inst_id

def parse_bitstring(bit_str: BitString) -> Tuple[bytes, bytes]:
msg_sid = int.from_bytes(bit_str.pop(MESSAGE_SID.length), byteorder='big')
Expand Down Expand Up @@ -113,12 +127,17 @@ def format_can_message(msg_sid: int, msg_data: List[int]) -> Tuple[bytes, bytes]

# given a dictionary of CAN message data, return the CAN message bits
def encode_data(parsed_data: dict) -> Tuple[int, List[int]]:
msg_prio = parsed_data['msg_prio']
msg_type = parsed_data['msg_type']
board_id = parsed_data['board_id']
board_type_id = parsed_data['board_type_id']
board_inst_id = parsed_data['board_inst_id']

bit_str = BitString()
bit_str.push(*MESSAGE_PRIO.encode(msg_prio))
bit_str.push(*MESSAGE_TYPE.encode(msg_type))
bit_str.push(*BOARD_ID.encode(board_id))
# FIXME
bit_str.push(*BOARD_TYPE_ID.encode(board_type_id))
bit_str.push(*BOARD_INST_ID.encode(board_inst_id))
msg_sid = int.from_bytes(bit_str.pop(bit_str.length), byteorder='big')

# skip the first field (board_id) since thats parsed separately
Expand All @@ -127,15 +146,19 @@ def encode_data(parsed_data: dict) -> Tuple[int, List[int]]:
msg_data = [byte for byte in bit_str.pop(bit_str.length)]
return msg_sid, msg_data

MSG_PRIO_LEN = max([len(msg_prio) for msg_prio in mt.msg_prio])
MSG_TYPE_LEN = max([len(msg_type) for msg_type in mt.msg_type])
BOARD_ID_LEN = max([len(board_id) for board_id in mt.board_id])
BOARD_TYPE_ID_LEN = max([len(board_type_id) for board_type_id in mt.board_type_id])
BOARD_INST_ID_LEN = max([len(board_inst_id) for board_inst_id in mt.board_inst_id])

# formats a parsed CAN message (dictionary) into a singular line
def format_line(parsed_data: dict) -> str:
msg_prio = parsed_data['msg_prio']
msg_type = parsed_data['msg_type']
board_id = parsed_data['board_id']
board_type_id = parsed_data['board_type_id']
board_inst_id = parsed_data['board_inst_id']
data = parsed_data['data']
res = f'[ {msg_type:<{MSG_TYPE_LEN}} {board_id:<{BOARD_ID_LEN}} ]'
res = f'[ {msg_prio:<{MSG_PRIO_LEN}} {msg_type:<{MSG_TYPE_LEN}} {board_type_id:<{BOARD_TYPE_ID_LEN}} {board_inst_id:<{BOARD_INST_ID_LEN}} ]'
for k, v in data.items():
formatted_value = f"{v:.3f}" if isinstance(v, float) else v
res += f' {k}: {formatted_value}'
Expand Down