Super Squirter

supersq1080717

Summary (Draft)

Create your own iPhone controlled and motion activated water gun with pan tilt aiming, video feed and fast burst camera!

This comprehensive step by step guide will illustrate everything from the theory of operation, how to build it, where to buy the parts and how the code works.

The Super Squirter can be used with the optional iPhone App (low low price of 99B) where it can be sighted, aimed, adjusted and triggered all while receiving the video feed to the screen. Whats more the App can connect to the Super Squirter from anywhere in the world.

WiWheels iOS App

 

The App is optional, the Super Squirter still works without it, when motion is detected the Super Squirter will fire and the Camera will save a series of fast burst shots to the SD Card. Also the SD card can be accessed using a browser so you can view, download or delete the images online.

These are the highlights

  • An ESPino is used to control the pan tilt arm, detect motion via a PIR Motion Sensor, turn the pump on using a transistor as a switch and trigger the camera on the Arducam UNO .
  • An ESP8266 ESP-12F UNO Board for ArduCAM Mini Camera and the Arducam Mini module Camera Shield w/ 2 MP OV2640 is used for the video feed to the iPhone App and as a fast burst camera to save images to the SD Card.
  • A HC-SR501 PIR Motion Detector is used to trigger the pump, pan tilt action and the camera burst to SD Card.
  • Communication between the ESPino and Arducam UNO and the iPhone App is done using HTTP.
  • An SD Card in the Arducam UNO is used to save images and to serve a htm file so that the saved images can be accessed via browser.
  • A home made vacuum relief valve is used to prevent water siphoning from the reservoir through the pump when not on.
  • A 12v  3A Windscreen Washer Pump is used.
  • Two 6v 7Ah Sealed Lead Acid Batteries are connected in series to provide 12v for the pump.
  • Router configuration including LAN Static IP's and NAT Port Forwarding along with a DDNS or a Static IP is required.
  • A TIP120 NPN Transistor is used to switch the pump on and off and the calculation is covered briefly, a 1N4001 Diode is used for flyback protection .
  • All the code is provided, most of it comes directly from the Arducam and ESP8266 Github repositories.

 https://github.com/ArduCAM

https://github.com/esp8266

 

Next steps

Night Vision with IR LED's and no cut out filter.

Adjust movement parameters through iPhone App.

Components List

Notes-

1. I used the Espino because it is ready to plug into a breadboard and comes with a micro USB (comms and power). You could just buy the ESp8266 module but you would have to mount it on a breakout board and use an FTDI for comms and power.

Espinovmodule

2. I used the ESP8266 ESP-12F UNO Board for ArduCAM Mini Camera and the Arducam Mini module Camera Shield w/ 2 MP OV2640 because I had them but if you need to buy one I would go for the Arducam 2MP V2 Mini Camera Shield with ESP8266 Nano instead, it's way smaller and you don't have to mount the Uno board or worry about the extra wires. It also fits in the pan tilt arm, just a lot neater.

UnovNano

 

The major components came from UCTronics and Thai Easy Elec

http://www.uctronics.com

uctronics

http://www.thaieasyelec.com/en/

espino

 

Other bits and pieces

  • M3 PCB Standoffs
  • Breadboard
  • Clear Acrylic Case
  • Two 6v 7Ah SLA
  • TIP120 NPN
  • Water Bottle
  • Sprinkler
  • Assortment of jumpers and wires etc
  • Diode 1N4001
  • 12v to 5v DC-DC Convertor
  • 12v Windscreen Washer Pump
  • A Vacuum Relief Valve if your nozzle sits lower than the water level.

parts

Network

If you are going to use the iPhone App or would otherwise like to take control of the Squirter you need to configure your router so that any traffic from the internet, for example the iPhone App is routed to the correct device. You will also need either a static IP, a DDNS service or be willing to update the IP entry in the iPhone App from time to time as your ISP changes your public IP address. We also need to assign static internal/private IP's to both the Arducam Uno and Espino (the LAN part) and direct traffic on the assigned ports to the assigned IP's (the NAT part).
For example in your router you set the IP address of the Arducam Uno to 192.168.1.21 and then in the .ino file you set the webserver on port 82 (ESP8266WebServer server(82)), you then setup NAT to forward any traffic on port 82 to 192.168.1.21.

1. If you are going to use the iPhone App and want to set the IP and forget about it setup either a DDNS service, I have used https://www.dynu.com and they are great for a free service. Or contact your ISP and request a static public IP, they normally charge for that. Or if you just want to set it up to work with the PIR Motion Detector and have it save images to the SD Card when activated then skip this step.

1. Get the MAC address from both the Espino and Arducam Uno. You can set the SSID and Password in the .ino's, let them connect and then through your routers advanced settings pages you can find the MACs

2. Configure your router to assign static IP's to the Espino and Arducam Uno, use the MAC addresses to create Static IP entries in the LAN setup page.

3. Configure your router to NAT port forward to your Arducam Uno and Espino so they can be accessed from the internet using your router's ISP assigned public IP address. The Arducam Uno and Espino connected to your private network will have internal IP addresses that are not visible from the internet. As above, the Arducam Uno is listening on port 82 and the Espino on port 81 (see this line in the respective ino files - ESP8266WebServer server(##). On the NAT setup page create entries so that traffic on the assigned ports is routed to the correct static IP's

Assembly

This is what my case looks like, this is just the first pass at it, that is Rev 0, the next build will be smaller and neater but anyways I mounted the boards and regulator to the lid to allow easy access and drilled a few holes for wiring. If using the Arducam Mini module the case could be half the size and there wouldn't be all the wires feeding up to the camera.
I used a soldering iron to make the hole in the water bottle for the pump grommet hence the mess of glue, next time I'll go and buy a correctly sized drill bit.
As indicated above if the outlet or nozzle is positioned higher than the water bottle you don't have to worry about water siphoning through the pump when it's not running and no need for vacuum relief valve.
 

