diff --git a/docs/pages/usage.md b/docs/pages/usage.md index 3e1807d..d6f2c1d 100644 --- a/docs/pages/usage.md +++ b/docs/pages/usage.md @@ -25,15 +25,22 @@ to another. Disambiguation of devices is done by using Stabilizer's MAC address. Settings are specific to an application. If two identical settings exist for two different applications, each application maintains its own independent value. -### Setup +### Installation +Install the Miniconf configuration utilities: +``` +python -m pip install git+https://github.com/quartiq/miniconf#subdirectory=miniconf-py +``` -In order to use `miniconf.py`, run the following command: +To use `miniconf`, execute it as follows: ``` -python -m pip install gmqtt +python -m miniconf --help ``` +Miniconf also exposes a programmatic Python API, so it's possible to write automation scripting of +Stabilizer as well. + ### Usage -The `miniconf.py` script utilizes a unique "device prefix". The device prefix is always of the +The Miniconf Python utility utilizes a unique "device prefix". The device prefix is always of the form `dt/sinara//`, where `` is the name of the application and `` is the MAC address of the device, formatted with delimiting dashes. @@ -46,7 +53,7 @@ used: * `value` = `{"ip": [192, 168, 0, 1], "port": 4000}` ``` -python miniconf.py --broker 10.34.16.10 dt/sinara/dual-iir/00-11-22-33-44-55 stream_target='{"ip": [10, 34, 16, 123], "port": 4000}' +python -m miniconf --broker 10.34.16.10 dt/sinara/dual-iir/00-11-22-33-44-55 stream_target='{"ip": [10, 34, 16, 123], "port": 4000}' Where `10.34.16.10` is the MQTT broker address that matches the one configured in the source code and `10.34.16.123` and `4000` are the desire stream target IP and port. ``` @@ -61,7 +68,7 @@ The rules for constructing `path` values are documented in [`miniconf`'s documentation](https://github.com/quartiq/miniconf#settings-paths) Refer to the documentation for [Miniconf]({{site.baseurl}}/firmware/miniconf/enum.Error.html) for a -description of the possible error codes that `miniconf.py` may return if the settings update was +description of the possible error codes that Miniconf may return if the settings update was unsuccessful. ## Telemetry diff --git a/hitl/loopback.py b/hitl/loopback.py new file mode 100644 index 0000000..475cd63 --- /dev/null +++ b/hitl/loopback.py @@ -0,0 +1,163 @@ +#!/usr/bin/python3 +""" +Author: Vertigo Designs, Ryan Summers + +Description: Loop-back integration tests for Stabilizer hardware +""" +import argparse +import asyncio +import json +import sys + +from gmqtt import Client as MqttClient +from miniconf import Miniconf + +# The minimum allowable loopback voltage error (difference between output set point and input +# measured value). +MINIMUM_VOLTAGE_ERROR = 0.010 + +def _voltage_to_machine_units(voltage): + """ Convert a voltage to IIR machine units. + + Args: + voltage: The voltage to convert + + Returns: + The IIR machine-units associated with the voltage. + """ + dac_range = 4.096 * 2.5 + assert abs(voltage) <= dac_range, 'Voltage out-of-range' + return voltage / dac_range * 0x7FFF + + +def static_iir_output(output_voltage): + """ Generate IIR configuration for a static output voltage. + + Args: + output_voltage: The desired static IIR output voltage. + + Returns + The IIR configuration to send over Miniconf. + """ + machine_units = _voltage_to_machine_units(output_voltage) + return { + 'y_min': machine_units, + 'y_max': machine_units, + 'y_offset': 0, + 'ba': [1, 0, 0, 0, 0], + } + + +class TelemetryReader: + """ Helper utility to read Stabilizer telemetry. """ + + @classmethod + async def create(cls, prefix, broker, queue): + """Create a connection to the broker and an MQTT device using it.""" + client = MqttClient(client_id='') + await client.connect(broker) + return cls(client, prefix, queue) + + + def __init__(self, client, prefix, queue): + """ Constructor. """ + self.client = client + self._telemetry = [] + self.client.on_message = self.handle_telemetry + self._telemetry_topic = f'{prefix}/telemetry' + self.client.subscribe(self._telemetry_topic) + self.queue = queue + + + def handle_telemetry(self, _client, topic, payload, _qos, _properties): + """ Handle incoming telemetry messages over MQTT. """ + assert topic == self._telemetry_topic + self.queue.put_nowait(json.loads(payload)) + + +async def test_loopback(miniconf, telemetry_queue, set_point, gain=1, channel=0): + """ Test loopback operation of Stabilizer. + + Note: + Loopback is tested by configuring DACs for static output and verifying telemetry reports the + ADCs are measuring those values. Output OUTx should be connected in a loopback configuration + to INx on the device. + + Args: + miniconf: The miniconf configuration interface. + telemetry: a helper utility to read inbound telemetry. + set_point: The desired output voltage to test. + channel: The loopback channel to test on. Either 0 or 1. + gain: The desired AFE gain. + """ + print(f'Testing loopback for Vout = {set_point:.2f}, Gain = x{gain}') + print('---------------------------------') + # Configure the AFE and IIRs to output at the set point + await miniconf.command(f'afe/{channel}', f'G{gain}', retain=False) + await miniconf.command(f'iir_ch/{channel}/0', static_iir_output(set_point), retain=False) + + # Configure signal generators to not affect the test. + await miniconf.command('signal_generator/0/amplitude', 0, retain=False) + + # Wait for telemetry to update. + await asyncio.sleep(5.0) + + # Verify the ADCs are receiving the setpoint voltage. + tolerance = max(0.05 * set_point, MINIMUM_VOLTAGE_ERROR) + latest_values = await telemetry_queue.get() + print(f'Latest telemtry: {latest_values}') + + assert abs(latest_values['adcs'][channel] - set_point) < tolerance + print('PASS') + print('') + + +def main(): + """ Main program entry point. """ + parser = argparse.ArgumentParser(description='Loopback tests for Stabilizer HITL testing',) + parser.add_argument('prefix', type=str, + help='The MQTT topic prefix of the target') + parser.add_argument('--broker', '-b', default='mqtt', type=str, + help='The MQTT broker address') + + args = parser.parse_args() + + telemetry_queue = asyncio.LifoQueue() + + async def telemetry(): + await TelemetryReader.create(args.prefix, args.broker, telemetry_queue) + try: + while True: + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + + + telemetry_task = asyncio.Task(telemetry()) + + async def test(): + """ The actual testing being completed. """ + interface = await Miniconf.create(args.prefix, args.broker) + + # Disable IIR holds and configure the telemetry rate. + await interface.command('allow_hold', False, retain=False) + await interface.command('force_hold', False, retain=False) + await interface.command('telemetry_period', 1, retain=False) + + # Test loopback with a static 1V output of the DACs. + await test_loopback(interface, telemetry_queue, 1.0) + + # Repeat test with AFE = 2x + await test_loopback(interface, telemetry_queue, 1.0, gain=2) + + # Test with 0V output + await test_loopback(interface, telemetry_queue, 0.0) + + telemetry_task.cancel() + + loop = asyncio.get_event_loop() + sys.exit(loop.run_until_complete(test())) + + +if __name__ == '__main__': + main() diff --git a/hitl/run.sh b/hitl/run.sh index 739b6ed..ad75102 100755 --- a/hitl/run.sh +++ b/hitl/run.sh @@ -13,7 +13,10 @@ set -eux # Set up python for testing python3 -m venv --system-site-packages py . py/bin/activate -python3 -m pip install -r requirements.txt + +# Install Miniconf utilities for configuring stabilizer. +python3 -m pip install git+https://github.com/quartiq/miniconf#subdirectory=py/miniconf-mqtt +python3 -m pip install gmqtt cargo flash --chip STM32H743ZITx --elf target/thumbv7em-none-eabihf/release/dual-iir @@ -27,6 +30,9 @@ sleep 30 ping -c 5 -w 20 stabilizer-hitl # Test the MQTT interface. -python3 miniconf.py dt/sinara/dual-iir/04-91-62-d9-7e-5f afe/0='"G2"' -python3 miniconf.py dt/sinara/dual-iir/04-91-62-d9-7e-5f afe/0='"G1"' iir_ch/0/0=\ +python3 -m miniconf dt/sinara/dual-iir/04-91-62-d9-7e-5f afe/0='"G2"' +python3 -m miniconf dt/sinara/dual-iir/04-91-62-d9-7e-5f afe/0='"G1"' iir_ch/0/0=\ '{"y_min": -32767, "y_max": 32767, "y_offset": 0, "ba": [1.0, 0, 0, 0, 0]}' + +# Test the ADC/DACs connected via loopback. +python3 hitl/loopback.py dt/sinara/dual-iir/04-91-62-d9-7e-5f diff --git a/miniconf.py b/miniconf.py deleted file mode 100644 index 644e026..0000000 --- a/miniconf.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/python -""" -Author: Vertigo Designs, Ryan Summers - Robert Jördens - -Description: Provides an API for controlling Miniconf devices over MQTT. -""" -import argparse -import asyncio -import json -import logging -import sys -import uuid - -from gmqtt import Client as MqttClient - -LOGGER = logging.getLogger(__name__) - - -class Miniconf: - """An asynchronous API for controlling Miniconf devices using MQTT.""" - - @classmethod - async def create(cls, prefix, broker): - """Create a connection to the broker and a Miniconf device using it.""" - client = MqttClient(client_id='') - await client.connect(broker) - return cls(client, prefix) - - def __init__(self, client, prefix): - """Constructor. - - Args: - client: A connected MQTT5 client. - prefix: The MQTT toptic prefix of the device to control. - """ - self.request_id = 0 - self.client = client - self.prefix = prefix - self.inflight = {} - self.client.on_message = self._handle_response - self.response_topic = f'{prefix}/response/{uuid.uuid1().hex}' - self.client.subscribe(self.response_topic) - - def _handle_response(self, _client, topic, payload, _qos, properties): - """Callback function for when messages are received over MQTT. - - Args: - _client: The MQTT client. - topic: The topic that the message was received on. - payload: The payload of the message. - _qos: The quality-of-service level of the received packet - properties: A dictionary of properties associated with the message. - """ - if topic == self.response_topic: - # Extract request_id corrleation data from the properties - request_id = int.from_bytes( - properties['correlation_data'][0], 'big') - - self.inflight[request_id].set_result(json.loads(payload)) - del self.inflight[request_id] - else: - LOGGER.warn('Unexpected message on "%s"', topic) - - async def command(self, path, value, retain=True): - """Write the provided data to the specified path. - - Args: - path: The path to write the message to. - value: The value to write to the path. - retain: Retain the MQTT message changing the setting - by the broker. - - Returns: - The response to the command as a dictionary. - """ - topic = f'{self.prefix}/settings/{path}' - - fut = asyncio.get_running_loop().create_future() - - # Assign unique correlation data for response dispatch - assert self.request_id not in self.inflight - self.inflight[self.request_id] = fut - correlation_data = self.request_id.to_bytes(4, 'big') - self.request_id += 1 - - payload = json.dumps(value) - LOGGER.info('Sending "%s" to "%s"', value, topic) - - self.client.publish( - topic, payload=payload, qos=0, retain=retain, - response_topic=self.response_topic, - correlation_data=correlation_data) - - return await fut - - -def main(): - """ Main program entry point. """ - parser = argparse.ArgumentParser( - description='Miniconf command line interface.', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog='''Examples: -%(prog)s dt/sinara/dual-iir/00-11-22-33-aa-bb iir_ch/0/0=\ -'{"y_min":-32767,"y_max":32767,"y_offset":0,"ba":[1.0,0,0,0,0]}' -%(prog)s dt/sinara/lockin/00-11-22-33-aa-bb afe/0='"G2"'\ -''') - parser.add_argument('-v', '--verbose', action='count', default=0, - help='Increase logging verbosity') - parser.add_argument('--broker', '-b', default='mqtt', type=str, - help='The MQTT broker address') - parser.add_argument('--no-retain', '-n', default=False, - action='store_true', - help='Do not retain the affected settings') - parser.add_argument('prefix', type=str, - help='The MQTT topic prefix of the target') - parser.add_argument('settings', metavar="PATH=VALUE", nargs='+', - help='JSON encoded values for settings path keys.') - - args = parser.parse_args() - - logging.basicConfig( - format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', - level=logging.WARN - 10*args.verbose) - - loop = asyncio.get_event_loop() - - async def configure_settings(): - interface = await Miniconf.create(args.prefix, args.broker) - for setting in args.settings: - path, value = setting.split("=", 1) - response = await interface.command(path, json.loads(value), - not args.no_retain) - print(f'{path}: {response}') - if response['code'] != 0: - return response['code'] - return 0 - - sys.exit(loop.run_until_complete(configure_settings())) - - -if __name__ == '__main__': - main() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index a3d7fef..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -gmqtt