Pine

V1.0

🌲 Control aircon by monitoring temperature in other parts of the room 🎐

power AA
wireless Infrared
sensor Temperature
mcu SAMD21G18
bom items 49
bom cost USD $9.71
vendors 2
status completed in September 2020

Features

  • Record and store the ON and OFF aircon commands with IR receiver
  • Turn on or off the aircon with the IR emitter
  • Measure temperature of another part of the room
  • Go into sleep mode after turning on or off the aircon

Firmware

Download code
// #define DEBUG

#include "DebugUtils.h"
#include "src/serial/serial.h"
#include "src/sensor/sensor.h"
#include "src/webusb/webusb.h"
#include "src/infrared/infrared.h"
#include "src/led/led.h"
#include <FlashStorage.h>

#define MAX_IR_LEN 300
typedef struct {
  int sizeofON;
  uint16_t rawDataON[MAX_IR_LEN];
  int sizeofOFF;
  uint16_t rawDataOFF[MAX_IR_LEN];
  int interval;
  int duration;
  int temperature;
  bool valid;
} IRRawCode;
IRRawCode userConfig;
IRRawCode readConfig;

int config[3];
int configIndex = 0;
bool isRecordingON = false;
bool isRecordingOFF = false;
bool isConfigMode = false;
bool hasSentUserConfig = false;

FlashStorage(my_flash_store, IRRawCode);

void setup() {
  #ifdef DEBUG
  initSerial();
  SerialUSB.println("Start Pine...");
  #endif

  initWebUSBSerial();

  if (initSensor()) {
    DEBUG_PRINT(readTemperature());
    DEBUG_PRINT(readHumidity());
  } else {
    DEBUG_TITLE("Error: Si7021 Sensor not found.")
  }

  initIR();
  initLED();
  readConfig = my_flash_store.read();
}

void loop() {
  // Scenario: Very first setup
  if (!readConfig.valid && !isConfigMode) {
    DEBUG_TITLE("Add user config at https://hutscape.com/pine/webusb");
    blink();
  }

  // Scenario: Normal operation
  if (readConfig.valid && !isConfigMode) {
    DEBUG_TITLE("Do demo task: Turn on/off aircon every 5s");
    doTask();
  }

  // Scenario: Config mode
  if (isConfigMode) {
    if (!hasSentUserConfig && readConfig.valid) {
      DEBUG_TITLE("Sending user config to web browser");
      sendUserConfig();
      hasSentUserConfig = true;
    }

    if (receiveIR()) {
      if (isValidIRCode()) {
        receiveIRFromUser();
      }
      enableIR();
    }
  }

  // All scenarios: alway check if web usb is connected
  if (isWebUSBAvailable()) {
    receiveConfigFromUser(readWebUSB());
  }
}

// Print out raw IR code received from the user pressing a remote control
void receiveIRFromUser() {
  int irSize = getIRcodeSize();
  uint16_t irCode[MAX_IR_LEN];
  getIRCode(irCode, MAX_IR_LEN);

  DEBUG_TITLE("Received user IR code...");

  #ifdef DEBUG
  SerialUSB.print(F("#define RAW_DATA_LEN "));
  SerialUSB.println(irSize, DEC);
  SerialUSB.print(F("uint16_t rawData[RAW_DATA_LEN]={\n"));

  for (int i = 1; i < irSize; i++) {
    SerialUSB.print(irCode[i], DEC);
    SerialUSB.print(F(", "));
  }

  SerialUSB.println(F("1000};\n"));
  #endif

  // TODO: Abstract out into a single function
  if (isRecordingON) {
    userConfig.sizeofON = irSize;

    for (int i = 0; i < irSize - 1; i++) {
      userConfig.rawDataON[i] = irCode[i+1];
    }

    userConfig.rawDataON[irSize - 1] = 1000;
    isRecordingON = false;
  } else if (isRecordingOFF) {
    userConfig.sizeofOFF = irSize;

    for (int i = 0; i < irSize - 1; i++) {
      userConfig.rawDataOFF[i] = irCode[i+1];
    }

    userConfig.rawDataOFF[irSize - 1] = 1000;
    isRecordingOFF = false;
  }

  writeWebUSB((const uint8_t *)irCode, irSize*2);
}

