600 lines
17 KiB
C++
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);
|
|
}
|
|
}
|