/************************************************************************* * * * This file is part of the 20n/act project. * * 20n/act enables DNA prediction for synthetic biology/bioengineering. * * Copyright (C) 2017 20n Labs, Inc. * * * * Please direct all queries to act@20n.com. * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see <http://www.gnu.org/licenses/>. * * * *************************************************************************/ package com.twentyn.bioreactor.sensors; import com.fasterxml.jackson.core.JsonEncoding; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.joda.JodaModule; import com.pi4j.io.i2c.I2CBus; import com.pi4j.io.i2c.I2CDevice; import com.pi4j.io.i2c.I2CFactory; import com.twentyn.bioreactor.util.Time; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.joda.time.DateTime; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.CommandLineParser; import org.apache.commons.cli.DefaultParser; import org.apache.commons.cli.HelpFormatter; import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import org.apache.commons.cli.ParseException; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; public class Sensor { private static final Logger LOGGER = LogManager.getFormatterLogger(Sensor.class); private static final String HELP_MESSAGE = "This class allows clients to register and run different sensors from a Raspberry Pi"; private static final HelpFormatter HELP_FORMATTER = new HelpFormatter(); private static final String OPTION_TYPE = "t"; private static final String OPTION_READING_PATH = "p"; private static final String OPTION_ADDRESS = "a"; private static final String OPTION_NAME = "n"; private static final List<Option.Builder> OPTION_BUILDERS = new ArrayList<Option.Builder>() {{ add(Option.builder(OPTION_TYPE) .argName("sensor type") .desc("Type of sensor: can take values of the enum SensorType: {PH, DO, TEMP}") .hasArg().required() .longOpt("sensor_type") ); add(Option.builder(OPTION_ADDRESS) .argName("sensor address") .desc("Address of the sensor to read from") .hasArg().required() .longOpt("sensor_address") ); add(Option.builder(OPTION_NAME) .argName("sensor name") .desc("Name under which to register the sensor") .hasArg().required() .longOpt("sensor_name") ); add(Option.builder(OPTION_READING_PATH) .argName("reading log path") .desc("Directory in which to store the sensor readings/logs") .hasArg() .longOpt("reading_log_path") ); add(Option.builder("h") .argName("help") .desc("Prints this help message") .longOpt("help") ); }}; private static final Boolean INFINITE_LOOP_READING = true; // Reading and log file default locations private static final String DEFAULT_READING_PATH = "/tmp/sensors/"; private static final String LOG_EXTENSION = ".log"; // READ command for sensor // This command is the same across Sensor types (pH, dissolved oxygen, temperature). // If that changes, make it Sensor type specific. private static final byte READ_COMMAND = (byte) 'R'; // When reading EZO circuits responses, we ask for a specific number of bytes. // The following constant defines how many bytes to read from the circuit response to a read query // Max number of bytes back from each sensor: {pH: 7, Temp: 9, DO: 14} // Therefore, this constant is set to 14 to be sure to read everything. Any extra byte will be null ('\0') private static final Integer N_BYTES = 14; // Response codes from a read event (encoded in the first byte) private static final byte REPONSE_CODE_SUCCESS = (byte) 1; private static final byte REPONSE_CODE_FAILED = (byte) 2; private static final byte REPONSE_CODE_PENDING = (byte) 254; private static final byte REPONSE_CODE_NO_DATA = (byte) 255; // In the event of a failed reading, we will retry N_RETRIES times to read, after waiting RETRY_DELAY seconds. private static final Integer N_RETRIES = 3; private static final Integer RETRY_DELAY = 500; // Default bus is #1 private static final Integer I2CBUS = I2CBus.BUS_1; private static final ObjectMapper objectMapper = new ObjectMapper(); // Device object private I2CDevice sensor; // Device name private String deviceName; // Sensor data (updated each read) private SensorData sensorData; // Sensor reading file location private Path sensorReadingFilePath; // Sensor reading log file location private Path sensorReadingLogFilePath; // Json generator to append to log file private JsonGenerator jsonGenerator; // Temp file where to write readings before atomically moving it private File sensorReadingTmp; // Sensor config parameters private Byte readCommand; static { objectMapper.registerModule(new JodaModule()); objectMapper.configure(SerializationFeature.INDENT_OUTPUT, true); } public enum SensorType { PH, DO, TEMP } public Sensor(SensorData sensorData, String deviceName) { this.sensorData = sensorData; this.deviceName = deviceName; this.sensorData.setDeviceName(deviceName); } public void setup(Integer deviceAddress, String sensorReadingPath) { // connects the device, create the right logging directories connectToDevice(I2CBUS, deviceAddress); setupFiles(sensorReadingPath); this.readCommand = READ_COMMAND; } private void setupFiles(String sensorReadingPath) { String logFilename = deviceName.concat(LOG_EXTENSION); Path sensorReadingDirectory = Paths.get(sensorReadingPath, sensorData.getDeviceType()); this.sensorReadingFilePath = Paths.get( sensorReadingDirectory.toString(), deviceName); this.sensorReadingLogFilePath = Paths.get( sensorReadingDirectory.toString(), logFilename); if (!Files.exists(sensorReadingDirectory)) { Boolean madeDir = sensorReadingDirectory.toFile().mkdirs(); if (!madeDir) { LOGGER.error("The following directory could not be accessed or created: %s", sensorReadingDirectory); } } try { this.jsonGenerator = objectMapper.getFactory().createGenerator( new File(sensorReadingLogFilePath.toString()), JsonEncoding.UTF8); this.sensorReadingTmp = File.createTempFile(sensorReadingFilePath.toString(), ".tmp"); } catch (IOException e) { LOGGER.error("Error during reading/log files creation: %s", e); System.exit(1); } } private void connectToDevice(Integer i2CBusNumber, Integer deviceAddress) { try { I2CBus bus = I2CFactory.getInstance(i2CBusNumber); LOGGER.info("Connected to bus #%d\n", i2CBusNumber); sensor = bus.getDevice(deviceAddress); LOGGER.info("Connected to device at address %d\n", deviceAddress); } catch (I2CFactory.UnsupportedBusNumberException e) { LOGGER.error("Connection to bus #%d failed: %s", i2CBusNumber, e); System.exit(1); } catch (IOException e) { LOGGER.error("Connection to device at address %d failed: %s\n", deviceAddress, e); System.exit(1); } } public void setReadCommand(byte readCommand) { this.readCommand = readCommand; } private byte[] readSensorResponse() { byte[] deviceResponse = new byte[N_BYTES]; try { sensor.write(readCommand); Thread.sleep(sensorData.getReadQueryTimeDelay()); sensor.read(deviceResponse, 0, N_BYTES); byte responseCode = deviceResponse[0]; if (responseCode == REPONSE_CODE_FAILED || responseCode == REPONSE_CODE_NO_DATA) { LOGGER.error("A read query failed or returned no data"); throw new IOException("The device did not return any sensor reading."); } else if (responseCode == REPONSE_CODE_PENDING) { int retryCounter = 0; while (responseCode == REPONSE_CODE_PENDING && retryCounter <= N_RETRIES) { LOGGER.debug("Read query returned PENDING response code: %d remaining attempts to read", N_RETRIES - retryCounter); Thread.sleep(RETRY_DELAY); sensor.read(deviceResponse, 0, N_BYTES); responseCode = deviceResponse[0]; retryCounter++; } if (responseCode != REPONSE_CODE_SUCCESS) { LOGGER.error("Did not manage to read sensor values after %d retries\n", N_RETRIES); throw new IOException("The device did not return any sensor reading."); } LOGGER.debug("Read succeeded after %d retries", retryCounter); } } catch (IOException e) { LOGGER.error("Error reading sensor value: %s", e); } catch (InterruptedException e) { LOGGER.error("Interrupted Exception: %s", e); } return deviceResponse; } public DateTime now() { return Time.now(); } private void atomicWrite(SensorData sensorData) throws IOException { // Write a sensor reading to a temporary file objectMapper.writeValue(sensorReadingTmp, sensorData); // Atomically move the temporary file once written to the location Files.move(sensorReadingTmp.toPath(), sensorReadingFilePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); // Append sensor reading to log file objectMapper.writeValue(jsonGenerator, sensorData); } public void run() { // We start an infinite reading loop, which we exit only by interrupting the process. while (INFINITE_LOOP_READING) { byte[] sensorResponse = readSensorResponse(); sensorData.setTimeOfReading(now()); sensorData.parseSensorDataFromResponse(sensorResponse); try { atomicWrite(sensorData); } catch (IOException e) { LOGGER.error("Exception when trying to write the sensor data to file: %s", e); System.exit(1); } } } public static void main(String[] args) { Options opts = new Options(); for (Option.Builder b : OPTION_BUILDERS) { opts.addOption(b.build()); } CommandLine cl = null; try { CommandLineParser parser = new DefaultParser(); cl = parser.parse(opts, args); } catch (ParseException e) { LOGGER.error(String.format("Argument parsing failed: %s\n", e.getMessage())); HELP_FORMATTER.printHelp(Sensor.class.getCanonicalName(), HELP_MESSAGE, opts, null, true); System.exit(1); } if (cl.hasOption("help")) { HELP_FORMATTER.printHelp(Sensor.class.getCanonicalName(), HELP_MESSAGE, opts, null, true); return; } SensorType sensorType = null; try { sensorType = SensorType.valueOf(cl.getOptionValue(OPTION_TYPE)); LOGGER.debug("Sensor Type %s was choosen", sensorType); } catch (IllegalArgumentException e) { LOGGER.error("Illegal value for Sensor Type. Note: it is case-sensitive."); HELP_FORMATTER.printHelp(Sensor.class.getCanonicalName(), HELP_MESSAGE, opts, null, true); System.exit(1); } SensorData sensorData = null; switch (sensorType) { case PH: sensorData = new PHSensorData(); break; case DO: sensorData = new DOSensorData(); break; case TEMP: sensorData = new TempSensorData(); break; } Integer deviceAddress = Integer.parseInt(cl.getOptionValue(OPTION_ADDRESS)); String deviceName = cl.getOptionValue(OPTION_NAME); String sensorReadingPath = cl.getOptionValue(OPTION_READING_PATH, DEFAULT_READING_PATH); Sensor sensor = new Sensor(sensorData, deviceName); sensor.setup(deviceAddress, sensorReadingPath); sensor.run(); } }