content: /*************************************************************************
  *                                                                        *
  *  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();
    }
  }
  