case1

case2

case3

case5

You'll notice there are no GPIO pins be used on teh Arducam UNO, when using the SD Card slot there are no spare GPIO pins, so we use a HTTP_GET in the espino.ino to message the Arducam Uno to start taking images when motion is detected.

Here is a link to the Pan Tilt arm assembly.
https://www.teknojelly.com/wp-content/uploads/2017/09/mini-pan-tilt-kit-assembly.pdf

 

This is one way to layout the breadboard, if you spin the Espino 180 degrees you can layout the board with no crossed wires. The size of the case and the length and stiffness of the micro usb cable I had kind of dictated this layout. 

squirter_bb

Electronic Switch (Transistor or H Bridge)

The pump runs at 12v and when under load (pumping water) is draws 3A.

Looking at a TIP120 TIP120 Datasheet the max collector current (Ic) is 5 amps and the max collector-emitter voltage is 60v. So far so good.

Looking at figure 2 on the datasheet the base current (Ib) is given by Ic=250* Ib or Ib=Ic/250

So a collector current of 3.0 amps requires Ib=3.0/250 which equals 0.012A

From the ESPino-Datasheet-EN-2  the max current per I/O pin is about 12mA.

You could add a small resistor to make sure the Espino doens't try to output more than the 12mA, so looking at figure 2 we can see that when the collector current is 3A Vbe(sat) is 1.8V. So when the Espino is high at ~3.3V the resistor should have a voltage drop of 3.3-1.8 = 1.5V accross it;

R=V/I

R=1.5/11

R= 136ohms

So you could add a 100 or 150 ohm resistor if you like.

espino.ino

#include <ESP8266WiFi.h>
#include <WebSocketsServer.h>
#include <ESP8266WebServer.h>
#include <Servo.h>
#include <Wire.h>
#include <ArduCAM.h>
#include <SPI.h>
#include "memorysaver.h"
#include "FS.h"
#include <ESP8266HTTPClient.h>

//Set your WiFi details
const char* ssid="###."; // yourSSID
const char* password="####"; // yourPASSWORD

int horSlider=110;
int vertSlider=103; // Default value of the slider
Servo horServo;
Servo vertServo;

// Interval is the delay between a client connection and the motion sensor being able to trigger again.
// Stops the Arduino trying to write to the SD Card and Stream at the same time.
int interval=10000;
// Tracks the time since last client connection.
unsigned long previousMillis=0;

const int PIR_MOTION_SENSOR = 15;
const int PUMP_PIN = 13;

//Set the port number here
ESP8266WebServer server(81);

bool motion = true;
bool hadClient;

void handleHorSlider() {
horSlider = server.arg("hor").toInt();
horServo.write(horSlider);
}

void handleVerSlider() {
vertSlider = server.arg("ver").toInt();
vertServo.write(vertSlider);
}

void handleSetButton() {
digitalWrite(PUMP_PIN, HIGH);
}

void handleButtonRelease() {
digitalWrite(PUMP_PIN, LOW);
}

void motChanged() {
String mot_status = server.arg("mot");
if (mot_status == "ON")
motion = true;
else if (mot_status == "OFF")
motion = false;
}

// This moves the pan tilt arm around and turns the pump on when motion is detected.
// Change the delays and servo writes as required
void handleMotion() {
if(motion==true){
horServo.write(110);
vertServo.write(103);
digitalWrite(PUMP_PIN, HIGH);
horServo.write(150);
vertServo.write(80);
delay(250);
vertServo.write(120);
delay(250);
horServo.write(80);
delay(250);
vertServo.write(103);
delay(250);
horServo.write(110);
delay(1250);
digitalWrite(PUMP_PIN, LOW);
delay(4000);
}
else{
digitalWrite(PUMP_PIN, HIGH);
delay(2500);
digitalWrite(PUMP_PIN, LOW);
delay(4000);
}
}

void setup() {
pinMode(PIR_MOTION_SENSOR, INPUT);
horServo.attach(5);
vertServo.attach(16);
pinMode(PUMP_PIN, OUTPUT);
Serial.begin(115200);
WiFi.begin(ssid,password);
while(WiFi.status()!=WL_CONNECTED)delay(500);
WiFi.mode(WIFI_STA);
//Serial.println("nnBOOTING ESP8266 ...");
//Serial.print("Connected to ");
//Serial.println(ssid);
//Serial.print("Station IP address = ");
//Serial.println(WiFi.localIP());

// Start the server
server.on("/setHorSlider", HTTP_POST, handleHorSlider);
server.on("/setVerSlider", HTTP_POST, handleVerSlider);
server.on("/setButton", HTTP_POST, handleSetButton);
server.on("/releaseButton", HTTP_POST, handleButtonRelease);
server.on("/motButton", HTTP_POST, motChanged);

// If serving a htm from SPIFFS
//SPIFFS.begin();
//server.serveStatic("/index.html", SPIFFS, "/index.html");
//server.serveStatic("/js", SPIFFS, "/js");
//server.serveStatic("/css", SPIFFS, "/css", "max-age=86400");
//server.serveStatic("/images", SPIFFS, "/images", "max-age=86400");
//server.serveStatic("/", SPIFFS, "/index.html");
//webSocket.begin();
//webSocket.onEvent(webSocketEvent);

server.begin();

//This is not great but the PIR will go high (false triggers) during the first 30 or so seconds.
//Stops the pump from turning on and off during the first 30 seconds after power up.
delay(30000);
}

