Browse Source

Merge pull request #152 from Teque5/feature/optional

Updates to SigMF GUI tool
pull/156/head
Ben Hilburn 4 weeks ago
committed by GitHub
parent
commit
4154335b1e
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 20
      setup.py
  2. 2
      sigmf/__init__.py
  3. 2
      sigmf/archive.py
  4. 2
      sigmf/error.py
  5. 193
      sigmf/gui.py
  6. 11
      sigmf/schema.py
  7. 2
      sigmf/sigmf_hash.py
  8. 9
      sigmf/sigmffile.py
  9. 9
      sigmf/utils.py
  10. 2
      sigmf/validate.py

20
setup.py

@ -3,14 +3,14 @@ from setuptools import setup
import os
import re
shortdesc = "Signal Metadata Format Specification"
longdesc = """
shortdesc = 'Signal Metadata Format Specification'
longdesc = '''
The Signal Metadata Format (SigMF) specifies a way to describe
sets of recorded digital signal samples with metadata written in JSON.
SigMF can be used to describe general information about a collection
of samples, the characteristics of the system that generated the
samples, and features of the signal itself.
"""
'''
with open(os.path.join('sigmf', '__init__.py')) as handle:
version = re.search(r'__version__\s*=\s*[\'"]([^\'"]*)[\'"]', handle.read()).group(1)
@ -29,14 +29,20 @@ setup(
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
],
entry_points = {
'console_scripts': ['sigmf_validate=sigmf.validate:main']
entry_points={
'console_scripts': [
'sigmf_validate = sigmf.validate:main',
'sigmf_gui = sigmf.gui:main [gui]',
]
},
packages=['sigmf'],
package_data = {
package_data={
'sigmf': ['*.json'],
},
install_requires=['six', 'numpy', 'pysimplegui==4.0.0'],
install_requires=['numpy'],
extras_require={
'gui': 'pysimplegui==4.0.0'
},
setup_requires=['pytest-runner'],
tests_require=['pytest>3'],
zip_safe=False

2
sigmf/__init__.py

@ -1,4 +1,4 @@
# Copyright 2016 GNU Radio Foundation
# Copyright 2021 GNU Radio Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal

2
sigmf/archive.py

@ -1,4 +1,4 @@
# Copyright 2017 GNU Radio Foundation
# Copyright 2021 GNU Radio Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal

2
sigmf/error.py

@ -1,4 +1,4 @@
# Copyright 2017 GNU Radio Foundation
# Copyright 2021 GNU Radio Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal

193
scripts/sigmf_gui.py → sigmf/gui.py

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# Copyright 2020 The Johns Hopkins University Applied Physics Laboratory LLC. All Rights Reserved.
# Copyright 2021 GNU Radio Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -8,8 +7,8 @@
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
@ -19,13 +18,16 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from PySimpleGUI import *
from sigmf.sigmffile import SigMFFile, fromarchive, dtype_info
from sigmf.archive import SIGMF_ARCHIVE_EXT
'''GUI for creating & editing SigMF Files'''
import os
import warnings
import logging
from PySimpleGUI import *
warnings.filterwarnings("error")
from .sigmffile import SigMFFile, fromarchive, dtype_info
from .archive import SIGMF_ARCHIVE_EXT
log = logging.getLogger()
validate_button = Button('Update', bind_return_key=False, enable_events=True)
submit_button = Button('Save Archive', disabled=True, button_color=('white', '#D3D3D3'))
@ -42,8 +44,7 @@ class Unit:
def convert(unit, value: float):
if unit is None:
return input
if unit == Unit.MHZ:
elif unit == Unit.MHZ:
return value * 1e6
elif unit == Unit.US:
return value * 1e-6
@ -105,47 +106,69 @@ class WindowInput(WindowElementGroup):
DATA_BYTE_ORDER = 'Data Byte Order'
def __init__(self):
SAMPLING_RATE = 'Sampling Rate'
DATA_OFFSET = 'Data Offset'
DESCRIPTION = 'Description'
AUTHOR = 'Author'
DATE = 'Date'
HARDWARE = 'Hardware'
RECEIVER_RF = 'Receiver RF'
RECEIVER_LAT = 'Receiver Lat'
RECEIVER_LON = 'Receiver Lon'
NORM_TECH = 'Normalization Technique'
ANTEN_POL = 'Antenna Polarization'
ANTEN_GAIN = 'Antenna Gain'
self.core_element_list = [WindowInput.DATA_TYPE_COMPLEX, WindowInput.DATA_TYPE_UNSIGNED,
WindowInput.DATA_TYPE_FIXEDPOINT, WindowInput.DATA_SAMPLE_SIZE,
WindowInput.DATA_BYTE_ORDER, DATA_OFFSET, DESCRIPTION, AUTHOR, DATE]
self.secondary_element_list = [HARDWARE, NORM_TECH, RECEIVER_LAT, ANTEN_POL, RECEIVER_LON, ANTEN_GAIN,
RECEIVER_RF]
self.core_element_list = [
WindowInput.DATA_TYPE_COMPLEX,
WindowInput.DATA_TYPE_UNSIGNED,
WindowInput.DATA_TYPE_FIXEDPOINT,
WindowInput.DATA_SAMPLE_SIZE,
WindowInput.DATA_BYTE_ORDER,
DATA_OFFSET, DESCRIPTION, AUTHOR, DATE, SAMPLING_RATE,
]
self.secondary_element_list = [HARDWARE, NORM_TECH, RECEIVER_LAT, ANTEN_POL, RECEIVER_LON, ANTEN_GAIN]
self.file_element_list = [WindowInput.DATA_FILE, WindowInput.OUTPUT_FOLDER]
self.partial_component_list = [WindowInput.DATA_TYPE_COMPLEX, WindowInput.DATA_TYPE_UNSIGNED,
WindowInput.DATA_TYPE_FIXEDPOINT, WindowInput.DATA_SAMPLE_SIZE,
WindowInput.DATA_BYTE_ORDER]
sigmf_tags = {DESCRIPTION: SigMFFile.DESCRIPTION_KEY,
AUTHOR: SigMFFile.AUTHOR_KEY, DATE: SigMFFile.DATETIME_KEY, HARDWARE: SigMFFile.HW_KEY,
RECEIVER_RF: SigMFFile.FREQUENCY_KEY, RECEIVER_LAT: SigMFFile.LAT_KEY,
RECEIVER_LON: SigMFFile.LON_KEY}
req_tags = [WindowInput.DATA_FILE, WindowInput.DATA_SAMPLE_SIZE, WindowInput.DATA_BYTE_ORDER, RECEIVER_RF]
el_types = {WindowInput.DATA_TYPE_COMPLEX: bool, WindowInput.DATA_TYPE_UNSIGNED: bool,
WindowInput.DATA_TYPE_FIXEDPOINT: bool, DATA_OFFSET: int, RECEIVER_LAT: float,
RECEIVER_LON: float, ANTEN_GAIN: float, RECEIVER_RF: float}
el_units = {ANTEN_GAIN: Unit.DBI, RECEIVER_RF: Unit.MHZ}
el_tooltip = {DATE: 'YYYY-MM-DD',
DATA_OFFSET: 'int: bit offset from start of data file of first element'}
self.partial_component_list = [
WindowInput.DATA_TYPE_COMPLEX,
WindowInput.DATA_TYPE_UNSIGNED,
WindowInput.DATA_TYPE_FIXEDPOINT,
WindowInput.DATA_SAMPLE_SIZE,
WindowInput.DATA_BYTE_ORDER,
]
sigmf_tags = {
DESCRIPTION: SigMFFile.DESCRIPTION_KEY,
SAMPLING_RATE: SigMFFile.SAMPLE_RATE_KEY,
AUTHOR: SigMFFile.AUTHOR_KEY,
DATE: SigMFFile.DATETIME_KEY,
HARDWARE: SigMFFile.HW_KEY,
RECEIVER_LAT: SigMFFile.LAT_KEY,
RECEIVER_LON: SigMFFile.LON_KEY,
}
req_tags = [WindowInput.DATA_FILE, WindowInput.DATA_SAMPLE_SIZE, WindowInput.DATA_BYTE_ORDER, SAMPLING_RATE]
el_types = {
WindowInput.DATA_TYPE_COMPLEX: bool,
WindowInput.DATA_TYPE_UNSIGNED: bool,
WindowInput.DATA_TYPE_FIXEDPOINT: bool,
DATA_OFFSET: int, RECEIVER_LAT: float,
RECEIVER_LON: float, ANTEN_GAIN: float, SAMPLING_RATE: float,
}
el_units = {ANTEN_GAIN: Unit.DBI}
el_tooltip = {
DATE: 'YYYY-MM-DD',
DATA_OFFSET: 'int: bit offset from start of data file of first element'}
el_text = {}
el_help = {WindowInput.DATA_BYTE_ORDER: 'Data Type Help'}
el_multiline = [DESCRIPTION]
el_selector = {WindowInput.DATA_SAMPLE_SIZE: [8, 16, 32],
WindowInput.DATA_BYTE_ORDER: ['little endian', 'big endian']}
el_checkbox = [WindowInput.DATA_TYPE_COMPLEX, WindowInput.DATA_TYPE_UNSIGNED, WindowInput.DATA_TYPE_FIXEDPOINT]
el_size = {WindowInput.DATA_TYPE_COMPLEX: (3, 1), WindowInput.DATA_TYPE_UNSIGNED: (3, 1),
WindowInput.DATA_TYPE_FIXEDPOINT: (3, 1), WindowInput.DATA_SAMPLE_SIZE: (4, 1),
WindowInput.DATA_BYTE_ORDER: (15, 1)}
el_size = {
WindowInput.DATA_TYPE_COMPLEX: (3, 1),
WindowInput.DATA_TYPE_UNSIGNED: (3, 1),
WindowInput.DATA_TYPE_FIXEDPOINT: (3, 1),
WindowInput.DATA_SAMPLE_SIZE: (4, 1),
WindowInput.DATA_BYTE_ORDER: (15, 1),
}
self.first_line_size = 5
super().__init__(self.core_element_list + self.secondary_element_list, sigmf_tags, req_tags, el_types, el_units,
el_tooltip, el_text, el_help, el_multiline, el_selector, el_checkbox, el_size)
@ -173,10 +196,9 @@ class CaptureData(WindowElementGroup):
START_INDEX = 'Start Index'
def __init__(self):
SAMPLING_RATE = 'Sampling Rate'
EMITTER = 'Emitter'
MODE_OF_OPERATION = 'Mode(s) of Operation'
SIGNAL_RF = 'Signal RF'
RECEIVER_RF = 'Receiver RF'
SIGNAL_BANDWIDTH = 'Signal Bandwidth'
MODULATION = 'Modulation'
PRF = 'PRF'
@ -190,16 +212,21 @@ class CaptureData(WindowElementGroup):
COMMENT = 'Comment'
self.annotation_element_list = [COMMENT]
element_list = [CaptureData.START_INDEX, SAMPLING_RATE, EMITTER, MODE_OF_OPERATION, SIGNAL_RF, SIGNAL_BANDWIDTH,
element_list = [CaptureData.START_INDEX, EMITTER, MODE_OF_OPERATION, RECEIVER_RF, SIGNAL_BANDWIDTH,
MODULATION, PRF, STAGGER, FREQUENCY_HOPPING, PW, BEAM_PATTERN, SNR,
CHIP_RATE] + self.annotation_element_list
sigmf_tags = {CaptureData.START_INDEX: SigMFFile.START_INDEX_KEY, SAMPLING_RATE: SigMFFile.SAMPLE_RATE_KEY,
COMMENT: SigMFFile.COMMENT_KEY}
req_tags = [CaptureData.START_INDEX, SAMPLING_RATE]
el_types = {CaptureData.START_INDEX: int, SAMPLING_RATE: float, SIGNAL_RF: float, SIGNAL_BANDWIDTH: float,
PRF: float, PW: float, SNR: float, CHIP_RATE: float}
el_units = {SIGNAL_RF: Unit.MHZ, PW: Unit.US, SIGNAL_BANDWIDTH: Unit.MHZ}
sigmf_tags = {
CaptureData.START_INDEX: SigMFFile.START_INDEX_KEY,
RECEIVER_RF: SigMFFile.FREQUENCY_KEY,
COMMENT: SigMFFile.COMMENT_KEY
}
req_tags = [CaptureData.START_INDEX]
el_types = {
CaptureData.START_INDEX: int,
RECEIVER_RF: float, SIGNAL_BANDWIDTH: float,
PRF: float, PW: float, SNR: float, CHIP_RATE: float,
}
el_units = {PW: Unit.US, SIGNAL_BANDWIDTH: Unit.MHZ, RECEIVER_RF: Unit.MHZ}
el_tooltip = {CaptureData.START_INDEX: 'int: start index in file of capture'}
el_text = {}
el_help = {}
@ -217,9 +244,9 @@ def update_dictionary(dictionary, key, val):
def add_sigmf_field(funct, values, field_name, *args, required=False, type=None, unit=None, **kwargs):
input = str(values[field_name]) if field_name in values else ''
print(args)
print(input)
print(kwargs)
log.debug(f'args {args}')
log.debug(f'input {input}')
log.debug(f'kwargs {kwargs}')
if input != '':
if type == int:
if '.' in input:
@ -227,21 +254,21 @@ def add_sigmf_field(funct, values, field_name, *args, required=False, type=None,
return False
try:
input = int(input)
except:
except ValueError:
show_error('Expected an integer for: {}'.format(field_name))
return False
elif type == float:
try:
input = float(input)
except:
except ValueError:
show_error('Expected a double for: {}'.format(field_name))
return False
elif type == bool:
try:
if input != 'False' and input != 'True':
if input not in ['False', 'True']:
raise ValueError('Unexpected input for boolean')
input = True if input == 'True' else False
except:
except ValueError:
show_error('Expected a bool for: {}'.format(field_name))
return False
Unit.convert(unit, input)
@ -251,7 +278,7 @@ def add_sigmf_field(funct, values, field_name, *args, required=False, type=None,
else:
funct(*args, input)
except UserWarning as w:
Popup('Warning: '.format(repr(w)), title='Warning')
Popup('Warning: {}'.format(repr(w)), title='Warning')
except Exception as e:
show_error(repr(e))
return False
@ -267,7 +294,7 @@ def show_error(message):
def validate_data(file):
isValid = file.validate()
print('Valid: ', isValid)
log.info(f'valid: {isValid}')
if not isValid:
show_error('Metadata not valid: ' + isValid.error)
submit_button.Update(disabled=True)
@ -310,7 +337,7 @@ def update_global_screen(window_data_input, window_text_blocks, window_dict, arc
def add_capture(capture_data_input, values, capture_selector_dict, file_data, from_archive=False):
capture_dict = dict()
capture_dict = {}
added = True
for el in capture_data_input.iter():
req_field = True if el in capture_data_input.req_tags else False
@ -367,15 +394,29 @@ def add_capture(capture_data_input, values, capture_selector_dict, file_data, fr
combo_button.Update(values=tuple(new_values), value=new_val)
def run_gui():
def main():
import argparse
parser = argparse.ArgumentParser(description='Edit SigMF Archive.')
parser.add_argument('-i', '--input', help='Input SigMF Archive Path.', default=None)
parser.add_argument('-v', '--verbose', action='count', default=0)
args = parser.parse_args()
level_lut = {
0: logging.WARNING,
1: logging.INFO,
2: logging.DEBUG,
}
logging.basicConfig(level=level_lut[min(args.verbose, 2)])
window_input = WindowInput()
capture_data_input = CaptureData()
capture_text_blocks = dict()
window_text_blocks = dict()
capture_text_blocks = {}
window_text_blocks = {}
f = SigMFFile()
capture_selector_dict = dict()
capture_selector_dict = {}
layout = [[Text('This is the APL SIGMF tool to archive RF datasets', size=(80, 1))],
layout = [[Text('This is the SigMF tool to archive RF datasets', size=(80, 1))],
[Text('Enter your data and signal captures below. You must include', auto_size_text=True),
Text('required', text_color='red', font=DEFAULT_FONT + ('italic',), auto_size_text=True),
Text('fields.', size=(50, 1), auto_size_text=True)],
@ -463,13 +504,24 @@ def run_gui():
[validate_button, Button('View Data')]]
)
window = Window('APL SigMF Archive Creator',
window = Window('SigMF Archive Creator',
auto_size_buttons=False,
default_element_size=(20, 1),
auto_size_text=False,
default_button_element_size=(10, 1)
).Layout(layout)
if args.input:
window.Refresh()
# optional input file specified -> load
log.info(f'reading from {args.input}')
load_path = args.input
f = fromarchive(load_path)
update_global_screen(window_input, window_text_blocks, f.get_global_info(), f)
capture_selector_dict = {}
for capture in f.get_captures():
add_capture(capture_data_input, capture, capture_selector_dict, f, from_archive=True)
while True:
validate_button.Update(text='Update')
load_button.Update(text='Load')
@ -477,7 +529,7 @@ def run_gui():
window.Refresh()
event, values = window.Read()
print(event, values)
log.debug(f'event: {event}, values: {values}')
if event == 'Load Archive':
load_path = values[WindowInput.LOAD_PATH]
if load_path == '':
@ -486,7 +538,7 @@ def run_gui():
load_button.Update(text='Loading...')
window.Refresh()
print('reading from ', values[WindowInput.LOAD_PATH])
log.info(f'reading from {values[WindowInput.LOAD_PATH]}')
f = fromarchive(values[WindowInput.LOAD_PATH])
update_global_screen(window_input, window_text_blocks, f.get_global_info(), f)
capture_selector_dict = {}
@ -532,13 +584,10 @@ def run_gui():
data_type_str += 'f' if not bool(window_data_type_dict[WindowInput.DATA_TYPE_FIXEDPOINT]) else ''
data_type_str += 'u' if bool(window_data_type_dict[WindowInput.DATA_TYPE_UNSIGNED]) else ''
data_type_str += str(window_data_type_dict[WindowInput.DATA_SAMPLE_SIZE]) + '_'
data_type_str += 'l' if window_data_type_dict[WindowInput.DATA_BYTE_ORDER] == 'little endian' else 'b'
data_type_str += 'le' if window_data_type_dict[WindowInput.DATA_BYTE_ORDER] == 'little endian' else 'be'
data_type_dict = {SigMFFile.DATATYPE_KEY: data_type_str}
added = added and add_sigmf_field(SigMFFile.set_global_field, data_type_dict, SigMFFile.DATATYPE_KEY, f,
SigMFFile.DATATYPE_KEY, required=True)
print('HERE: ', window_data_type_dict)
added = added and add_sigmf_field(SigMFFile.set_data_file, values, WindowInput.DATA_FILE, f,
required=True) and added
added = added and add_sigmf_field(SigMFFile.set_global_field, data_type_dict, SigMFFile.DATATYPE_KEY, f, SigMFFile.DATATYPE_KEY, required=True)
added = added and add_sigmf_field(SigMFFile.set_data_file, values, WindowInput.DATA_FILE, f, required=True) and added
if not added:
# requirement not given
continue
@ -552,7 +601,7 @@ def run_gui():
add_capture(capture_data_input, values, capture_selector_dict, f)
elif event == 'Remove Capture':
capture_dict = dict()
capture_dict = {}
added = add_sigmf_field(update_dictionary, values, CaptureData.START_INDEX, capture_dict,
SigMFFile.START_INDEX_KEY, required=True, type=int)
if not added:
@ -589,7 +638,7 @@ def run_gui():
if output_folder == '':
show_error('No output folder provided')
continue
elif len(capture_selector_dict.keys()) == 0:
if len(capture_selector_dict.keys()) == 0:
show_error('No capture data specified')
submit_button.Update(text='Saving...')
window.Refresh()
@ -601,7 +650,3 @@ def run_gui():
break
window.Close()
if __name__ == '__main__':
run_gui()

11
sigmf/schema.py

@ -1,4 +1,4 @@
# Copyright 2016 GNU Radio Foundation
# Copyright 2021 GNU Radio Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -27,8 +27,15 @@ from . import utils
def get_schema(version=None):
'''
safe load json
In the future load specific schema versions.
'''
schema_file = os.path.join(
utils.get_schema_path(os.path.dirname(utils.__file__)),
'schema.json'
)
return json.load(open(schema_file))
with open(schema_file, 'rb') as handle:
schema = json.load(handle)
return schema

2
sigmf/sigmf_hash.py

@ -1,4 +1,4 @@
# Copyright 2016 GNU Radio Foundation
# Copyright 2021 GNU Radio Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal

9
sigmf/sigmffile.py

@ -1,4 +1,4 @@
# Copyright 2016 GNU Radio Foundation
# Copyright 2021 GNU Radio Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -27,7 +27,6 @@ import tarfile
import tempfile
from os import path
import warnings
from six import iteritems
import numpy as np
from . import __version__, schema, sigmf_hash, validate
@ -119,7 +118,7 @@ class SigMFFile():
Throws if not.
"""
schema_section = self.get_schema()[section_key]
for key, value in iteritems(entries):
for key, value in entries.items():
validate.validate_key_throw(
value, schema_section.get(key, {}), schema_section, key
)
@ -616,7 +615,7 @@ def get_default_metadata(schema):
" Return a dict with all default values from keys_dict "
return {
key: desc.get("default")
for key, desc in iteritems(keys_dict)
for key, desc in keys_dict.items()
if "default" in desc
}
@ -629,5 +628,5 @@ def get_default_metadata(schema):
return {
category: default_category_data(desc["type"], get_default_dict(desc["keys"]))
for category, desc in iteritems(schema)
for category, desc in schema.items()
}

9
sigmf/utils.py

@ -1,4 +1,4 @@
# Copyright 2016 GNU Radio Foundation
# Copyright 2021 GNU Radio Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@ -23,9 +23,6 @@
from copy import deepcopy
from datetime import datetime
from six import iteritems
SIGMF_DATETIME_ISO8601_FMT = "%Y-%m-%dT%H:%M:%S.%fZ"
@ -44,13 +41,14 @@ def dict_merge(a_dict, b_dict):
if not isinstance(b_dict, dict):
return b_dict
result = deepcopy(a_dict)
for key, value in iteritems(b_dict):
for key, value in b_dict.items():
if key in result and isinstance(result[key], dict):
result[key] = dict_merge(result[key], value)
else:
result[key] = deepcopy(value)
return result
def insert_sorted_dict_list(dict_list, new_entry, key, force_insertion=False):
"""
Insert new_entry (which must be a dict) into a sorted list of other dicts.
@ -70,6 +68,7 @@ def insert_sorted_dict_list(dict_list, new_entry, key, force_insertion=False):
dict_list = dict_list + [new_entry]
return dict_list
def get_schema_path(module_path):
"""
"""

2
sigmf/validate.py

@ -1,4 +1,4 @@
# Copyright 2016 GNU Radio Foundation
# Copyright 2021 GNU Radio Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal

Loading…
Cancel
Save