diff --git a/docs/agents/devantech_dS378.rst b/docs/agents/devantech_dS378.rst new file mode 100644 index 000000000..73c1608f5 --- /dev/null +++ b/docs/agents/devantech_dS378.rst @@ -0,0 +1,82 @@ +.. highlight:: rst + +.. _devantech_dS378: + +======================== +Devantech dS378 Agent +======================== + +This agent is designed to interface with devantech's dS378 ethernet relay. + + +.. argparse:: + :filename: ../socs/agents/devantech_dS378/agent.py + :func: make_parser + :prog: python3 agent.py + +Configuration File Examples +--------------------------- + +Below are configuration examples for the ocs config file and for running the +Agent in a docker container. + +OCS Site Config +````````````````````` + +An example site-config-file block:: + + {'agent-class': 'dS378Agent', + 'instance-id': 'ds378', + 'arguments': [['--port', 17123], + ['--ip_address', '192.168.0.100']] + }, + + +Docker Compose +`````````````` + +The dS378 Agent should be configured to run in a Docker container. An +example docker-compose service configuration is shown here:: + + ocs-ds378: + image: simonsobs/socs:latest + hostname: ocs-docker + network_mode: "host" + volumes: + - ${OCS_CONFIG_DIR}:/config:ro + environment: + - INSTANCE_ID=ds378 + - LOGLEVEL=info + +The ``LOGLEVEL`` environment variable can be used to set the log level for +debugging. The default level is "info". + +Description +-------------- + +dS378 is a board with 8 relays that can be cotrolled via ethernet. +The relay can be used for both DC (up to 24 V) and AC (up to 250V). +The electronics box for the stimulator uses this board to control +shutter and powercycling devices such as motor controller or KR260 board. + +The driver code assumes the board is configured to communicate with binary codes. +This configuration can be changed via web interface (but requires USB connection as well, +see `documentation `_ provided from the manufacturer). +You can also configure the ip address and the port number with the same interface. + +The device only accepts 10/100BASE communication. + +Agent API +--------- + +.. autoclass:: socs.agents.devantech_dS378.agent.DS378Agent + :members: + +Supporting APIs +--------------- + +.. autoclass:: socs.agents.devantech_dS378.drivers.DS378 + :members: + +.. autoclass:: socs.agents.devantech_dS378.drivers.RelayStatus + :members: diff --git a/docs/index.rst b/docs/index.rst index 8c7816542..d153ec993 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ API Reference Full API documentation for core parts of the SOCS library. agents/acu_agent agents/bluefors_agent agents/cryomech_cpa + agents/devantech_dS378 agents/fts_agent agents/generator agents/hi6200 diff --git a/socs/agents/devantech_dS378/__init__.py b/socs/agents/devantech_dS378/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/socs/agents/devantech_dS378/agent.py b/socs/agents/devantech_dS378/agent.py new file mode 100644 index 000000000..bf92238c5 --- /dev/null +++ b/socs/agents/devantech_dS378/agent.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +'''OCS agent for dS378 ethernet relay +''' +import argparse +import os +import time + +import txaio +from ocs import ocs_agent, site_config +from ocs.ocs_twisted import Pacemaker, TimeoutLock + +from socs.agents.devantech_dS378.drivers import DS378 + +PORT_DEFAULT = 17123 + +LOCK_RELEASE_SEC = 1. +LOCK_RELEASE_TIMEOUT = 10 +ACQ_TIMEOUT = 100 + + +class DS378Agent: + """OCS agent class for dS378 ethernet relay + + Parameters + ---------- + ip : string + IP address + port : int + Port number + """ + + def __init__(self, agent, ip, port=PORT_DEFAULT): + self.active = True + self.agent = agent + self.log = agent.log + self.lock = TimeoutLock() + self.take_data = False + + self._dev = DS378(ip=ip, port=port) + + self.initialized = False + + agg_params = {'frame_length': 60} + self.agent.register_feed('relay', + record=True, + agg_params=agg_params, + buffer_time=1) + + @ocs_agent.param('sampling_frequency', default=0.5, type=float) + def acq(self, session, params): + """acq() + + **Process** - Monitor status of the relay. + + Parameters + ---------- + sampling_frequency : float, optional + Sampling frequency in Hz, defaults to 0.5 Hz. + + Notes + ----- + An example of the session data:: + + >>> response.session['data'] + + {'V_sppl': 11.8, + 'T_int': 30.8, + 'Relay_1': 0, + 'Relay_2': ..., + 'timestamp': 1736541796.779634 + } + """ + f_sample = params.get('sampling_frequency', 0.5) + pace_maker = Pacemaker(f_sample) + + with self.lock.acquire_timeout(timeout=0, job='acq') as acquired: + if not acquired: + self.log.warn( + f'Could not start acq because {self.lock.job} is already running') + return False, 'Could not acquire lock.' + + self.take_data = True + session.data = {} + last_release = time.time() + + while self.take_data: + # Release lock + if time.time() - last_release > LOCK_RELEASE_SEC: + last_release = time.time() + if not self.lock.release_and_acquire(timeout=LOCK_RELEASE_TIMEOUT): + print(f'Re-acquire failed: {self.lock.job}') + return False, 'Could not re-acquire lock.' + + # Data acquisition + current_time = time.time() + data = {'timestamp': current_time, 'block_name': 'relay', 'data': {}} + + try: + d_status = self._dev.get_status() + relay_list = self._dev.get_relays() + if session.degraded: + self.log.info('Connection re-established.') + session.degraded = False + except ConnectionError: + self.log.error('Failed to get data from relay. Check network connection.') + session.degraded = True + time.sleep(1) + continue + + data['data']['V_sppl'] = d_status['V_sppl'] + data['data']['T_int'] = d_status['T_int'] + for i in range(8): + data['data'][f'Relay_{i + 1}'] = relay_list[i] + + field_dict = {'V_sppl': d_status['V_sppl'], + 'T_int': d_status['T_int']} + + for i in range(8): + field_dict[f'Relay_{i + 1}'] = relay_list[i] + + session.data.update(field_dict) + + self.agent.publish_to_feed('relay', data) + session.data.update({'timestamp': current_time}) + + pace_maker.sleep() + + self.agent.feeds['relay'].flush_buffer() + + return True, 'Acquisition exited cleanly.' + + def _stop_acq(self, session, params=None): + if self.take_data: + self.take_data = False + return True, 'requested to stop taking data.' + + return False, 'acq is not currently running.' + + @ocs_agent.param('relay_number', type=int, check=lambda x: 1 <= x <= 8) + @ocs_agent.param('on_off', type=int, choices=[0, 1]) + @ocs_agent.param('pulse_time', default=None, type=int, check=lambda x: 0 <= x <= 2**32 - 1) + def set_relay(self, session, params=None): + """set_relay(relay_number, on_off, pulse_time=None) + + **Task** - Turns the relay on/off or pulses it. + + Parameters + ---------- + relay_number : int + Relay number to manipulate. Values must be in range [1, 8]. + on_off : int + 1 (0) to turn on (off). + pulse_time : int, optional + Pulse time in ms. Values must be in range [0, 4294967295]. + + Notes + ----- + This command pulses relay for a given period when ``pulse_time`` + argument is specified, otherwise just turns a relay on or off. + + """ + with self.lock.acquire_timeout(3, job='set_values') as acquired: + if not acquired: + self.log.warn('Could not start set_values because ' + f'{self.lock.job} is already running') + return False, 'Could not acquire lock.' + + if params.get('pulse_time') is None: + params['pulse_time'] = 0 + + self._dev.set_relay(relay_number=params['relay_number'], + on_off=params['on_off'], + pulse_time=params['pulse_time']) + + return True, 'Set values' + + def get_relays(self, session, params=None): + """get_relays() + + **Task** - Returns current relay status. + + Notes + ----- + The most recent data collected is stored in session.data in the + structure:: + + >>> response.session['data'] + {'Relay_1': 1, + 'Relay_2': ..., + 'timestamp': 1736541796.779634 + } + """ + with self.lock.acquire_timeout(3, job='get_relays') as acquired: + if not acquired: + self.log.warn('Could not start get_relays because ' + f'{self.lock.job} is already running') + return False, 'Could not acquire lock.' + + d_status = self._dev.get_relays() + session.data = {f'Relay_{i + 1}': d_status[i] for i in range(8)} + session.data.update({'timestamp': time.time()}) + + return True, 'Got relay status' + + +def make_parser(parser=None): + if parser is None: + parser = argparse.ArgumentParser() + + pgroup = parser.add_argument_group('Agent Options') + pgroup.add_argument('--port', default=PORT_DEFAULT, type=int, + help='Port number for TCP communication.') + pgroup.add_argument('--ip_address', + help='IP address of the device.') + + return parser + + +def main(args=None): + """Boot OCS agent""" + txaio.start_logging(level=os.environ.get('LOGLEVEL', 'info')) + + parser = make_parser() + args = site_config.parse_args(agent_class='DS378Agent', + parser=parser, + args=args) + + agent_inst, runner = ocs_agent.init_site_agent(args) + ds_agent = DS378Agent(agent_inst, ip=args.ip_address, port=args.port) + + agent_inst.register_task( + 'set_relay', + ds_agent.set_relay + ) + + agent_inst.register_task( + 'get_relays', + ds_agent.get_relays + ) + + agent_inst.register_process( + 'acq', + ds_agent.acq, + ds_agent._stop_acq, + startup=True + ) + + runner.run(agent_inst, auto_reconnect=True) + + +if __name__ == '__main__': + main() diff --git a/socs/agents/devantech_dS378/drivers.py b/socs/agents/devantech_dS378/drivers.py new file mode 100644 index 000000000..f37d7b004 --- /dev/null +++ b/socs/agents/devantech_dS378/drivers.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""dS378 ethernet relay""" +import sys +from enum import IntEnum + +from socs.tcp import TCPInterface + +# Command byte, length of expected response +GET_STATUS = 0x30, 8 +SET_RELAY = 0x31, 1 +SET_OUTPUT = 0x32, 1 +GET_RELAYS = 0x33, 5 +GET_INPUTS = 0x34, 2 +GET_ANALOG = 0x35, 14 +GET_COUNTERS = 0x36, 8 + +# Number of trial for send / receive +N_TRIAL = 2 +LEN_BUFF = 1024 + + +class RelayStatus(IntEnum): + """Relay status""" + on = 1 + off = 0 + + +class DS378(TCPInterface): + """dS378 ethernet relay""" + + def __init__(self, ip, port, timeout=10): + super().__init__(ip, port, timeout) + + def __del__(self): + self._com.close() + + def _send(self, msg): + self.send(msg) + + def _send1(self, msg_byte): + self._send(bytearray([msg_byte])) + + def _recv(self): + rcv = self.recv(LEN_BUFF) + return rcv + + def _recv1(self): + return self._recv()[0] + + def _send_recv(self, msg, length): + # Check the length of the received message + # to drop invalid response when reconnecting. + for _ in range(N_TRIAL): + self._send(msg) + msg_rcv = self._recv() + if len(msg_rcv) == length: + return msg_rcv + + raise ConnectionError + + def get_status(self): + """Get status of the dS378 device. + + Returns + ------- + d_status : dict + Status information + """ + + ret_bytes = self._send_recv(bytearray([GET_STATUS[0]]), + GET_STATUS[1]) + + d_status = {} + d_status['module_id'] = ret_bytes[0] + d_status['firm_ver'] = f'{ret_bytes[1]}.{ret_bytes[2]}' + d_status['app_ver'] = f'{ret_bytes[3]}.{ret_bytes[4]}' + d_status['V_sppl'] = ret_bytes[5] / 10 + d_status['T_int'] = int.from_bytes(ret_bytes[6:8], signed=True, byteorder='big') / 10 + + return d_status + + def set_relay(self, relay_number, on_off, pulse_time=0): + """Turns the relay on/off or pulses it + + Parameters + ---------- + relay_number : int + relay_number, 1 -- 8 + on_off : int or RelayStatus + 1: on, 0: off + pulse_time : int, 32 bit + See document + + Returns + ------- + status : int + 0: ACK + otherwise: NACK + """ + assert 1 <= relay_number <= 8 + assert 0 <= pulse_time <= 2**32 - 1 + assert on_off in [0, 1] + + msg = bytearray([SET_RELAY[0], relay_number, on_off]) + msg += pulse_time.to_bytes(4, byteorder='big') + self._send(msg) + + return self._recv1() + + def set_output(self, io_num, on_off): + """Set output on/off + + Parameters + ---------- + io_num : int, 1 -- 7 + I/O port number + on_off : int or RelayStatus + 1: on, 0: off + """ + assert 1 <= io_num <= 7 + assert on_off in [0, 1] + + msg = bytearray([SET_OUTPUT[0], io_num, on_off]) + self._send(msg) + + return self._recv1() + + def get_relays(self): + """Get relay states + + Returns + ------- + d_status : list of RelayStatus + """ + + ret_bytes = self._send_recv(bytearray([GET_RELAYS[0], 1]), + GET_RELAYS[1]) + + d_status = [None] * 32 + for i in range(32): + d_status[i] = RelayStatus((ret_bytes[4 - int(i / 8)] >> (i % 8)) & 1) + + return d_status + + def get_inputs(self): + """Get input states + + Returns + ------- + d_status : list of RelayStatus + """ + + ret_bytes = self._send_recv(bytearray([GET_INPUTS[0], 1]), + GET_INPUTS[1]) + + d_status = [None] * 7 + for i in range(7): + d_status[i] = RelayStatus((ret_bytes[1] >> i) & 1) + + return d_status + + def get_analog(self): + """Get analog input + + Returns + ------- + values : list of int + """ + + ret_bytes = self._send_recv(bytearray([GET_ANALOG[0]]), + GET_ANALOG[1]) + + values = [None] * 7 + for i in range(7): + values[i] = int.from_bytes(ret_bytes[2 * i:2 * i + 2], byteorder='big') + + return values + + def get_counters(self, counter_number): + """Get counters + + Parameter + --------- + counter_number : int + counter number. 1 -- 8 + + Returns + ------- + c_current : int + current counter value + c_reg : register + capture register for the counter + """ + + ret_bytes = self._send_recv(bytearray([GET_COUNTERS[0], counter_number]), + GET_COUNTERS[1]) + c_current = int.from_bytes(ret_bytes[0:4], byteorder='big') + c_reg = int.from_bytes(ret_bytes[4:8], byteorder='big') + + return c_current, c_reg + + +def main(): + """Main function""" + ds_dev = DS378(sys.argv[1], 17123) + print(ds_dev.get_status()) + print(ds_dev.get_relays()[:8]) + ds_dev.set_relay(3, RelayStatus.off) + print(ds_dev.get_relays()[:8]) + print(ds_dev.get_analog()) + print(ds_dev.get_inputs()) + + +if __name__ == '__main__': + main() diff --git a/socs/agents/ocs_plugin_so.py b/socs/agents/ocs_plugin_so.py index a78b86d3b..962f5c601 100644 --- a/socs/agents/ocs_plugin_so.py +++ b/socs/agents/ocs_plugin_so.py @@ -15,6 +15,7 @@ ('BlueforsAgent', 'bluefors/agent.py'), ('CrateAgent', 'smurf_crate_monitor/agent.py'), ('CryomechCPAAgent', 'cryomech_cpa/agent.py'), + ('dS378Agent', 'devantech_dS378/agent.py'), ('FlowmeterAgent', 'ifm_sbn246_flowmeter/agent.py'), ('FPGAAgent', 'holo_fpga/agent.py'), ('FTSAerotechAgent', 'fts_aerotech/agent.py'), diff --git a/socs/plugin.py b/socs/plugin.py index 23f35266e..153f2c5bc 100644 --- a/socs/plugin.py +++ b/socs/plugin.py @@ -4,6 +4,7 @@ 'BlueforsAgent': {'module': 'socs.agents.bluefors.agent', 'entry_point': 'main'}, 'CrateAgent': {'module': 'socs.agents.smurf_crate_monitor.agent', 'entry_point': 'main'}, 'CryomechCPAAgent': {'module': 'socs.agents.cryomech_cpa.agent', 'entry_point': 'main'}, + 'dS378Agent': {'module': 'socs.agents.devantech_dS378.agent', 'entry_point': 'main'}, 'FPGAAgent': {'module': 'socs.agents.holo_fpga.agent', 'entry_point': 'main'}, 'FlowmeterAgent': {'module': 'socs.agents.ifm_sbn246_flowmeter.agent', 'entry_point': 'main'}, 'FTSAerotechAgent': {'module': 'socs.agents.fts_aerotech.agent', 'entry_point': 'main'},