boolean isMove()
{
int sensorValue = digitalRead(PIR_MOTION_SENSOR);
if(sensorValue == HIGH){
return true;
}
else {
return false;
}
}

void msgArduino() {
unsigned long currentMillis = millis();
WiFiClient client = server.client();
if ((unsigned long)(currentMillis - previousMillis) >= interval){

if(isMove()){
handleMotion();
HTTPClient http;
//IP and Port of the Arduino
http.begin("http://192.168.1.21:82/savepic");
int httpCode = http.GET();
http.end();
}
}

if (client.connected()){
previousMillis = currentMillis;
}
}

void loop() {
//webSocket.loop();
server.handleClient();
msgArduino();
}


arducam_uno.ino

#include <ESP8266WiFi.h>
#include <WebSocketsServer.h>
#include <ESP8266WebServer.h>
#include <Servo.h>
#include <Wire.h>
#include <ArduCAM.h>
#include <SPI.h>
#include <SD.h>
#include "memorysaver.h"

//Espino 192.168.1.20 Port 81
//Arduino 192.168.1.21 Port 82

//Set your WiFi details
const char* ssid="###."; // yourSSID
const char* password="####"; // yourPASSWORD

static bool hasSD = false;
File uploadFile;

ESP8266WebServer server(82);

const int CS = 16;
//Version 2,set GPIO0 as the slave select :
const int SD_CS = 0;

static const size_t bufferSize = 2048; //4096;
static uint8_t buffer[bufferSize] = {0xFF};
uint8_t temp = 0, temp_last = 0;
int i = 0;
bool is_header = false;

#if defined (OV2640_MINI_2MP) || defined (OV2640_CAM)
ArduCAM myCAM(OV2640, CS);
#endif

void start_capture(){
myCAM.clear_fifo_flag();
myCAM.start_capture();
}

void myCAMSaveToSDFile(){
WiFiClient client = server.client();
server.send(200, "text/plain", "This is response to client");
//Probably don't need to loop over all this, !!research loop over buffer
for (uint8_t q=0; q<10; q++){
char str[8];
byte buf[256];
static int i = 0;
static int k = 0;
uint8_t temp = 0, temp_last = 0;
uint32_t length = 0;
bool is_header = false;
File outFile;
//Flush the FIFO
myCAM.flush_fifo();
//Clear the capture done flag
myCAM.clear_fifo_flag();
//Start capture
myCAM.start_capture();
Serial.println(F("Star Capture"));
while(!myCAM.get_bit(ARDUCHIP_TRIG , CAP_DONE_MASK));
Serial.println(F("Capture Done."));

length = myCAM.read_fifo_length();
Serial.print(F("The fifo length is :"));
Serial.println(length, DEC);
if (length >= MAX_FIFO_SIZE) //8M
{
Serial.println(F("Over size."));
}
if (length == 0 ) //0 kb
{
Serial.println(F("Size is 0."));
}
//Construct a file name
k = k + 1;
itoa(k, str, 10);
strcat(str, ".jpg");
//Open the new file
outFile = SD.open(str, O_WRITE | O_CREAT | O_TRUNC);
if(! outFile){
Serial.println(F("File open faild"));
return;
}
i = 0;
myCAM.CS_LOW();
myCAM.set_fifo_burst();

while ( length-- )
{
temp_last = temp;
temp = SPI.transfer(0x00);
//Read JPEG data from FIFO
if ( (temp == 0xD9) && (temp_last == 0xFF) ) //If find the end ,break while,
{
buf[i++] = temp; //save the last 0XD9
//Write the remain bytes in the buffer
myCAM.CS_HIGH();
outFile.write(buf, i);
//Close the file
outFile.close();
Serial.println(F("Image save OK."));
is_header = false;
i = 0;
}
if (is_header == true)
{
//Write image data to buffer if not full
if (i < 256)
buf[i++] = temp;
else
{
//Write 256 bytes image data to file
myCAM.CS_HIGH();
outFile.write(buf, 256);
i = 0;
buf[i++] = temp;
myCAM.CS_LOW();
myCAM.set_fifo_burst();
}
}
else if ((temp == 0xD8) & (temp_last == 0xFF))
{
is_header = true;
buf[i++] = temp_last;
buf[i++] = temp;
}
}
}
}


void returnFail(String msg) {
server.send(500, "text/plain", msg + "rn");
}


void serverStream(){
WiFiClient client = server.client();

String response = "HTTP/1.1 200 OKrn";
response += "Content-Type: multipart/x-mixed-replace; boundary=framernrn";
server.sendContent(response);

while (1){
start_capture();
while (!myCAM.get_bit(ARDUCHIP_TRIG, CAP_DONE_MASK));
size_t len = myCAM.read_fifo_length();
if (len >= MAX_FIFO_SIZE) //8M
{
Serial.println(F("Over size."));
continue;
}
if (len == 0 ) //0 kb
{
Serial.println(F("Size is 0."));
continue;
}
myCAM.CS_LOW();
myCAM.set_fifo_burst();
if (!client.connected()) break;
response = "--framern";
response += "Content-Type: image/jpegrnrn";
server.sendContent(response);
while ( len-- )
{
temp_last = temp;
temp = SPI.transfer(0x00);

//Read JPEG data from FIFO
if ( (temp == 0xD9) && (temp_last == 0xFF) ) //If find the end ,break while,
{
buffer[i++] = temp; //save the last 0XD9
//Write the remain bytes in the buffer
myCAM.CS_HIGH();;
if (!client.connected()) break;
client.write(&buffer[0], i);
is_header = false;
i = 0;
}
if (is_header == true)
{
//Write image data to buffer if not full
if (i < bufferSize)
buffer[i++] = temp;
else
{
//Write bufferSize bytes image data to file
myCAM.CS_HIGH();
if (!client.connected()) break;
client.write(&buffer[0], bufferSize);
i = 0;
buffer[i++] = temp;
myCAM.CS_LOW();
myCAM.set_fifo_burst();
}
}
else if ((temp == 0xD8) & (temp_last == 0xFF))
{
is_header = true;
buffer[i++] = temp_last;
buffer[i++] = temp;
}
}
if (!client.connected()) break;
}
}