// Emit IR code that is stored in the flash memory
void doTask() {
  delay(5000);
  sendIR(readConfig.rawDataON, readConfig.sizeofON, 36);
  DEBUG_TITLE("Sent Turn ON Aircon");

  delay(5000);
  sendIR(readConfig.rawDataOFF, readConfig.sizeofOFF, 36);
  DEBUG_TITLE("Sent Turn OFF Aircon");
}

// Read config values from the user and store in flash memory
void receiveConfigFromUser(int byte) {
  if (byte == 'A') {
    DEBUG_TITLE("Recording ON IR command");
    isRecordingON = true;
  } else if (byte == 'B') {
    DEBUG_TITLE("Recording OFF IR command");
    isRecordingOFF = true;
  } else if (byte == '1') {
    DEBUG_TITLE("Connected via Web USB");
    isConfigMode = true;
    hasSentUserConfig = false;
  } else {
    config[configIndex++] = byte;
    if (configIndex == 3) {
      configIndex = 0;
      DEBUG_TITLE("Received user config from the browser");

      userConfig.interval = config[0];
      DEBUG_PRINT(userConfig.interval);

      userConfig.duration = config[1];
      DEBUG_PRINT(userConfig.duration);

      userConfig.temperature = config[2];
      DEBUG_PRINT(userConfig.temperature);

      printWebUSB("From MCU: Received all user configurations.");
      userConfig.valid = true;
      isConfigMode = false;

      storeUserConfig();
    }
  }
}

void sendUserConfig() {
  DEBUG_PRINT(readConfig.interval);
  DEBUG_PRINT(readConfig.duration);
  DEBUG_PRINT(readConfig.temperature);

  // NOTE: Send to the web browser as a JSON string
  String config = "{\"interval\":"
    + String(readConfig.interval)
    + ", \"duration\":"
    + String(readConfig.duration)
    + ", \"temperature\":"
    + String(readConfig.temperature)
    + "}";

  printWebUSB(config);
}

void storeUserConfig() {
  my_flash_store.write(userConfig);
  readConfig = my_flash_store.read();
}

Makefile

BOARD?=arduino:samd:arduino_zero_native
PORT := $(shell ls /dev/cu.usbmodem*)

.PHONY: default lint all flash clean

default: lint all flash clean

lint:
	cpplint --extensions=ino --filter=-legal/copyright,-whitespace/line_length,-readability/casting,-readability/todo *.ino

all:
	# This custom PCB does not have a crytal on pins PA00 and PA01
	# Hence, use -DCRYSTALLESS to replace the extra_flags in boards.txt
	arduino-cli compile --fqbn $(BOARD) --build-properties build.extra_flags="-DCRYSTALLESS -D__SAMD21G18A__ {build.usb_flags}" ./
flash:
	arduino-cli upload -p $(PORT) --fqbn $(BOARD)

server:
	echo "Open Chrome browser at http://localhost:8000"
	python -m SimpleHTTPServer 8000

clean:
	rm -r build

Serial console

Firmware serial console

Test

Download test code
#include "Adafruit_Si7021.h"
#include <IRLibSendBase.h>
#include <IRLib_HashRaw.h>
#include <IRLibRecvPCI.h>
#include <WebUSB.h>
#include "./data.h"

#define LED 13
// https://github.com/cyborg5/IRLib2/blob/master/IRLibProtocols/IRLibSAMD21.h#L38-L39
// For SAMD21 boards: We are recommending using Pin 5 for receiving
#define IR_RECEIVE_PIN 5

// https://github.com/cyborg5/IRLib2/blob/master/IRLibProtocols/IRLibSAMD21.h#L40-L41
// For SAMD21 boards: For sending, default pin is 9
#define IR_EMIT_PIN 9

Adafruit_Si7021 sensor = Adafruit_Si7021();
WebUSB WebUSBSerial(1, "webusb.github.io/arduino/demos/console");
IRrecvPCI myReceiver(IR_RECEIVE_PIN);
IRsendRaw mySender;
String readString;
int test = 0;

