CameraTrigger/arduino/main/main.ino

600 lines
17 KiB
C++

/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include <ArduinoJson.h>
#include <ESP8266WiFi.h> //https://github.com/esp8266/Arduino
#include <ESP8266mDNS.h>
#include <ESP8266WebServer.h>
#include <LittleFS.h>
#include "wlanmanager.h"
#define MORSEPIN 2
#define MORSEON LOW
#define MORSEOFF HIGH
char lang[3]="en";
/// \brief handle config call
void handleConfig();
/// \brief handle SSID call
void handleSSID();
/// \brief handle wifi call and set new credentials
void handleWifi();
/// should program be run
volatile bool run;
/// translation of D0...D7 to physical pins
char pinTranslation[8] = { 16, 5, 4, 0, 2, 14, 12, 13 };
// default config expects this:
// camera @d0 + side of optocoppler
// valve1 @d1 + side of optocoppler
// valve2 @d2 + side of optocoppler
// Trigger @d3 connects to ground (uses internal pull-up)
// LED @d4 + side of LED
// Flash @d5 + side of optocoppler
// IR @d6 + side of LED
/// our local ip
char localIP[17]={0};
/// morse ip info
volatile int8_t morseIP;
/// web server
ESP8266WebServer server(80);
/// wlan manager
WLANManager wlanManager;
/// start timestamp (in local time) of the main loop
uint32_t start;
/// handle OTA update response
void handleUpdate () {
server.sendHeader("Connection", "close");
if (Update.hasError()) {
server.send(500, "application/json", "{\"error\":\"failed\"}");
} else {
server.send(200, "application/json", "{\"success\":true}");
}
ESP.restart();
}
/// handle OTA update
void handleUpdateProgress() {
HTTPUpload& upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
Serial.setDebugOutput(true);
WiFiUDP::stopAll();
Serial.printf("Update: %s\n", upload.filename.c_str());
uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
if (!Update.begin(maxSketchSpace)) { //start with max available size
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_END) {
if (Update.end(true)) { //true to set the size to the current progress
Serial.printf("Update Success: %u\nRebooting...\n", upload.totalSize);
} else {
Update.printError(Serial);
}
Serial.setDebugOutput(false);
}
yield();
}
/// setup routine
void setup() {
LittleFS.begin();
Serial.begin(115200);
wlanManager.setSSID("cameratrigger","cameratrigger"); // set default
wlanManager.loadConfigFromFile("/config"); // load from config file
wlanManager.start(); // start access point or connect to access point
uint8_t ip[4];
*((uint32_t*)&ip) = wlanManager.getIP();
sprintf(localIP,"%i.%i.%i.%i",ip[0],ip[1],ip[2],ip[3]);
Serial.println(localIP);
morseIP = 0;
server.onNotFound([]() { // If the client requests any URI
if (!handleFileRead(server.uri())) // send it if it exists
server.send(404, "text/plain", "404: Not Found"); // otherwise, respond with a 404 (Not Found) error
});
server.on("/api/config.json", HTTP_GET, handleConfig);
server.on("/api/ssid.json", HTTP_GET, handleSSID);
server.on("/api/wifi", HTTP_POST, handleWifi);
server.on("/api/pinmapping", HTTP_POST, handlePinMapping);
server.on("/api/run", HTTP_POST, handleRun);
server.on("/api/upload", HTTP_POST, handleUpdate, handleUpdateProgress);
server.on("/update", HTTP_POST, handleUpdate, handleUpdateProgress);
server.begin();
run = false;
start = millis();
pinMode(LED_BUILTIN, OUTPUT);
pinMode(D8, OUTPUT);
digitalWrite(D8, false);
}
/// handle config call
void handleConfig() {
morseIP = -1;
String result = String("{\"wifimode\":\"") + (wlanManager.isAccessPoint()? "ap" : "client") + "\",\"ssid\": \"" + wlanManager.getSSID() +"\"}";
server.send(200, "application/json", result); // Send HTTP status 200 (Ok) and send some text to the browser/client
}
/// handle SSID scan call and return list of WLAN
void handleSSID() {
int n = WiFi.scanNetworks();
String out="[";
char line[1024] = {0};
for (int i = 0; i < n; ++i) {
snprintf(line,1023,"[\"%s\",%i,%i,\"%s\"]%c",WiFi.SSID(i).c_str(), WiFi.RSSI(i), WiFi.channel(i), WiFi.encryptionType(i) == ENC_TYPE_NONE ? "open": "", ((i+1<n) ? ',' : 0));
out += line;
}
out +="]";
server.send(200, "application/json", out); // Send HTTP status 200 (Ok) and send some text to the browser/client
}
/// handle wifi call and set new credentials
void handleWifi() {
char newssid[64] = {0};
char newpassword[64] = {0};
strncpy(newssid, server.arg("ssid").c_str(), 63);
strncpy(newpassword, server.arg("password").c_str(), 63);
if (server.arg("mode") == "ap") {
// set own AP credentials
wlanManager.setSSID(newssid, newpassword);
server.send(200, "application/json", "{}"); // Send HTTP status 200 (Ok) and send some text to the browser/client
wlanManager.openAP();
wlanManager.save("/config");
} else {
// try client credentials
bool success = wlanManager.connectToAp(newssid, newpassword, [](){
String out = String("{\"redirect\":\"") + toStringIp(WiFi.localIP()) + "\"}";
server.send(200, "application/json", out.c_str()); // Send HTTP status 200 (Ok) and send some text to the browser/client
wlanManager.save("/config");
delay(1000);
uint8_t ip[4];
*((uint32_t*)&ip) = wlanManager.getIP();
sprintf(localIP,"%i.%i.%i.%i",ip[0],ip[1],ip[2],ip[3]);
morseIP = 0;
});
//Serial.printf("conn to %s - %i\n",newssid, success);
if (!success) {
server.send(403, "application/json", "{\"error\":\"invalidpassword\"}"); // Send HTTP status 200 (Ok) and send some text to the browser/client
}
}
}
/// handle of gettting pin mapping configuration
void handlePinMapping() {
morseIP = -1;
File file = LittleFS.open("/htdocs/api/pinmapping.json", "w");
const String& data = server.arg("plain");
file.write(data.c_str(), data.length());
file.close();
handleFileRead("/api/pinmapping.json");
}
enum Command {
/// enable: value is pin
E = 'E',
/// disable: value is pin
D = 'D',
/// trigger a Canon IR command
C = 'C',
/// trigger a Nikon IR command
N = 'N',
/// trigger a Sony IR command
S = 'S',
/// delay
d = 'd',
};
/// actual program step
struct cmd {
/// command
Command c;
/// value for command
unsigned int value;
};
/// storage for up to 256 command
cmd cmds[256];
unsigned char cmdIndex = 0;
IRAM_ATTR void triggerSequence() {
run = true;
}
void handleRun() {
morseIP = -2;
const String& data = server.arg("cmd");
cmdIndex = 0;
const char* input = data.c_str();
bool error = false;
while (*input && !error) {
bool parseValue = true;
bool advance = true;
bool enableOutput = false;
bool enableInput = false;
switch (*input) {
case 'E': // enable: value is pin
case 'D': // disable: value is pin
case 'C':
case 'N':
case 'S':
enableOutput = true;
// fall through
case 'd': // delay: value is time
cmds[cmdIndex].c = static_cast<Command>(*input);
break;
case 'T': // trigger on rising input
enableInput = true;
break;
default:
error = true;
parseValue = false;
advance = false;
server.send(400, "application/json", "{\"error\":\"invalidcommand\"}");
}
if (parseValue) {
unsigned int value = 0;
++input;
while ((*input) && (*input) != ';') {
if ((*input) >= '0' && (*input) <= '9') {
value = value * 10 + ((*input)-'0');
}
if (*input)
++input;
}
cmds[cmdIndex].value = value;
}
if (enableOutput || enableInput) {
if (cmds[cmdIndex].value > 7) {
server.send(400, "application/json", "{\"error\":\"invalidpin\"}");
error = true;
break;
}
cmds[cmdIndex].value = pinTranslation[cmds[cmdIndex].value];
if (enableOutput) {
pinMode(cmds[cmdIndex].value, OUTPUT);
} else if (enableInput) {
pinMode(cmds[cmdIndex].value, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(cmds[cmdIndex].value), triggerSequence, FALLING);
}
}
if (advance) {
if (cmdIndex < 255)
++cmdIndex;
else {
error = true;
server.send(400, "application/json", "{\"error\":\"toomanycommands\"}");
break;
}
}
if (*input)
++input;
}
if (error) {
return;
}
server.send(200, "application/json", "{\"message\":\"ok\"}");
// wait for trigger ?
const String& trigger = server.arg("trigger");
if (trigger == "true") {
// run now
run = true;
}
}
/// send a trigger impulse for Nikon cameras (confirmed, works)
/// \param pin which pin
/// \param reps number of repeats
void sendNikonPulse(uint8_t pin, uint8_t reps) {
// 13us on, 13us off
while (reps) {
digitalWrite(pin,HIGH);
delayMicroseconds(10);
delayTicks(40);
digitalWrite(pin,LOW);
delayMicroseconds(10);
delayTicks(39);
--reps;
}
}
/// send a trigger for Nikon cameras (confirmed, works)
/// \param pin which pin
void triggerNikon(uint16_t pin) { // takes 135.6ms
noInterrupts();
// SendSequence generates a full command signal by repeatedly calling SendPulse().
// The duration of each pulse and the time gaps between pulses is exactly the same as the
// values obtained from the analysis^ by oscilloscope
for(uint8_t i=0;i<2;++i) {
//pulse for 2.0 millis
sendNikonPulse(pin, 77);
//delay for 27.8 millis
//using a combination of delay() and delayMicroseconds()
delayMicroseconds(10000);
delayMicroseconds(10000);
delayMicroseconds(7786);
//on pulse for 0.4 millis
sendNikonPulse(pin, 15);
//delay for 1.5 millis
delayMicroseconds(1499);
//on pulse for 0.4 millis
sendNikonPulse(pin, 15);
//delay for 3.5 millis
delayMicroseconds(3496);
//send pulse for 0.4 millis
sendNikonPulse(pin, 15);
if(!i) {
delayMicroseconds(10000);
delayMicroseconds(10000);
delayMicroseconds(10000);
delayMicroseconds(10000);
delayMicroseconds(10000);
delayMicroseconds(10000);
delayMicroseconds(2977);
}
}
interrupts();
}
/// send a 32kHz pulse
/// \param pin pin number to use
/// \param reps number of pulses
void send32kHzPulse(uint8_t pin, uint16_t reps) {
while (reps) {
digitalWrite(pin,HIGH);
delayMicroseconds(12);
delayTicks(64);
digitalWrite(pin,LOW);
delayMicroseconds(12);
delayTicks(64);
--reps;
}
}
/// send a trigger for Canon cameras (not confirmed)
void triggerCanon(uint16_t pin) { // takes 8.3 ms
noInterrupts();
//16 pulses for at 32kHz
send32kHzPulse(pin, 16);
delayMicroseconds(5310);
//on pulse for 0.5 millis
send32kHzPulse(pin, 16);
interrupts();
}
/// get current tick count
/// \param tick count
uint32_t inline asm_ccount() {
uint32_t r;
asm volatile ("rsr %0, ccount": "=r"(r));
return r;
}
/// delay for given number of ticks
/// \param ticks number of ticks
void delayTicks(int32_t ticks) {
uint32_t expire_ticks = asm_ccount() + ticks;
do {
ticks = expire_ticks - asm_ccount();
} while(ticks>0);
}
/// send a pulse for Sony cameras
/// \param pin pin number to use
/// \param count number of pulses
void sendSonyPulse(uint16_t pin, uint16_t count) {
// warm up cache
digitalWrite(pin,LOW);
delayTicks(40);
while (count) { // 1 pulse = 7uS high, 18us Low = 25uS at 40kHz
digitalWrite(pin,HIGH);
delayMicroseconds(4);
delayTicks(39);
digitalWrite(pin,LOW);
delayMicroseconds(15);
delayTicks(50);
--count;
}
}
/// send a trigger for Sony cameras (not confirmed)
/// \param pin pin number to use
void triggerSony(uint16_t pin) { // takes ? 6ms
noInterrupts();
bool cmd[] = {1,0,1,1,0,1,0,0,1,0,1,1,1,0,0,0,1,1,1,1};
//2,4ms start pulse, 600uS gap, 20 bit data
sendSonyPulse(pin, 96); // 96 cycles = 2,4ms
delayMicroseconds(600);
for (uint8_t i = 0; i < 20; ++i) {
sendSonyPulse(pin, cmd[i] ? 48 : 24);
delayMicroseconds(600);
}
interrupts();
}
/// rund defined program
void runProgram() {
for (unsigned int i = 0 ; i < cmdIndex; ++i) {
switch(cmds[i].c) {
case E:
digitalWrite(cmds[i].value, true);
break;
case D:
digitalWrite(cmds[i].value, false);
break;
case C:
triggerCanon(cmds[i].value);
break;
case N:
triggerNikon(cmds[i].value);
break;
case S:
triggerSony(cmds[i].value);
break;
case d:
uint32_t d = cmds[i].value;
while (d>16000) {
d-= 16000;
delay(16000);
}
delay(d);
break;
}
}
}
/// guess content type based on file name
/// \param filename filename to check
/// \return content type
String getContentType(String filename) { // convert the file extension to the MIME type
if (filename.endsWith(".html")) return "text/html";
else if (filename.endsWith(".css")) return "text/css";
else if (filename.endsWith(".js")) return "application/javascript";
else if (filename.endsWith(".ico")) return "image/x-icon";
else if (filename.endsWith(".png")) return "image/png";
else if (filename.endsWith(".svg")) return "image/svg+xml";
else if (filename.endsWith(".json")) return "application/json";
return "text/plain";
}
/// send a file to the client
/// \param path file/path to send
/// \return true on success
bool handleFileRead(String path) { // send the right file to the client (if it exists)
morseIP = -2;
if (path.endsWith("/")) path += "index.html"; // If a folder is requested, send the index file
String contentType = getContentType(path); // Get the MIME type
path = "/htdocs" + path;
if (LittleFS.exists(path)) { // If the file exists
File file = LittleFS.open(path, "r"); // Open it
server.sendHeader("X-Version", "1.2");
server.streamFile(file, contentType); // And send it to the client
file.close(); // Then close the file again
return true;
}
if (captivePortal()) {
return true;
}
return false; // If the file doesn't exist, return false
}
/// crude check if string is an IP
/// \return true if an IP
bool isIp(String str) {
for (size_t i = 0; i < str.length(); i++) {
int c = str.charAt(i);
if (c != '.' && (c < '0' || c > '9')) {
return false;
}
}
return true;
}
/// IP to String
/// \param ip IP to convert
/// return formatted string
String toStringIp(IPAddress ip) {
String res = "";
for (int i = 0; i < 3; i++) {
res += String((ip >> (8 * i)) & 0xFF) + ".";
}
res += String(((ip >> 8 * 3)) & 0xFF);
return res;
}
/// Redirect to captive portal if we got a request for another domain.
/// \return true in that case so the page handler do not try to handle the request again.
bool captivePortal() {
morseIP = -2;
if (!isIp(server.hostHeader()) ) {
//Serial.println("Request redirected to captive portal");
server.sendHeader("Location", String("http://") + toStringIp(server.client().localIP()) + "/", true);
server.send ( 302, "text/plain", ""); // Empty content inhibits Content-length header so we have to close the socket ourselves.
server.client().stop(); // Stop is needed because we sent no content length
return true;
}
return false;
}
///
// char msg[4];
// int msgLen = 0;
// uint32_t nextmsg;
// bool out = false;
/// "morse" a digit (blink number of times)
/// \param count number of times to blink
void morseDigit(uint8_t count) {
while (count) {
server.handleClient();
wlanManager.loop();
digitalWrite(MORSEPIN, MORSEON);
delay(500);
digitalWrite(MORSEPIN, MORSEOFF);
delay(250);
--count;
}
delay(2000);
}
/// is ready LED on
bool readyLEDActive = false;
/// main loop
void loop() {
if (morseIP > -1) {
if (morseIP == 0) {
readyLEDActive = false;
// send long start indicator
digitalWrite(MORSEPIN, MORSEON);
delay(1000);
digitalWrite(MORSEPIN, MORSEOFF);
delay(1000);
}
// morse localIP[morseIP]
char digit = localIP[morseIP];
if (digit == '.') {
digitalWrite(MORSEPIN, MORSEON);
delay(10);
digitalWrite(MORSEPIN, MORSEOFF);
// wait 1 sec
delay(1000);
} else if (digit == 0) {
morseIP = -1; // restart
// send long end indicator
for (uint8_t i = 0; i < 50; ++i) {
digitalWrite(MORSEPIN, MORSEON);
delay(30);
digitalWrite(MORSEPIN, MORSEOFF);
delay(10);
}
// wait 2 sec
delay(2000);
} else {
morseDigit(digit - '0');
}
++morseIP;
} else if (!readyLEDActive) {
readyLEDActive = true;
digitalWrite(MORSEPIN, MORSEON);
}
server.handleClient();
wlanManager.loop();
if (run) {
digitalWrite(MORSEPIN, MORSEOFF);
runProgram();
digitalWrite(MORSEPIN, MORSEON);
run = false;
delay(1000);
}
}