bool loadFromSdCard(String path){
String dataType = "text/plain";
if(path.endsWith("/")) path += "index.htm";

if(path.endsWith(".src")) path = path.substring(0, path.lastIndexOf("."));
else if(path.endsWith(".htm")) dataType = "text/html";
else if(path.endsWith(".css")) dataType = "text/css";
else if(path.endsWith(".js")) dataType = "application/javascript";
else if(path.endsWith(".png")) dataType = "image/png";
else if(path.endsWith(".gif")) dataType = "image/gif";
else if(path.endsWith(".jpg")) dataType = "image/jpeg";
else if(path.endsWith(".ico")) dataType = "image/x-icon";
else if(path.endsWith(".xml")) dataType = "text/xml";
else if(path.endsWith(".pdf")) dataType = "application/pdf";
else if(path.endsWith(".zip")) dataType = "application/zip";

File dataFile = SD.open(path.c_str());
if(dataFile.isDirectory()){
path += "/index.htm";
dataType = "text/html";
dataFile = SD.open(path.c_str());
}

if (!dataFile)
return false;

if (server.hasArg("download")) dataType = "application/octet-stream";

if (server.streamFile(dataFile, dataType) != dataFile.size()) {
//DBG_OUTPUT_PORT.println("Sent less data than expected!");
}

dataFile.close();
return true;
}

void returnOK() {
server.send(200, "text/plain", "");
}


void handleFileUpload(){
if(server.uri() != "/edit") return;
HTTPUpload& upload = server.upload();
if(upload.status == UPLOAD_FILE_START){
if(SD.exists((char *)upload.filename.c_str())) SD.remove((char *)upload.filename.c_str());
uploadFile = SD.open(upload.filename.c_str(), FILE_WRITE);
//DBG_OUTPUT_PORT.print("Upload: START, filename: "); DBG_OUTPUT_PORT.println(upload.filename);
} else if(upload.status == UPLOAD_FILE_WRITE){
if(uploadFile) uploadFile.write(upload.buf, upload.currentSize);
//DBG_OUTPUT_PORT.print("Upload: WRITE, Bytes: "); DBG_OUTPUT_PORT.println(upload.currentSize);
} else if(upload.status == UPLOAD_FILE_END){
if(uploadFile) uploadFile.close();
//DBG_OUTPUT_PORT.print("Upload: END, Size: "); DBG_OUTPUT_PORT.println(upload.totalSize);
}
}

void deleteRecursive(String path){
File file = SD.open((char *)path.c_str());
if(!file.isDirectory()){
file.close();
SD.remove((char *)path.c_str());
return;
}

file.rewindDirectory();
while(true) {
File entry = file.openNextFile();
if (!entry) break;
String entryPath = path + "/" +entry.name();
if(entry.isDirectory()){
entry.close();
deleteRecursive(entryPath);
} else {
entry.close();
SD.remove((char *)entryPath.c_str());
}
yield();
}

SD.rmdir((char *)path.c_str());
file.close();
}

void handleDelete(){
if(server.args() == 0) return returnFail("BAD ARGS");
String path = server.arg(0);
if(path == "/" || !SD.exists((char *)path.c_str())) {
returnFail("BAD PATH");
return;
}
deleteRecursive(path);
returnOK();
}