void setup() {
  SerialUSB.begin(9600);
  while (!SerialUSB) {}

  WebUSBSerial.begin(9600);

  delay(1000);

  pinMode(LED, OUTPUT);
  myReceiver.enableIRIn();

  SerialUSB.println("Starting Pine design verification test!");
  SerialUSB.println("-------------------------------------");

  SerialUSB.println("\n\nTest 1: It expects to turn ON and OFF the LED");
  blink(5);
  delay(1000);

  SerialUSB.println("\n\nTest 2: It expects to measure the humidity and temp");
  if (initSensor()) {
    SerialUSB.print("Humidity: ");
    SerialUSB.print(sensor.readHumidity(), 2);
    SerialUSB.println(" RH%");

    SerialUSB.print("Temperature: ");
    SerialUSB.print(sensor.readTemperature(), 2);
    SerialUSB.println(" C");
  }
  delay(1000);

  SerialUSB.println("\n\nTest 3: It expects to turn ON the aircon");
  SerialUSB.print("Have you pointed the circuit to the aircon? [y/n]: ");
  test = 0;
  while (test == 0) {
    while (SerialUSB.available()) {
      delay(3);
      char c = SerialUSB.read();
      readString += c;
    }

    if (readString == "y") {
      SerialUSB.println(readString);
      mySender.send(rawDataON, RAW_DATA_LEN, 38);
      SerialUSB.println("Sent Turn ON Aircon");

      readString = "";
      test = 1;
    }
  }
  delay(1000);

  SerialUSB.println("\n\nTest 4: It expects to turn OFF the aircon");
  SerialUSB.print("Have you pointed the circuit to the aircon? [y/n]: ");
  test = 0;
  while (test == 0) {
    while (SerialUSB.available()) {
      delay(3);
      char c = SerialUSB.read();
      readString += c;
    }

    if (readString == "y") {
      SerialUSB.println(readString);
      mySender.send(rawDataOFF, RAW_DATA_LEN, 38);
      SerialUSB.println("Sent Turn OFF Aircon");

      readString = "";
      test = 1;
    }
  }
  delay(1000);

  SerialUSB.println("\n\nTest 5: It expects to receive IR signals");
  SerialUSB.print("Have you pressed and pointed the remote control to Pine? [y/n]: ");
  test = 0;
  while (test == 0) {
    while (SerialUSB.available()) {
      delay(3);
      char c = SerialUSB.read();
      readString += c;
    }

    if (myReceiver.getResults()) {
      SerialUSB.print(" Received IR signal length: ");
      SerialUSB.print(recvGlobal.recvLength, DEC);
      SerialUSB.print("     ");
      myReceiver.enableIRIn();
    }

    if (readString == "y") {
      SerialUSB.println(readString);
      readString = "";
      test = 1;
    }
  }

  SerialUSB.println("\n\nTest 6: It expects to receive info from the browser");
  SerialUSB.print("Have you turned on/off led from hutscape.com/webusb-led? [y/n]: ");
  test = 0;
  while (test == 0) {
    while (SerialUSB.available()) {
      delay(3);
      char c = SerialUSB.read();
      readString += c;
    }

    if (readString == "y") {
      SerialUSB.println(readString);
      readString = "";
      test = 1;
    }

    if (WebUSBSerial && WebUSBSerial.available()) {
      int byte = WebUSBSerial.read();

      if (byte == 'H') {
        SerialUSB.print("LED ON     ");
        WebUSBSerial.write("Turning LED on.");
        digitalWrite(LED, HIGH);
      } else if (byte == 'L') {
        SerialUSB.print("LED OFF     ");
        WebUSBSerial.write("Turning LED off.");
        digitalWrite(LED, LOW);
      }

      WebUSBSerial.flush();
    }
  }
  delay(1000);

  SerialUSB.println("\n\nTest 7: It expects to send info to the browser");
  SerialUSB.print("Have you open the dev console at hutscape.com/webusb-send? [y/n]: ");
  test = 0;
  while (test == 0) {
    while (SerialUSB.available()) {
      delay(3);
      char c = SerialUSB.read();
      readString += c;
    }

    if (readString == "y") {
      SerialUSB.println(readString);
      readString = "";
      test = 1;

      WebUSBSerial.write("Hello from mcu to the browser!");
      WebUSBSerial.flush();
    }
  }
  delay(1000);

  SerialUSB.println("\n\n-------------------------------------");
  SerialUSB.println("Completed Pine design verification test!");
  SerialUSB.println("-------------------------------------");
}

void loop() { }