void handleCreate(){
if(server.args() == 0) return returnFail("BAD ARGS");
String path = server.arg(0);
if(path == "/" || SD.exists((char *)path.c_str())) {
returnFail("BAD PATH");
return;
}

if(path.indexOf('.') > 0){
File file = SD.open((char *)path.c_str(), FILE_WRITE);
if(file){
file.write((const char *)0);
file.close();
}
} else {
SD.mkdir((char *)path.c_str());
}
returnOK();
}
void printDirectory() {
if(!server.hasArg("dir")) return returnFail("BAD ARGS");
String path = server.arg("dir");
if(path != "/" && !SD.exists((char *)path.c_str())) return returnFail("BAD PATH");
File dir = SD.open((char *)path.c_str());
path = String();
if(!dir.isDirectory()){
dir.close();
return returnFail("NOT DIR");
}
dir.rewindDirectory();
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
server.send(200, "text/json", "");
WiFiClient client = server.client();

server.sendContent("[");
for (int cnt = 0; true; ++cnt) {
File entry = dir.openNextFile();
if (!entry)
break;

String output;
if (cnt > 0)
output = ',';

output += "{"type":"";
output += (entry.isDirectory()) ? "dir" : "file";
output += "","name":"";
output += entry.name();
output += """;
output += "}";
server.sendContent(output);
entry.close();
}
server.sendContent("]");
dir.close();
}
void handleNotFound(){
if(hasSD && loadFromSdCard(server.uri())) return;
String message = "SDCARD Not Detectednn";
message += "URI: ";
message += server.uri();
message += "nMethod: ";
message += (server.method() == HTTP_GET)?"GET":"POST";
message += "nArguments: ";
message += server.args();
message += "n";
for (uint8_t i=0; i<server.args(); i++){
message += " NAME:"+server.argName(i) + "n VALUE:" + server.arg(i) + "n";
}
server.send(404, "text/plain", message);

}

void setup() {

uint8_t vid, pid;
uint8_t temp;
#if defined(__SAM3X8E__)
Wire1.begin();
#else
Wire.begin();
#endif

Serial.begin(115200);
pinMode(PIR_MOTION_SENSOR, INPUT);

WiFi.begin(ssid,password);
while(WiFi.status()!=WL_CONNECTED)delay(500);
WiFi.mode(WIFI_STA);

// set the CS as an output:
pinMode(CS, OUTPUT);

// initialize SPI:
SPI.begin();
SPI.setFrequency(4000000); //4MHz

//Check if the ArduCAM SPI bus is OK
myCAM.write_reg(ARDUCHIP_TEST1, 0x55);
temp = myCAM.read_reg(ARDUCHIP_TEST1);
if (temp != 0x55){
Serial.println(F("SPI1 interface Error!"));
while(1);
}

if(!SD.begin(SD_CS)){
Serial.println(F("SD Card Error"));
}
else {
Serial.println(F("SD Card detected!"));
hasSD = true;
}

#if defined (OV2640_MINI_2MP) || defined (OV2640_CAM)
//Check if the camera module type is OV2640
myCAM.wrSensorReg8_8(0xff, 0x01);
myCAM.rdSensorReg8_8(OV2640_CHIPID_HIGH, &vid);
myCAM.rdSensorReg8_8(OV2640_CHIPID_LOW, &pid);
#endif
if ((vid != 0x26 ) && (( pid != 0x41 ) || ( pid != 0x42 )))
Serial.println(F("Can't find OV2640 module!"));
else
Serial.println(F("OV2640 detected."));

//Change to JPEG capture mode and initialize the OV2640 module
myCAM.set_format(JPEG);
myCAM.InitCAM();
#if defined (OV2640_MINI_2MP) || defined (OV2640_CAM)
myCAM.OV2640_set_JPEG_size(OV2640_320x240);

#endif

myCAM.clear_fifo_flag();

server.on("/stream", HTTP_GET, serverStream);
server.on("/savepic", HTTP_GET, myCAMSaveToSDFile);
server.on("/list", HTTP_GET, printDirectory);
server.on("/edit", HTTP_DELETE, handleDelete);
server.on("/edit", HTTP_PUT, handleCreate);
server.on("/edit", HTTP_POST, [](){ returnOK(); }, handleFileUpload);
server.onNotFound(handleNotFound);
server.begin();

}

void loop() {

server.handleClient();

}

index.htm (Arducam Uno SD Card)

<!DOCTYPE html>
<html lang="en">
<head>
<title>SD Editor</title>
<style type="text/css" media="screen">
.contextMenu {
z-index: 300;
position: absolute;
left: 5px;
border: 1px solid #444;
background-color: #F5F5F5;
display: none;
box-shadow: 0 0 10px rgba( 0, 0, 0, .4 );
font-size: 12px;
font-family: sans-serif;
font-weight:bold;
}
.contextMenu ul {
list-style: none;
top: 0;
left: 0;
margin: 0;
padding: 0;
}
.contextMenu li {
position: relative;
min-width: 60px;
cursor: pointer;
}
.contextMenu span {
color: #444;
display: inline-block;
padding: 6px;
}
.contextMenu li:hover { background: #444; }
.contextMenu li:hover span { color: #EEE; }

.css-treeview ul, .css-treeview li {
padding: 0;
margin: 0;
list-style: none;
}
.css-treeview input {
position: absolute;
opacity: 0;
}
.css-treeview {
font: normal 11px Verdana, Arial, Sans-serif;
-moz-user-select: none;
-webkit-user-select: none;
user-select: none;
}
.css-treeview span {
color: #00f;
cursor: pointer;
}
.css-treeview span:hover {
text-decoration: underline;
}
.css-treeview input + label + ul {
margin: 0 0 0 22px;
}
.css-treeview input ~ ul {
display: none;
}
.css-treeview label, .css-treeview label::before {
cursor: pointer;
}
.css-treeview input:disabled + label {
cursor: default;
opacity: .6;
}
.css-treeview input:checked:not(:disabled) ~ ul {
display: block;
}
.css-treeview label, .css-treeview label::before {
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAACgCAYAAAAFOewUAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAApxJREFUeNrslM1u00AQgGdthyalFFOK+ClIIKQKyqUVQvTEE3DmAhLwAhU8QZoH4A2Q2gMSFace4MCtJ8SPBFwAkRuiHKpA6sRN/Lu7zG5i14kctaUqRGhGXnu9O/Pt7MzsMiklvF+9t2kWTDvyIrAsA0aKRRi1T0C/hJ4LUbt5/8rNpWVlp8RSr9J40b48fxFaTQ9+ft8EZ6MJYb0Ok+dnYGpmPgXwKIAvLx8vYXc5GdMAQJgQEkpjRTh36TS2U+DWW/D17WuYgm8pwJyY1npZsZKOxImOV1I/h4+O6vEg5GCZBpgmA6hX8wHKUHDRBXQYicQ4rlc3Tf0VMs8DHBS864F2YFspjgUYjKX/Az3gsdQd2eeBHwmdGWXHcgBGSkZXOXohcEXebRoQcAgjqediNY+AVyu3Z3sAKqfKoGMsewBeEIOPgQxxPJIjcGH6qtL/0AdADzKGnuuD+2tLK7Q8DhHHbOBW+KEzcHLuYc82MkEUekLiwuvVH+guQBQzOG4XdAb8EOcRcqQvDkY2iCLuxECJ43JobMXoutqGgDa2T7UqLKwt9KRyuxKVByqVXXqIoCCUCAqhUOioTWC7G4TQEOD0APy2/7G2Xpu1J4+lxeQ4TXBbITDpoVelRN/BVFbwu5oMMJUBhoXy5tmdRcMwymP2OLQaLjx9/vnBo6V3K6izATmSnMa0Dq7ferIohJhr1p01zrlz49rZF4OMs8JkX23vVQzYp+wbYGV/KpXKjvspl8tsIKCrMNAYFxj2GKS5ZWxg4ewKsJfaGMIY5KXqPz8LBBj6+yDvVP79+yDp/9F9oIx3OisHWwe7Oal0HxCAAAQgAAEIQAACEIAABCAAAQhAAAIQgAAEIAABCEAAAhCAAAQgwD8E/BZgAP0qhKj3rXO7AAAAAElFTkSuQmCC") no-repeat;
}
.css-treeview label, .css-treeview span, .css-treeview label::before {
display: inline-block;
height: 16px;
line-height: 16px;
vertical-align: middle;
}
.css-treeview label {
background-position: 18px 0;
}
.css-treeview label::before {
content: "";
width: 16px;
margin: 0 22px 0 0;
vertical-align: middle;
background-position: 0 -32px;
}
.css-treeview input:checked + label::before {
background-position: 0 -16px;
}
/* webkit adjacent element selector bugfix */
@media screen and (-webkit-min-device-pixel-ratio:0)
{
.css-treeview{
-webkit-animation: webkit-adjacent-element-selector-bugfix infinite 1s;
}
@-webkit-keyframes webkit-adjacent-element-selector-bugfix
{
from {
padding: 0;
}
to {
padding: 0;
}
}
}
#uploader {
position: absolute;
top: 0;
right: 0;
left: 0;
height:28px;
line-height: 24px;
padding-left: 10px;
background-color: #444;
color:#EEE;
}
#tree {
position: absolute;
top: 28px;
bottom: 0;
left: 0;
width:200px;
padding: 8px;
}
#editor, #preview {
position: absolute;
top: 28px;
right: 0;
bottom: 0;
left: 200px;
}
#preview {
background-color: #EEE;
padding:5px;
}
</style>
<script>
function createFileUploader(element, tree, editor){
var xmlHttp;
var input = document.createElement("input");
input.type = "file";
input.multiple = false;
input.name = "data";
document.getElementById(element).appendChild(input);
var path = document.createElement("input");
path.id = "upload-path";
path.type = "text";
path.name = "path";
path.defaultValue = "/";
document.getElementById(element).appendChild(path);
var button = document.createElement("button");
button.innerHTML = 'Upload';
document.getElementById(element).appendChild(button);
var mkdir = document.createElement("button");
mkdir.innerHTML = 'MkDir';
document.getElementById(element).appendChild(mkdir);
var mkfile = document.createElement("button");
mkfile.innerHTML = 'MkFile';
document.getElementById(element).appendChild(mkfile);

function httpPostProcessRequest(){
if (xmlHttp.readyState == 4){
if(xmlHttp.status != 200) alert("ERROR["+xmlHttp.status+"]: "+xmlHttp.responseText);
else {
tree.refreshPath(path.value);
}
}
}
function createPath(p){
xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = httpPostProcessRequest;
var formData = new FormData();
formData.append("path", p);
xmlHttp.open("PUT", "/edit");
xmlHttp.send(formData);
}

mkfile.onclick = function(e){
if(path.value.indexOf(".") === -1) return;
createPath(path.value);
editor.loadUrl(path.value);
};
mkdir.onclick = function(e){
if(path.value.length < 2) return;
var dir = path.value
if(dir.indexOf(".") !== -1){
if(dir.lastIndexOf("/") === 0) return;
dir = dir.substring(0, dir.lastIndexOf("/"));
}
createPath(dir);
};
button.onclick = function(e){
if(input.files.length === 0){
return;
}
xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = httpPostProcessRequest;
var formData = new FormData();
formData.append("data", input.files[0], path.value);
xmlHttp.open("POST", "/edit");
xmlHttp.send(formData);
}
input.onchange = function(e){
if(input.files.length === 0) return;
var filename = input.files[0].name;
var ext = /(?:.([^.]+))?$/.exec(filename)[1];
var name = /(.*).[^.]+$/.exec(filename)[1];
if(typeof name !== undefined){
if(name.length > 8) name = name.substring(0, 8);
filename = name;
}
if(typeof ext !== undefined){
if(ext === "html") ext = "htm";
else if(ext === "jpeg") ext = "jpg";
filename = filename + "." + ext;
}
if(path.value === "/" || path.value.lastIndexOf("/") === 0){
path.value = "/"+filename;
} else {
path.value = path.value.substring(0, path.value.lastIndexOf("/")+1)+filename;
}
}
}
function createTree(element, editor){
var preview = document.getElementById("preview");
var treeRoot = document.createElement("div");
treeRoot.className = "css-treeview";
document.getElementById(element).appendChild(treeRoot);

function loadDownload(path){
document.getElementById('download-frame').src = path+"?download=true";
}

function loadPreview(path){
document.getElementById("editor").style.display = "none";
preview.style.display = "block";
preview.innerHTML = '<img src="'+path+'" style="max-width:100%; max-height:100%; margin:auto; display:block;" />';
}

function fillFolderMenu(el, path){
var list = document.createElement("ul");
el.appendChild(list);
var action = document.createElement("li");
list.appendChild(action);
var isChecked = document.getElementById(path).checked;
var expnd = document.createElement("li");
list.appendChild(expnd);
if(isChecked){
expnd.innerHTML = "<span>Collapse</span>";
expnd.onclick = function(e){
document.getElementById(path).checked = false;
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(el);
};
var refrsh = document.createElement("li");
list.appendChild(refrsh);
refrsh.innerHTML = "<span>Refresh</span>";
refrsh.onclick = function(e){
var leaf = document.getElementById(path).parentNode;
if(leaf.childNodes.length == 3) leaf.removeChild(leaf.childNodes[2]);
httpGet(leaf, path);
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(el);
};
} else {
expnd.innerHTML = "<span>Expand</span>";
expnd.onclick = function(e){
document.getElementById(path).checked = true;
var leaf = document.getElementById(path).parentNode;
if(leaf.childNodes.length == 3) leaf.removeChild(leaf.childNodes[2]);
httpGet(leaf, path);
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(el);
};
}
var upload = document.createElement("li");
list.appendChild(upload);
upload.innerHTML = "<span>Upload</span>";
upload.onclick = function(e){
var pathEl = document.getElementById("upload-path");
if(pathEl){
var subPath = pathEl.value;
if(subPath.lastIndexOf("/") < 1) pathEl.value = path+subPath;
else pathEl.value = path.substring(subPath.lastIndexOf("/"))+subPath;
}
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(el);
};
var delFile = document.createElement("li");
list.appendChild(delFile);
delFile.innerHTML = "<span>Delete</span>";
delFile.onclick = function(e){
httpDelete(path);
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(el);
};
}

function fillFileMenu(el, path){
var list = document.createElement("ul");
el.appendChild(list);
var action = document.createElement("li");
list.appendChild(action);
if(isTextFile(path)){
action.innerHTML = "<span>Edit</span>";
action.onclick = function(e){
editor.loadUrl(path);
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(el);
};
} else if(isImageFile(path)){
action.innerHTML = "<span>Preview</span>";
action.onclick = function(e){
loadPreview(path);
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(el);
};
}
var download = document.createElement("li");
list.appendChild(download);
download.innerHTML = "<span>Download</span>";
download.onclick = function(e){
loadDownload(path);
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(el);
};
var delFile = document.createElement("li");
list.appendChild(delFile);
delFile.innerHTML = "<span>Delete</span>";
delFile.onclick = function(e){
httpDelete(path);
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(el);
};
}

function showContextMenu(e, path, isfile){
var divContext = document.createElement("div");
var scrollTop = document.body.scrollTop ? document.body.scrollTop : document.documentElement.scrollTop;
var scrollLeft = document.body.scrollLeft ? document.body.scrollLeft : document.documentElement.scrollLeft;
var left = e.clientX + scrollLeft;
var top = e.clientY + scrollTop;
divContext.className = 'contextMenu';
divContext.style.display = 'block';
divContext.style.left = left + 'px';
divContext.style.top = top + 'px';
if(isfile) fillFileMenu(divContext, path);
else fillFolderMenu(divContext, path);
document.body.appendChild(divContext);
var width = divContext.offsetWidth;
var height = divContext.offsetHeight;
divContext.onmouseout = function(e){
if(e.clientX < left || e.clientX > (left + width) || e.clientY < top || e.clientY > (top + height)){
if(document.body.getElementsByClassName('contextMenu').length > 0) document.body.removeChild(divContext);
}
};
}

function createTreeLeaf(path, name, size){
var leaf = document.createElement("li");
leaf.id = (((path == "/")?"":path)+"/"+name).toLowerCase();
var label = document.createElement("span");
label.textContent = name.toLowerCase();
leaf.appendChild(label);
leaf.onclick = function(e){
if(isTextFile(leaf.id)){
editor.loadUrl(leaf.id);
} else if(isImageFile(leaf.id)){
loadPreview(leaf.id);
}
};
leaf.oncontextmenu = function(e){
e.preventDefault();
e.stopPropagation();
showContextMenu(e, leaf.id, true);
};
return leaf;
}

function createTreeBranch(path, name, disabled){
var leaf = document.createElement("li");
var check = document.createElement("input");
check.type = "checkbox";
check.id = (((path == "/")?"":path)+"/"+name).toLowerCase();
if(typeof disabled !== "undefined" && disabled) check.disabled = "disabled";
leaf.appendChild(check);
var label = document.createElement("label");
label.for = check.id;
label.textContent = name.toLowerCase();
leaf.appendChild(label);
check.onchange = function(e){
if(check.checked){
if(leaf.childNodes.length == 3) leaf.removeChild(leaf.childNodes[2]);
httpGet(leaf, check.id);
}
};
label.onclick = function(e){
if(!check.checked){
check.checked = true;
if(leaf.childNodes.length == 3) leaf.removeChild(leaf.childNodes[2]);
httpGet(leaf, check.id);
} else {
check.checked = false;
}
};
leaf.oncontextmenu = function(e){
e.preventDefault();
e.stopPropagation();
showContextMenu(e, check.id, false);
}
return leaf;
}

function addList(parent, path, items){
var list = document.createElement("ul");
parent.appendChild(list);
var ll = items.length;
for(var i = 0; i < ll; i++){
var item = items[i];
var itemEl;
if(item.type === "file"){
itemEl = createTreeLeaf(path, item.name, item.size);
} else {
itemEl = createTreeBranch(path, item.name);
}
list.appendChild(itemEl);
}

}

function isTextFile(path){
var ext = /(?:.([^.]+))?$/.exec(path)[1];
if(typeof ext !== undefined){
switch(ext){
case "txt":
case "htm":
case "html":
case "js":
case "json":
case "c":
case "h":
case "cpp":
case "css":
case "xml":
return true;
}
}
return false;
}

function isImageFile(path){
var ext = /(?:.([^.]+))?$/.exec(path)[1];
if(typeof ext !== undefined){
switch(ext){
case "png":
case "jpg":
case "gif":
case "ico":
return true;
}
}
return false;
}

this.refreshPath = function(path){
if(path.lastIndexOf('/') < 1){
path = '/';
treeRoot.removeChild(treeRoot.childNodes[0]);
httpGet(treeRoot, "/");
} else {
path = path.substring(0, path.lastIndexOf('/'));
var leaf = document.getElementById(path).parentNode;
if(leaf.childNodes.length == 3) leaf.removeChild(leaf.childNodes[2]);
httpGet(leaf, path);
}
};

function delCb(path){
return function(){
if (xmlHttp.readyState == 4){
if(xmlHttp.status != 200){
alert("ERROR["+xmlHttp.status+"]: "+xmlHttp.responseText);
} else {
if(path.lastIndexOf('/') < 1){
path = '/';
treeRoot.removeChild(treeRoot.childNodes[0]);
httpGet(treeRoot, "/");
} else {
path = path.substring(0, path.lastIndexOf('/'));
var leaf = document.getElementById(path).parentNode;
if(leaf.childNodes.length == 3) leaf.removeChild(leaf.childNodes[2]);
httpGet(leaf, path);
}
}
}
}
}

function httpDelete(filename){
xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = delCb(filename);
var formData = new FormData();
formData.append("path", filename);
xmlHttp.open("DELETE", "/edit");
xmlHttp.send(formData);
}

function getCb(parent, path){
return function(){
if (xmlHttp.readyState == 4){
//clear loading
if(xmlHttp.status == 200) addList(parent, path, JSON.parse(xmlHttp.responseText));
}
}
}

function httpGet(parent, path){
xmlHttp = new XMLHttpRequest(parent, path);
xmlHttp.onreadystatechange = getCb(parent, path);
xmlHttp.open("GET", "/list?dir="+path, true);
xmlHttp.send(null);
//start loading
}

httpGet(treeRoot, "/");
return this;
}
function createEditor(element, file, lang, theme, type){
function getLangFromFilename(filename){
var lang = "plain";
var ext = /(?:.([^.]+))?$/.exec(filename)[1];
if(typeof ext !== undefined){
switch(ext){
case "txt": lang = "plain"; break;
case "htm": lang = "html"; break;
case "js": lang = "javascript"; break;
case "c": lang = "c_cpp"; break;
case "cpp": lang = "c_cpp"; break;
case "css":
case "scss":
case "php":
case "html":
case "json":
case "xml":
lang = ext;
}
}
return lang;
}

if(typeof file === "undefined") file = "/index.htm";

if(typeof lang === "undefined"){
lang = getLangFromFilename(file);
}

if(typeof theme === "undefined") theme = "textmate";

if(typeof type === "undefined"){
type = "text/"+lang;
if(lang === "c_cpp") type = "text/plain";
}

var xmlHttp = null;
var editor = ace.edit(element);

//post
function httpPostProcessRequest(){
if (xmlHttp.readyState == 4){
if(xmlHttp.status != 200) alert("ERROR["+xmlHttp.status+"]: "+xmlHttp.responseText);
}
}
function httpPost(filename, data, type){
xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = httpPostProcessRequest;
var formData = new FormData();
formData.append("data", new Blob([data], { type: type }), filename);
xmlHttp.open("POST", "/edit");
xmlHttp.send(formData);
}
//get
function httpGetProcessRequest(){
if (xmlHttp.readyState == 4){
document.getElementById("preview").style.display = "none";
document.getElementById("editor").style.display = "block";
if(xmlHttp.status == 200) editor.setValue(xmlHttp.responseText);
else editor.setValue("");
editor.clearSelection();
}
}
function httpGet(theUrl){
xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = httpGetProcessRequest;
xmlHttp.open("GET", theUrl, true);
xmlHttp.send(null);
}

if(lang !== "plain") editor.getSession().setMode("ace/mode/"+lang);
editor.setTheme("ace/theme/"+theme);
editor.$blockScrolling = Infinity;
editor.getSession().setUseSoftTabs(true);
editor.getSession().setTabSize(2);
editor.setHighlightActiveLine(true);
editor.setShowPrintMargin(false);
editor.commands.addCommand({
name: 'saveCommand',
bindKey: {win: 'Ctrl-S', mac: 'Command-S'},
exec: function(editor) {
httpPost(file, editor.getValue()+"", type);
},
readOnly: false
});
editor.commands.addCommand({
name: 'undoCommand',
bindKey: {win: 'Ctrl-Z', mac: 'Command-Z'},
exec: function(editor) {
editor.getSession().getUndoManager().undo(false);
},
readOnly: false
});
editor.commands.addCommand({
name: 'redoCommand',
bindKey: {win: 'Ctrl-Shift-Z', mac: 'Command-Shift-Z'},
exec: function(editor) {
editor.getSession().getUndoManager().redo(false);
},
readOnly: false
});
httpGet(file);
editor.loadUrl = function(filename){
file = filename;
lang = getLangFromFilename(file);
type = "text/"+lang;
if(lang !== "plain") editor.getSession().setMode("ace/mode/"+lang);
httpGet(file);
}
return editor;
}
function onBodyLoad(){
var vars = {};
var parts = window.location.href.replace(/[?&]+([^=&]+)=([^&]*)/gi, function(m,key,value) { vars[key] = value; });
var editor = createEditor("editor", vars.file, vars.lang, vars.theme);
var tree = createTree("tree", editor);
createFileUploader("uploader", tree, editor);
};
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.1.9/ace.js" type="text/javascript" charset="utf-8"></script>
</head>
<body onload="onBodyLoad();">
<div id="uploader"></div>
<div id="tree"></div>
<div id="editor"></div>
<div id="preview" style="display:none;"></div>
<iframe id=download-frame style='display:none;'></iframe>
</body>
</html>

I made a vacuum relief valve to prevent siphoning through the pump when it's not running or alternatively place the nozzle above the water bottle.

Here is a link to a video which demonstrates how to make a vacuum relief valve, no English but easy enough to follow.