void blink(int num) {
  for (int i=0; i < num; i++) {
    SerialUSB.print(i+1);
    SerialUSB.print("  ");

    digitalWrite(LED, LOW);
    delay(500);
    digitalWrite(LED, HIGH);
    delay(500);
  }
}

bool initSensor() {
  if (!sensor.begin()) {
    SerialUSB.println("\nERROR: Did not find Si7021 sensor!");
    return false;
  }

  return true;
}

Makefile

BOARD?=arduino:samd:arduino_zero_native
PORT := $(shell ls /dev/cu.usbmodem*)

.PHONY: default lint all flash clean

default: lint all flash clean

lint:
	cpplint --extensions=ino --filter=-legal/copyright,-whitespace/line_length *.ino

all:
	# This custom PCB does not have a crytal on pins PA00 and PA01
	# Hence, use -DCRYSTALLESS to replace the extra_flags in boards.txt
	arduino-cli compile --fqbn $(BOARD) --build-properties build.extra_flags="-DCRYSTALLESS -D__SAMD21G18A__ {build.usb_flags}" ./

flash:
	arduino-cli upload -p $(PORT) --fqbn $(BOARD)

server:
	echo "Open Chrome browser at http://localhost:8000"
	python -m SimpleHTTPServer 8000

clean:
	rm -r build

Serial console

Firmware serial console

Web USB

Web USB on Chrome browser is used to take in user configuration about how they want to control their aircon.

Download code View demo
<!doctype html>
<html>
<head>
  <title>Configure Pine</title>
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/bulma/0.7.5/css/bulma.min.css">
</head>
<body>
<section class="hero is-small is-light">
  <div class="hero-body">
    <div class="container">
      <h2 class="subtitle is-1">Configure [email protected]</h2>
    </div>
  </div>
</section>

<section class="section is-small">
  <div class="container">
    <div class="content is-large">
      <p id="info"></p>
    </div>
  </div>
</section>

<section class="section is-small is-hidden" id="configure">
  <div class="container">
    <!-- TODO: Aircon ON / OFF commands should be able to be read again and tested before submitting -->
    <div class="field">
      <label class="label is-large">Aircon ON</label>
      <button id="on" class="button is-info">Record ON command</button>
      <p class="help">Point the aircon remote controller to Pine and press the on button.</p>
      <p id="on-command"></p>
    </div>

    <div class="field">
      <label class="label is-large">Aircon OFF</label>
      <button id="off" class="button is-info">Record OFF command</button>
      <p class="help">Point the aircon remote controller to Pine and press the off button.</p>
      <p id="off-command"></p>
    </div>

    <form id="form">
      <div class="field">
        <label class="label is-large">Interval (minutes)</label>
        <div class="control">
          <input class="input is-large" id="interval" required="required" type="number" min=10 placeholder="Interval (mins)"/>
          <p class="help">Pine will check for the room temperature every interval set. Example every 20 minutes.</p>
        </div>
      </div>

      <div class="field">
        <label class="label is-large">Total duration (hours)</label>
        <div class="control">
          <input class="input is-large" id="duration" type="number" required="required" min=1 placeholder="Total period (hours)"/>
          <p class="help">Pine will check for the room temperature every interval set for a total of this duration.</p>
        </div>
      </div>

      <div class="field">
        <label class="label is-large">Ideal room temperature (C)</label>
        <div class="control">
          <input class="input is-large" id="temperature" type="number" required="required" min=15 max=28 placeholder="Ideal temperature (C)"/>
          <p class="help">Pine will turn on or off the aircon according to this temperature.</p>
        </div>
      </div>
    </form>
  </div>
</section>

<section class="section is-small">
  <div class="container">
    <div class="control">
      <input class="button is-info is-large" id="connect" type="submit" value="Connect to Pine">
    </div>
  </div>
</section>

<script src="serial.js"></script>
<script>
var port
let formSection = document.querySelector('#form')
let connectButton = document.querySelector('#connect')
let submitButton = document.querySelector('#submit')

let intervalEl = document.getElementById("interval")
let durationEl = document.getElementById("duration")
let temperatureEl = document.getElementById("temperature")

let recordONButton = document.querySelector('#on')
var isRecordingON = false
let recordOFFButton = document.querySelector('#off')
var isRecordingOFF = false

let configureSection = document.querySelector('#configure')
let infoSection = document.querySelector('#info')
let textEncoder = new TextEncoder()
let textDecoder = new TextDecoder()
var irCode = []

connectButton.addEventListener('click', function() {
  if (port) { // If port is already connected, disconnect it
    if (!formSection.checkValidity()) {
      console.log("Form fields are not valid. Please check.")
      return
    }

    let config = new Uint8Array(3)
    config[0] = document.getElementById("interval").value
    config[1] = document.getElementById("duration").value
    config[2] = document.getElementById("temperature").value
    console.log("Sending config to MCU")
    port.send(config).catch(error => {
      console.log('Send error: ' + error)
    })

    // FIXME: Disconnect only after successfully sending all the config to the mcu
    // Not after 1 second delay
    setTimeout(function() {
      port.disconnect()
      port = null

      connectButton.value = 'Connect to Pine'
      configureSection.classList.add("is-hidden")
      infoSection.innerHTML = 'User config stored successfully!'
    }, 1000);
  } else { // If there is no port, then connect to a new port
    console.log("Connect!")
    serial.requestPort().then(selectedPort => {
      port = selectedPort
      configureSection.classList.remove("is-hidden")
      port.connect().then(() => {
        infoSection.innerHTML = '<strong>Pine is now connected via Web USB!</strong>' +
          '<br>Product ID: 0x' + port.device_.productId.toString(16) +
          '<br>Vendor ID: 0x' + port.device_.vendorId.toString(16)

        port.send(textEncoder.encode('1')).catch(error => {
          console.log('Send error: ' + error)
        })

        connectButton.value = 'Submit'
        port.onReceive = data => { processReceievedData(data) }
        port.onReceiveError = error => { console.log('Receive error: ' + error)}
      }, error => { console.log('Connection error: ' + error) })
    }).catch(error => { console.log('Connection error: ' + error) })
  }
})

// TODO: Refactor recordONButton and recordOFFButton into a single function
recordONButton.addEventListener('click', function() {
  recordONButton.classList.add("is-warning")
  recordONButton.textContent = 'Recording ON Command...'
  isRecordingON = true
  port.send(textEncoder.encode('A')).catch(error => {
    console.log('Send error: ' + error)
  })
})

recordOFFButton.addEventListener('click', function() {
  recordOFFButton.classList.add("is-warning")
  recordOFFButton.textContent = 'Recording OFF Command...'
  isRecordingOFF = true
  port.send(textEncoder.encode('B')).catch(error => {
    console.log('Send error: ' + error)
  })
})

function processReceievedData(data) {
  var str = textDecoder.decode(data)

  // Data received: string message "From MCU: "
  if (isMessage(str)) {
    console.log(str)
    return
  }

  // Data received type: JSON user config
  if (isJson(str)) {
    var userConfig = JSON.parse(str)
    console.log(userConfig)

    intervalEl.value = userConfig.interval
    durationEl.value = userConfig.duration
    temperatureEl.value = userConfig.temperature

    return
  }

  // Data received type: int 16 array for IR raw code
  var irCodeReceieved = new Int16Array(data.buffer)

  if (irCodeReceieved.length == 32) {
    irCodeReceieved.forEach(function(el) {
      irCode.push(el)
    })
  } else if (irCodeReceieved.length < 32 && irCodeReceieved.length > 0) {
    irCodeReceieved.forEach(function(el) {
      irCode.push(el)
    })

    irCode.shift() // Remove the first element which is always garbage
    irCode.push(1000) // Add 1000 as the last element

    if (irCode.length == 292) { // Valid IR Code is 292
      if (isRecordingON) {
        document.getElementById('on-command').innerText = "Received ON command: [" + irCode.join(', ') + "]"
        recordONButton.classList.remove("is-warning")
        recordONButton.textContent = 'Record ON Command'
        isRecordingON = false
      }

      if (isRecordingOFF) {
        document.getElementById('off-command').innerText = "Received OFF command: [" + irCode.join(', ') + "]"
        recordOFFButton.classList.remove("is-warning")
        recordOFFButton.textContent = 'Record OFF Command'
        isRecordingOFF = false
      }
    }

    irCode = []
  }
}

function isJson(str) {
  try {
    JSON.parse(str);
  } catch (e) {
    return false;
  }
  return true;
}

function isMessage(str) {
  // A message from the micro-controller will start with "From MCU"
  return str.substring(0, 8) === "From MCU"
}
</script>
</body>