initial import

master
Sascha Nitsch 2023-01-29 00:53:50 +01:00
parent dcbcae5c0c
commit 3af643d418
52 changed files with 7733 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
.tscache
.project
arduino/main/main.ino.nodemcu.bin
htdocs
node_modules
package-lock.json
tmp
electronics/electronics-cache.lib
electronics/electronics.kicad_prl
electronics/fp-info-cache
electronics/sym-lib-table

117
Gruntfile.js Normal file
View File

@ -0,0 +1,117 @@
module.exports = function(grunt) {
// Project configuration.
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
copy: {
release: {
files: [
{ src: "node_modules/jquery/dist/jquery.min.js", dest: "htdocs/jquery.min.js" },
{ src: "node_modules/jquery-toast-plugin/dist/jquery.toast.min.js", dest: "htdocs/jquery.toast.min.js" },
{ src: "node_modules/jquery-toast-plugin/dist/jquery.toast.min.css", dest: "htdocs/jquery.toast.min.css" },
{ src: "node_modules/mustache/mustache.min.js", dest: "htdocs/mustache.min.js" },
{ cwd: "js/templates/", src:"*.mst", dest: "htdocs/", expand: true},
{ src: "node_modules/mustache/mustache.min.js", dest: "htdocs/mustache.min.js" },
]
},
static: {
files: [
{ cwd: "static/", src: "**/*", dest: "htdocs/", expand:true},
]
}
},
ts: {
default: {
src: ['ts/**/*.ts'],
outDir: 'tmp/',
options: {
module: 'none',
moduleResolution: 'node',
sourceMap: true,
target: 'es5',
rootDir: ['ts']
}
}
},
uglify: {
options: {
beautify: true,
mangle:false,
sourceMap: false
},
bootstrap: {
files: {
'htdocs/bootstrap.min.js': ['tmp/view.js','tmp/*.js'],
}
},
},
watch: {
jsbootstrapts: {
files: ['ts/*.ts'],
tasks: ['ts', 'newer:uglify:bootstrap']
},
lessdefault: {
files: ['less/*.less'],
tasks: ['less']
},
concat: {
files: ['templates/base/*'],
tasks: ['concat']
},
jstemplate: {
files: ['templates/*.mst'],
tasks: ['copy']
},
json_merge: {
files: ["lang/*/*.json"],
tasks: ['json_merge']
},
static: {
files: ['static/**/*'],
tasks: ['copy:static']
},
},
less: {
default: {
options: {
"strictImports": true,
"compress": true
},
files: {
"htdocs/default.css": "less/default.less"
}
},
},
concat: {
options: {
sourceMap: false
},
base: { src: ['templates/base/*'], dest: 'htdocs/base.mst'},
},
json_merge: {
options: {
replacer: null,
space: " "
},
en: {
files: [
{ 'htdocs/en_main.json': ['lang/en/generic.json','lang/en/menu.json', 'lang/en/wifi.json', 'lang/en/error.json', 'lang/en/mapping.json', 'lang/en/main.json', 'lang/en/update.json'] },
]
}
},
});
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-copy');
grunt.loadNpmTasks('grunt-contrib-watch');
grunt.loadNpmTasks('grunt-contrib-less');
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-newer');
grunt.loadNpmTasks('grunt-json-merge');
grunt.loadNpmTasks("grunt-ts");
// Default task(s).
grunt.registerTask('default', ['less', 'concat', 'ts', 'newer:uglify', 'json_merge', 'copy', 'watch']);
grunt.registerTask('release', ['less', 'concat', 'ts', 'uglify', 'json_merge', 'copy']);
};

2
arduino/main/CPPLINT.cfg Normal file
View File

@ -0,0 +1,2 @@
filter=-build/include_subdir,-whitespace/line_length
root=./

1
arduino/main/data/config Normal file
View File

@ -0,0 +1 @@
abc123;42758142061871992313;client

599
arduino/main/main.ino Normal file
View File

@ -0,0 +1,599 @@
/*
* 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);
}
}

View File

@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#include <LittleFS.h>
#include <ESP8266WiFi.h>
#include "wlanmanager.h"
#define _connectTimeout 5000
WLANManager::WLANManager() {
bzero(m_ssid, sizeof(m_ssid));
bzero(m_password, sizeof(m_password));
m_dnsServer = NULL;
m_isAccessPoint = true;
}
void WLANManager::setSSID(const char* ssid, const char* password) {
strncpy(m_ssid, ssid, sizeof(m_ssid) - 1);
strncpy(m_password, password, sizeof(m_password) - 1);
}
void WLANManager::loadConfigFromFile(const char* filename) {
File file = LittleFS.open(filename, "r"); // Open it
unsigned int size = file.size();
if (size == 0) {
return;
}
char mode[20]={0};
char* config = reinterpret_cast<char*>(malloc(size + 1));
config[size] = 0;
file.read((unsigned char*)config, size);
file.close();
while (config[size - 1] == '\n') {
config[size - 1] = 0;
--size;
}
// format: "ssid;password;ap/client"
char* next = nextSemi(config);
strncpy(m_ssid, config, sizeof(m_ssid)-1);
if (!next) return;
char* start = next;
next = nextSemi(start);
strncpy(m_password, start, sizeof(m_password)-1);
start = next;
next = nextSemi(start);
strncpy(mode, start, sizeof(mode)-1);
m_isAccessPoint = strcmp(mode, "ap") == 0;
free(config);
}
void WLANManager::start() {
// try to connect to stored AP
if (m_isAccessPoint || !connectToAp(NULL, NULL, NULL)) {
// fire up own AP
openAP();
m_dnsServer = new DNSServer();
m_dnsServer->setErrorReplyCode(DNSReplyCode::NoError);
m_dnsServer->start(53, "*", WiFi.softAPIP());
}
}
bool WLANManager::connectToAp(const char* ssid, const char* pass, std::function<void()> callback) {
m_isAccessPoint = false;
if (ssid == NULL) {
WiFi.mode(WIFI_STA);
}
if (WiFi.status() == WL_CONNECTED) {
return true;
}
if (ssid) {
WiFi.begin(ssid, pass);
} else {
if (WiFi.SSID().length()) {
ETS_UART_INTR_DISABLE();
wifi_station_disconnect();
ETS_UART_INTR_ENABLE();
WiFi.begin();
} else {
WiFi.begin(m_ssid, m_password);
}
}
int connRes = waitForConnectResult();
if (connRes != WL_CONNECTED) {
WiFi.beginWPSConfig();
// should be connected at the end of WPS
connRes = waitForConnectResult();
}
if (connRes == WL_CONNECTED) {
if (callback) {
callback();
}
}
WiFi.mode(WIFI_STA);
if (m_dnsServer) {
delete m_dnsServer;
m_dnsServer = NULL;
}
return connRes == WL_CONNECTED;
}
uint8_t WLANManager::waitForConnectResult() {
uint32_t start = millis();
boolean keepConnecting = true;
uint8_t status;
while (keepConnecting) {
status = WiFi.status();
if (millis() > start + _connectTimeout) {
keepConnecting = false;
}
if (status == WL_CONNECTED || status == WL_CONNECT_FAILED) {
keepConnecting = false;
}
delay(100);
}
return status;
}
void WLANManager::openAP() {
m_isAccessPoint = true;
// do we have stored settings?
if (!WiFi.isConnected()) {
WiFi.persistent(false);
// disconnect sta, start ap
WiFi.disconnect(); // this alone is not enough to stop the autoconnecter
WiFi.mode(WIFI_AP);
WiFi.persistent(true);
} else {
// setup AP
WiFi.mode(WIFI_AP_STA);
}
Serial.println(m_ssid);
Serial.println(m_password);
WiFi.softAP(m_ssid, m_password);
}
char* WLANManager::nextSemi(char* start) {
char* semi = strchr(start, ';');
if (!semi) return NULL;
*semi = 0;
return semi + 1;
}
bool WLANManager::isAccessPoint() const {
return m_isAccessPoint;
}
void WLANManager::save(const char* filename) {
String line = String(m_ssid) + ";" + m_password + ";" + (m_isAccessPoint ? "ap" : "client");
File file = LittleFS.open(filename, "w");
file.write(line.c_str(), line.length());
file.close();
}
const char* WLANManager::getSSID() const {
return m_ssid;
}
void WLANManager::loop() {
if (m_dnsServer) {
m_dnsServer->processNextRequest();
}
}
uint32_t WLANManager::getIP() const {
if (m_isAccessPoint)
return WiFi.softAPIP();
return WiFi.localIP();
}

View File

@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
#ifndef WLANMANAGER_H_
#define WLANMANAGER_H_
#include <DNSServer.h>
/**
* \class WLANManager
* \brief manager for and connecting to WLAN
*/
class WLANManager {
public:
/// \brief constructor
WLANManager();
/// \brief set SSID and password
/// \param ssid SSID to set
/// \param password password to set
void setSSID(const char* ssid, const char* password);
/// \brief load config from given file
/// \param filename file name of config file
void loadConfigFromFile(const char* filename);
/// \brief start WLAN
void start();
/// \brief set mode to access point (true) or client (false)
/// \param isAccessPoint true if we are an access point
void setAccessPoint(bool isAccessPoint);
/// \brief are we an access point?
/// \return true if we are
bool isAccessPoint() const;
/// \brief save configuration
/// \param filename filename of config file
void save(const char* filename);
/// \brief connect to an access point
/// \param ssid ssid of AP
/// \param pass password of AP
/// \param callbal function to be called if connected
/// \retval true on success
bool connectToAp(const char* ssid, const char* pass, std::function<void()> callback);
/// \brief get SSID
/// \return current SSID
const char* getSSID() const;
/// \brief open access point
void openAP();
/// \brief internal loop, need to be called from main loop
void loop();
/// \brief get current IP
/// \return current IP
uint32_t getIP() const;
private:
/// \brief parse input and search for next semicolon
/// \param start start offset. Input string will be modified
/// \return pointer to char after semicolor or NULL if not found
char* nextSemi(char* start);
/// \brief wait for connection to access point
/// \return WLAN status
uint8_t waitForConnectResult();
/// pointer to our dns server
DNSServer* m_dnsServer;
/// are we an access point
bool m_isAccessPoint;
/// our ssid
char m_ssid[64];
// our password
char m_password[64];
};
#endif // WLANMANAGER_H_

BIN
electronics/board.pdf Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,435 @@
{
"board": {
"design_settings": {
"defaults": {
"board_outline_line_width": 0.049999999999999996,
"copper_line_width": 0.19999999999999998,
"copper_text_italic": false,
"copper_text_size_h": 1.5,
"copper_text_size_v": 1.5,
"copper_text_thickness": 0.3,
"copper_text_upright": false,
"courtyard_line_width": 0.049999999999999996,
"dimension_precision": 4,
"dimension_units": 3,
"dimensions": {
"arrow_length": 1270000,
"extension_offset": 500000,
"keep_text_aligned": true,
"suppress_zeroes": false,
"text_position": 0,
"units_format": 1
},
"fab_line_width": 0.09999999999999999,
"fab_text_italic": false,
"fab_text_size_h": 1.0,
"fab_text_size_v": 1.0,
"fab_text_thickness": 0.15,
"fab_text_upright": false,
"other_line_width": 0.09999999999999999,
"other_text_italic": false,
"other_text_size_h": 1.0,
"other_text_size_v": 1.0,
"other_text_thickness": 0.15,
"other_text_upright": false,
"pads": {
"drill": 0.762,
"height": 1.524,
"width": 1.524
},
"silk_line_width": 0.12,
"silk_text_italic": false,
"silk_text_size_h": 1.0,
"silk_text_size_v": 1.0,
"silk_text_thickness": 0.15,
"silk_text_upright": false,
"zones": {
"45_degree_only": false,
"min_clearance": 0.508
}
},
"diff_pair_dimensions": [
{
"gap": 0.0,
"via_gap": 0.0,
"width": 0.0
}
],
"drc_exclusions": [],
"meta": {
"filename": "board_design_settings.json",
"version": 2
},
"rule_severities": {
"annular_width": "error",
"clearance": "error",
"copper_edge_clearance": "error",
"courtyards_overlap": "error",
"diff_pair_gap_out_of_range": "error",
"diff_pair_uncoupled_length_too_long": "error",
"drill_out_of_range": "error",
"duplicate_footprints": "warning",
"extra_footprint": "warning",
"footprint_type_mismatch": "error",
"hole_clearance": "error",
"hole_near_hole": "error",
"invalid_outline": "error",
"item_on_disabled_layer": "error",
"items_not_allowed": "error",
"length_out_of_range": "error",
"malformed_courtyard": "error",
"microvia_drill_out_of_range": "error",
"missing_courtyard": "ignore",
"missing_footprint": "warning",
"net_conflict": "warning",
"npth_inside_courtyard": "ignore",
"padstack": "error",
"pth_inside_courtyard": "ignore",
"shorting_items": "error",
"silk_over_copper": "warning",
"silk_overlap": "warning",
"skew_out_of_range": "error",
"through_hole_pad_without_hole": "error",
"too_many_vias": "error",
"track_dangling": "warning",
"track_width": "error",
"tracks_crossing": "error",
"unconnected_items": "error",
"unresolved_variable": "error",
"via_dangling": "warning",
"zone_has_empty_net": "error",
"zones_intersect": "error"
},
"rule_severitieslegacy_courtyards_overlap": true,
"rule_severitieslegacy_no_courtyard_defined": false,
"rules": {
"allow_blind_buried_vias": false,
"allow_microvias": false,
"max_error": 0.005,
"min_clearance": 0.0,
"min_copper_edge_clearance": 0.0,
"min_hole_clearance": 0.25,
"min_hole_to_hole": 0.25,
"min_microvia_diameter": 0.19999999999999998,
"min_microvia_drill": 0.09999999999999999,
"min_silk_clearance": 0.0,
"min_through_hole_diameter": 0.3,
"min_track_width": 0.19999999999999998,
"min_via_annular_width": 0.049999999999999996,
"min_via_diameter": 0.39999999999999997,
"use_height_for_length_calcs": true
},
"track_widths": [
0.0,
1.0
],
"via_dimensions": [
{
"diameter": 0.0,
"drill": 0.0
}
],
"zones_allow_external_fillets": false,
"zones_use_no_outline": true
},
"layer_presets": []
},
"boards": [],
"cvpcb": {
"equivalence_files": []
},
"erc": {
"erc_exclusions": [],
"meta": {
"version": 0
},
"pin_map": [
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
1,
0,
1,
2
],
[
0,
1,
0,
0,
0,
0,
1,
1,
2,
1,
1,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
2
],
[
1,
1,
1,
1,
1,
0,
1,
1,
1,
1,
1,
2
],
[
0,
0,
0,
1,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
1,
2,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
0,
2,
1,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2
]
],
"rule_severities": {
"bus_definition_conflict": "error",
"bus_entry_needed": "error",
"bus_label_syntax": "error",
"bus_to_bus_conflict": "error",
"bus_to_net_conflict": "error",
"different_unit_footprint": "error",
"different_unit_net": "error",
"duplicate_reference": "error",
"duplicate_sheet_names": "error",
"extra_units": "error",
"global_label_dangling": "warning",
"hier_label_mismatch": "error",
"label_dangling": "error",
"lib_symbol_issues": "warning",
"multiple_net_names": "warning",
"net_not_bus_member": "warning",
"no_connect_connected": "warning",
"no_connect_dangling": "warning",
"pin_not_connected": "error",
"pin_not_driven": "error",
"pin_to_pin": "warning",
"power_pin_not_driven": "error",
"similar_labels": "warning",
"unannotated": "error",
"unit_value_mismatch": "error",
"unresolved_variable": "error",
"wire_dangling": "error"
}
},
"libraries": {
"pinned_footprint_libs": [],
"pinned_symbol_libs": []
},
"meta": {
"filename": "electronics.kicad_pro",
"version": 1
},
"net_settings": {
"classes": [
{
"bus_width": 12.0,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_via_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"name": "Default",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 1.0,
"via_diameter": 0.8,
"via_drill": 0.4,
"wire_width": 6.0
}
],
"meta": {
"version": 2
},
"net_colors": null
},
"pcbnew": {
"last_paths": {
"gencad": "",
"idf": "",
"netlist": "",
"specctra_dsn": "",
"step": "",
"vrml": ""
},
"page_layout_descr_file": ""
},
"schematic": {
"annotate_start_num": 0,
"drawing": {
"default_line_thickness": 6.0,
"default_text_size": 50.0,
"field_names": [],
"intersheets_ref_own_page": false,
"intersheets_ref_prefix": "",
"intersheets_ref_short": false,
"intersheets_ref_show": false,
"intersheets_ref_suffix": "",
"junction_size_choice": 3,
"label_size_ratio": 0.25,
"pin_symbol_size": 0.0,
"text_offset_ratio": 0.08
},
"legacy_lib_dir": "",
"legacy_lib_list": [],
"meta": {
"version": 1
},
"net_format_name": "",
"ngspice": {
"fix_include_paths": true,
"fix_passive_vals": false,
"meta": {
"version": 0
},
"model_mode": 0,
"workbook_filename": ""
},
"page_layout_descr_file": "${KICAD6_TEMPLATE_DIR}/pagelayout_default.kicad_wks",
"plot_directory": "",
"spice_adjust_passive_values": false,
"spice_external_command": "spice \"%I\"",
"subpart_first_id": 65,
"subpart_id_separator": 0
},
"sheets": [
[
"102cb741-e9ae-4781-bb1b-b26fba217145",
""
]
],
"text_variables": {}
}

File diff suppressed because it is too large Load Diff

249
electronics/electronics.pro Normal file
View File

@ -0,0 +1,249 @@
update=Mo 13 Jul 2020 19:09:55 CEST
version=1
last_client=kicad
[general]
version=1
RootSch=
BoardNm=
[cvpcb]
version=1
NetIExt=net
[eeschema]
version=1
LibDir=
[eeschema/libraries]
[schematic_editor]
version=1
PageLayoutDescrFile=empty.kicad_wks
PlotDirectoryName=
SubpartIdSeparator=0
SubpartFirstId=65
NetFmtName=
SpiceAjustPassiveValues=0
LabSize=50
ERC_TestSimilarLabels=1
[pcbnew]
version=1
PageLayoutDescrFile=
LastNetListRead=
CopperLayerCount=2
BoardThickness=1.6
AllowMicroVias=0
AllowBlindVias=0
RequireCourtyardDefinitions=0
ProhibitOverlappingCourtyards=1
MinTrackWidth=0.2
MinViaDiameter=0.4
MinViaDrill=0.3
MinMicroViaDiameter=0.2
MinMicroViaDrill=0.09999999999999999
MinHoleToHole=0.25
TrackWidth1=0.25
TrackWidth2=1
ViaDiameter1=0.8
ViaDrill1=0.4
dPairWidth1=0.2
dPairGap1=0.25
dPairViaGap1=0.25
SilkLineWidth=0.12
SilkTextSizeV=1
SilkTextSizeH=1
SilkTextSizeThickness=0.15
SilkTextItalic=0
SilkTextUpright=1
CopperLineWidth=0.2
CopperTextSizeV=1.5
CopperTextSizeH=1.5
CopperTextThickness=0.3
CopperTextItalic=0
CopperTextUpright=1
EdgeCutLineWidth=0.05
CourtyardLineWidth=0.05
OthersLineWidth=0.15
OthersTextSizeV=1
OthersTextSizeH=1
OthersTextSizeThickness=0.15
OthersTextItalic=0
OthersTextUpright=1
SolderMaskClearance=0.051
SolderMaskMinWidth=0.25
SolderPasteClearance=0
SolderPasteRatio=-0
[pcbnew/Layer.F.Cu]
Name=F.Cu
Type=0
Enabled=1
[pcbnew/Layer.In1.Cu]
Name=In1.Cu
Type=0
Enabled=0
[pcbnew/Layer.In2.Cu]
Name=In2.Cu
Type=0
Enabled=0
[pcbnew/Layer.In3.Cu]
Name=In3.Cu
Type=0
Enabled=0
[pcbnew/Layer.In4.Cu]
Name=In4.Cu
Type=0
Enabled=0
[pcbnew/Layer.In5.Cu]
Name=In5.Cu
Type=0
Enabled=0
[pcbnew/Layer.In6.Cu]
Name=In6.Cu
Type=0
Enabled=0
[pcbnew/Layer.In7.Cu]
Name=In7.Cu
Type=0
Enabled=0
[pcbnew/Layer.In8.Cu]
Name=In8.Cu
Type=0
Enabled=0
[pcbnew/Layer.In9.Cu]
Name=In9.Cu
Type=0
Enabled=0
[pcbnew/Layer.In10.Cu]
Name=In10.Cu
Type=0
Enabled=0
[pcbnew/Layer.In11.Cu]
Name=In11.Cu
Type=0
Enabled=0
[pcbnew/Layer.In12.Cu]
Name=In12.Cu
Type=0
Enabled=0
[pcbnew/Layer.In13.Cu]
Name=In13.Cu
Type=0
Enabled=0
[pcbnew/Layer.In14.Cu]
Name=In14.Cu
Type=0
Enabled=0
[pcbnew/Layer.In15.Cu]
Name=In15.Cu
Type=0
Enabled=0
[pcbnew/Layer.In16.Cu]
Name=In16.Cu
Type=0
Enabled=0
[pcbnew/Layer.In17.Cu]
Name=In17.Cu
Type=0
Enabled=0
[pcbnew/Layer.In18.Cu]
Name=In18.Cu
Type=0
Enabled=0
[pcbnew/Layer.In19.Cu]
Name=In19.Cu
Type=0
Enabled=0
[pcbnew/Layer.In20.Cu]
Name=In20.Cu
Type=0
Enabled=0
[pcbnew/Layer.In21.Cu]
Name=In21.Cu
Type=0
Enabled=0
[pcbnew/Layer.In22.Cu]
Name=In22.Cu
Type=0
Enabled=0
[pcbnew/Layer.In23.Cu]
Name=In23.Cu
Type=0
Enabled=0
[pcbnew/Layer.In24.Cu]
Name=In24.Cu
Type=0
Enabled=0
[pcbnew/Layer.In25.Cu]
Name=In25.Cu
Type=0
Enabled=0
[pcbnew/Layer.In26.Cu]
Name=In26.Cu
Type=0
Enabled=0
[pcbnew/Layer.In27.Cu]
Name=In27.Cu
Type=0
Enabled=0
[pcbnew/Layer.In28.Cu]
Name=In28.Cu
Type=0
Enabled=0
[pcbnew/Layer.In29.Cu]
Name=In29.Cu
Type=0
Enabled=0
[pcbnew/Layer.In30.Cu]
Name=In30.Cu
Type=0
Enabled=0
[pcbnew/Layer.B.Cu]
Name=B.Cu
Type=0
Enabled=1
[pcbnew/Layer.B.Adhes]
Enabled=1
[pcbnew/Layer.F.Adhes]
Enabled=1
[pcbnew/Layer.B.Paste]
Enabled=1
[pcbnew/Layer.F.Paste]
Enabled=1
[pcbnew/Layer.B.SilkS]
Enabled=1
[pcbnew/Layer.F.SilkS]
Enabled=1
[pcbnew/Layer.B.Mask]
Enabled=1
[pcbnew/Layer.F.Mask]
Enabled=1
[pcbnew/Layer.Dwgs.User]
Enabled=1
[pcbnew/Layer.Cmts.User]
Enabled=1
[pcbnew/Layer.Eco1.User]
Enabled=1
[pcbnew/Layer.Eco2.User]
Enabled=1
[pcbnew/Layer.Edge.Cuts]
Enabled=1
[pcbnew/Layer.Margin]
Enabled=1
[pcbnew/Layer.B.CrtYd]
Enabled=1
[pcbnew/Layer.F.CrtYd]
Enabled=1
[pcbnew/Layer.B.Fab]
Enabled=1
[pcbnew/Layer.F.Fab]
Enabled=1
[pcbnew/Layer.Rescue]
Enabled=0
[pcbnew/Netclasses]
[pcbnew/Netclasses/Default]
Name=Default
Clearance=0.2
TrackWidth=0.25
ViaDiameter=0.8
ViaDrill=0.4
uViaDiameter=0.3
uViaDrill=0.1
dPairWidth=0.2
dPairGap=0.25
dPairViaGap=0.25

BIN
electronics/schematic.pdf Normal file

Binary file not shown.

5
lang/en/error.json Normal file
View File

@ -0,0 +1,5 @@
{
"error": {
"invalidpassword": "SSID or password wrong"
}
}

6
lang/en/generic.json Normal file
View File

@ -0,0 +1,6 @@
{
"generic": {
"save": "Save",
"savesuccess": "Saved successfully"
}
}

9
lang/en/main.json Normal file
View File

@ -0,0 +1,9 @@
{
"main": {
"edittiming": "Edit Timing",
"save": "Just save",
"start": "Start time",
"trigger": "Save and Go!",
"needpause": "IR Triggering needs $1 ms pause until next action"
}
}

14
lang/en/mapping.json Normal file
View File

@ -0,0 +1,14 @@
{
"mapping": {
"edittitle": "Edit mapping",
"pin": "Pin",
"type": "Type",
"inverted": "Inverted",
"digitalout": "digital output",
"ircanon": "Canon IR",
"irnikon": "Nikon IR",
"irsony": "Sony IR",
"notconn": "Not connected",
"trigger": "trigger signal"
}
}

6
lang/en/menu.json Normal file
View File

@ -0,0 +1,6 @@
{
"menu": {
"setupwifi": "Setup WiFi",
"setuppinmapping": "Setup Pin Mapping"
}
}

7
lang/en/update.json Normal file
View File

@ -0,0 +1,7 @@
{
"update": {
"header": "Update Camera Trigger",
"fileselect": "select the new file:",
"submit": "do update"
}
}

15
lang/en/wifi.json Normal file
View File

@ -0,0 +1,15 @@
{
"wifi": {
"accessmode": "Access mode",
"accesspoint": "Access point",
"client": "Client device",
"accessdetails": "Access details",
"rescan": "Rescan networks",
"channel": "Channel",
"ssid": "SSID",
"password": "Password",
"encryptionType": "Encryption",
"encryption": "yes",
"encryptionopen": "no"
}
}

30
less/colors.less Normal file
View File

@ -0,0 +1,30 @@
@labelColor: #222222;
@labelBorder: transparent;
@fontColor: #dedede;
@backgroundColor: #303030;
@headerBackground: #606060;
@headerBottom: rgb(19,19,19);
@boxBorderColor: rgb(80,80,80);
@boxShadowTLColor: rgb(100,100,100);
@boxShadowBRColor: rgb(0,0,0);
@boxBackgroundColor: #404040;
@loadingColor: rgba(255,255,255,0.25);
@buttonUnCheckedColorInner: #808080;
@buttonUnCheckedColorOuter: #606060;
@buttonCheckedColorInner: #839eff;
@buttonCheckedColorOuter: #334eb0;
@buttonShadowTLColor: rgb(150,150,150);
@buttonShadowBRColor: rgb(50,50,50);
@buttonUnCheckedColorInnerGreen: mix(@buttonUnCheckedColorInner, rgb(0,255,0), 95%);
@buttonUnCheckedColorOuterGreen: mix(@buttonUnCheckedColorOuter, rgb(0,255,0), 95%);
@buttonUnCheckedColorInnerRed: mix(@buttonUnCheckedColorInner, rgb(255,0,0), 95%);
@buttonUnCheckedColorOuterRed: mix(@buttonUnCheckedColorOuter, rgb(255,0,0), 95%);
@labelBackground: @buttonUnCheckedColorOuter;
@inputBorderColor:rgb(150,150,150);
@inputBorderColorActive:mix(@inputBorderColor, rgb(255,255,255),50%);
@errorColor: rgb(255,0,0);

114
less/default.less Normal file
View File

@ -0,0 +1,114 @@
@import "colors.less";
@import "header.less";
@import "fform.less";
@import "mapping.less";
@import "main.less";
@import "wifi.less";
@import "update.less";
html,body {
margin:0;
padding:0;
}
body {
background:@backgroundColor;
color: @fontColor;
font-family: sans-serif;
}
.hsplit {
display: flex;
> div + div {
margin-left:40px;
}
}
.half {
width: 50%;
}
.center {
margin-left:auto;
margin-right:auto;
}
.hidden {
display:none;
}
ul.none {
list-style-type: none;
padding:0;
}
ul.inline li {
display:inline-block;
}
.errormsg {
color:#FF0000;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading:before {
display:inline-block;
content: ' ';
border-radius: 50%;
border: .5rem solid @loadingColor;
border-top-color: #000;
animation: spin 1s infinite linear;
width:16px;
height:16px;
}
.loading.smallani:before {
margin-left:-20px;
border-width: .3rem;
position:absolute;
width:8px;
height:8px;
}
a, input, button {
outline:0;
}
::-moz-focus-inner {
border:0;
}
#main {
position:relative;
padding:10px;
}
#main {
h1, h2, h3 {
font-weight:500;
margin-left:10px;
margin-right:10px;
}
}
#mapping {
td {
vertical-align:middle;
}
input[type=text] {
margin-top:0;
}
}
a {
color:inherit;
text-decoration: none;
}
@media screen and (max-width: 400px) {
#main {
padding:0;
}
}

152
less/fform.less Normal file
View File

@ -0,0 +1,152 @@
.fform {
min-width:320px;
background-color: @boxBackgroundColor;
box-shadow: -5px -5px 30px @boxShadowTLColor, 5px 5px 30px @boxShadowBRColor;
border-radius:20px;
border:1px solid @boxBorderColor;
margin:10px;
padding:10px;
position:relative;
.loading {
&:before {
width:25px;
height:25px;
}
}
input[type=text] + label,
input[type=password] + label {
box-sizing: border-box;
display:block;
position:absolute;
pointer-events: none;
margin: ~"calc(-1.85em + 1px) 1em 0";
&:before {
content: attr(placeholder);
display: inline-block;
color: @labelColor;
white-space: nowrap;
transition-property: transform, color, font-size;
transition-duration: 0.2s;
transition-timing-function: ease-out;
transform-origin: left center;
border:1px solid @labelBorder;
border-bottom:0;
padding:3px;
border-radius:5px 5px 0 0;
}
}
input,button,.button,select {
box-shadow: -3px -3px 10px @buttonShadowTLColor, 3px 3px 10px @buttonShadowBRColor;
background: radial-gradient(@buttonUnCheckedColorInner 20%, @buttonUnCheckedColorOuter 100%);
border:1px solid @inputBorderColor;
border-radius:1em;
color: @labelColor;
transition: box-shadow .5s, border .5s;
&:hover, &:focus, &:focus-within {
box-shadow: none;
border:1px solid @inputBorderColorActive;
transition: box-shadow 0.5s, border .5s;
}
&:checked, &:active {
box-shadow: -5px -5px 13px @buttonShadowBRColor, 3px 3px 10px @buttonShadowTLColor;
transition: box-shadow 0.5s;
}
&.green {
background: radial-gradient(@buttonUnCheckedColorInnerGreen 20%, @buttonUnCheckedColorOuterGreen 100%);
}
&.red {
background: radial-gradient(@buttonUnCheckedColorInnerRed 20%, @buttonUnCheckedColorOuterRed 100%);
}
}
input[type=radio], input[type=checkbox], select {
height: 2em;
-webkit-appearance: none;
-moz-appearance: none;
}
select {
margin-top:-4px;
vertical-align:top;
padding:0 3px;
}
input[type=radio], input[type=checkbox] {
width:2em;
&:checked {
background: radial-gradient(@buttonCheckedColorInner 20%, @buttonCheckedColorOuter 100%);
}
& + label {
margin:0.5em 0 0 5px;
display:inline-block;
height:2em;
vertical-align:top;
}
}
input[type=text], input[type=password], input[type=submit], button {
font-size: 100%;
margin: 1.3em 0 0;
padding: 0.2em 0.5em 0;
height: 2em;
box-sizing: border-box;
width:100%;
}
input[type=text], input[type=password] {
&:active {
box-shadow:none;
}
}
button {
cursor:pointer;
outline:none;
}
label + a, label + p {
margin-top:1.3em;
display:inline-block;
}
input[type=text], input[type=password], input[type=file], select, button {
&:focus,
&.active,
&:valid,
&.invalid,
&:disabled {
& + label:before {
transform: translateY(-2.0em) translateX(0.5em);
border-color: @inputBorderColor;
font-size:70%;
background: @labelBackground;
}
}
}
.errormsg {
display:inline-block;
}
.successmsg {
margin-top:1em;
color:#009900;
display:inline-block;
}
&.small, .small {
font-size: 75%;
input[type=checkbox] + label {
margin:0.6em 0 0;
}
input[type=text], input[type=password], input[type=file], select {
&:focus,
&.active,
&:valid,
&.invalid,
&:disabled {
& + label:before {
transform: translateY(-2.3em) translateX(0.5em);
}
}
}
}
}

23
less/header.less Normal file
View File

@ -0,0 +1,23 @@
header {
padding:0px 1em 1.5em;
background: linear-gradient(0deg, @backgroundColor 0%, @headerBottom 1.0em, @headerBackground 1.0em 100%);
h1 {
display:inline-block;
margin: 0 20px 0 0;
}
#menu {
display:inline-block;
margin:0;
li+li{
margin-left: 20px;
}
}
a {
color:inherit;
text-decoration: none;
&:focus, &:active {
text-decoration: underline;
}
}
}

112
less/main.less Normal file
View File

@ -0,0 +1,112 @@
#trigger {
button.trigger {
height:1.5em;
margin-top:0;
text-align:center;
font-size:2em;
img {
margin-right:20px;
vertical-align:top;
height:1em;
}
align-items:center;
}
}
#edittiming {
> div {
float:left;
margin-bottom:20px;
.outer {
transition: font-size .1s, opacity 0.1s 0.1s;
}
overflow:hidden;
.minmax {
display:block;
position:absolute;
right:20px;
width:18px;
height:18px;
border-radius:50%;
background: @buttonUnCheckedColorOuter;
&:before {
top:5px;
right:1px;
content: '';
display: block;
border-top: 8px solid @buttonCheckedColorInner;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
width: 0;
height: 0;
position:absolute;
transition: transform 0.2s;
}
}
h3 {
text-align:center;
margin:0;
font-weight:500;
}
input[type=text] {
width:80px;
}
button.del {
width:40px;
}
.time {
white-space:nowrap;
}
hr {
margin:10px -10px -10px;
border:0;
border-top:1px solid rgba(0,0,0,.3);
border-bottom:1px solid rgba(255,255,255,.3);
}
&.min {
.outer {
font-size:0;
opacity:0;
transition: opacity 0.1s, font-size 0.1s .1s;
}
.minmax:before{
transform: rotate(90deg);
transition:transform .2s;
}
}
}
}
#mainview .error {
color:@errorColor;
margin:25px 10px;
}
@media screen and (min-width: 800px) {
#trigger {
width:400px;
}
#edittiming > div {
width:400px;
.time br {
display: none;
}
}
}
@media screen and (max-width: 800px) {
#edittiming {
> div {
width:auto;
float:none;
.time {
white-space: normal;
}
button.del {
display:block;
width:100%;
}
}
}
}

12
less/mapping.less Normal file
View File

@ -0,0 +1,12 @@
#mapping {
max-width:500px;
.pin {
display:inline-block;
height:2em;
margin:0.5em 0 0;
vertical-align:top;
}
.mapping {
margin-bottom:0.5em;
}
}

41
less/overlay.less Normal file
View File

@ -0,0 +1,41 @@
.overlay {
position:fixed;
z-index:128000;
top:0;
left:0;
width:100%;
height:100vh;
background:rgba(0,0,0,0.5);
> div {
display:flex;
flex-direction:column;
margin: 12vh auto 0;
max-width:960px;
min-width:350px;
min-height:300px;
width:75%;
height:75vh;
background:#FFF;
border:1px solid #cacaca;
border-radius:5px;
box-shadow:0 0 10px #000;
.header {
padding:5px;
border-bottom:1px solid #cacaca;
h2 {
margin:0;
padding:0;
font-size:120%;
text-align:center;
}
span.close {
float:right;
cursor:pointer;
}
}
form {
overflow:auto;
}
}
}

9
less/update.less Normal file
View File

@ -0,0 +1,9 @@
#update {
> div {
width:400px;
}
label {
display:block;
margin-bottom:10px;
}
}

21
less/wifi.less Normal file
View File

@ -0,0 +1,21 @@
#wifi {
margin-top:20px;
max-width:600px;
.hsplit {
>div + div {
width:400px;
}
}
}
@media screen and (max-width: 500px) {
#wifi {
.hsplit {
display:block;
>div + div {
margin:0;
width:100%;
}
}
}
}

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "CameraTrigger",
"version": "1.0.0",
"homepage": "https://contentnation.net/grumpydevelop/",
"author": {
"name" : "Sascha Nitsch",
"url" : "https://contentnation.net/grumpydevelop/"
},
"funding": {
"type": "ContentNation",
"url": "https://contentnation.net/grumpydevelop/"
},
"license": "GPL-3.0-or-later",
"devDependencies": {
"@types/jquery": "^3.5.16",
"grunt": "^1.5.3",
"grunt-cli": "^1.4.3",
"grunt-contrib-concat": "^2.1.0",
"grunt-contrib-copy": "^1.0.0",
"grunt-contrib-less": "^3.0.0",
"grunt-contrib-uglify": "^5.2.2",
"grunt-contrib-watch": "^1.1.0",
"grunt-json-merge": "^0.2.2",
"grunt-newer": "^1.3.0",
"grunt-ts": "^6.0.0-beta.22",
"typescript": "^4.9.4"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.2.1",
"@types/mustache": "^4.2.2",
"jquery": "^3.6.3",
"jquery-toast-plugin": "^1.3.2",
"mustache": "^4.2.0"
},
"browserslist": [
"defaults"
]
}

4
static/api/config.json Normal file
View File

@ -0,0 +1,4 @@
{
"wifimode": "ap",
"ssid": "CameraTrigger"
}

View File

@ -0,0 +1,10 @@
{
"d0": ["Flash 1", "digitalout", false],
"d1": ["Valve 1", "digitalout", false],
"d2": ["Valve 2", "digitalout", false],
"d3": ["trigger", "trigger", false],
"d4": ["", "none", false],
"d5": ["Flash 2", "digitalout", false],
"d6": ["Camera", "irsony", false],
"d7": ["", "none", false]
}

3
static/api/run Normal file
View File

@ -0,0 +1,3 @@
{
"message": "ok"
}

4
static/api/ssid.json Normal file
View File

@ -0,0 +1,4 @@
[
["abc", 70, 2, ""],
["def", 65, 3, "open"]
]

3
static/api/wifi Normal file
View File

@ -0,0 +1,3 @@
{
"error": "invalidpassword"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

20
static/index.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Camera Trigger</title>
<script src="jquery.min.js"></script>
<script src="jquery.toast.min.js"></script>
<script src="mustache.min.js"></script>
<link rel="stylesheet" href="default.css" type="text/css" />
<link rel="stylesheet" href="jquery.toast.min.css" type="text/css" />
</head>
<body>
<header>
<h1><a href="#"><img src="cameratrigger_logo.png" alt="logo" height="24"/> Camera Trigger</a></h1>
<ul id="menu" class="none inline"></ul>
</header>
<div id="main">loading ...</div>
</body>
<script src="bootstrap.min.js"></script>
</html>

26
templates/base/main.mst Normal file
View File

@ -0,0 +1,26 @@
<script id="tplMain" type="x-tmpl-mustache">
<form><div id="mainview">
<div class="fform" id="trigger">
<button class="green trigger"><img src="cameratrigger_logo.png" alt="logo"/> {{l.main.trigger}}</button>
<button class="green save"> {{l.main.save}}</button>
</div>
<div class="error"></div>
<h2>{{l.main.edittiming}}</h2>
<div id="edittiming">
{{#pinmapping}}
<div class="fform" data-pin="{{pin}}">
<a href="#" class="minmax button" data-collapse="{{pin}}">&nbsp;</a>
<h3>{{name}}</h3>
<div class="outer"><div class="inner">
<button class="add green">+</button>
</div></div>
</div>
{{/pinmapping}}
</div>
</div>
</form>
</script>
<script id="tplMainTime" type="x-tmpl-mustache">
<div class="time">{{l.main.start}}: <input type="text" name="start_{{name}}_{{index}}" value="0"/> ms {{#delta}}&Delta; <input type="text" name="delta_{{name}}_{{index}}" value="1"/> ms {{/delta}}<button class="del red">-</button></div>
<hr />
</script>

4
templates/base/menu.mst Normal file
View File

@ -0,0 +1,4 @@
<script id="tplMenu" type="x-tmpl-mustache">
<li><a href="#wifi">{{l.menu.setupwifi}}</a></li>
<li><a href="#pinmapping">{{l.menu.setuppinmapping}}</a></li>
</script>

View File

@ -0,0 +1,23 @@
<script id="tplPinMapping" type="x-tmpl-mustache">
<form class="fform" id="mapping"><div>
<h2>{{l.mapping.edittitle}}</h2>
{{#pinmapping}}
<div class="mapping">
<span class="pin">{{l.mapping.pin}} {{pin}}</span>
<span class="pin"><label for="type_{{pin}}">{{l.mapping.type}}</label> <select name="type_{{pin}}" id="type_{{pin}}">
<option value="none">{{l.mapping.notconn}}</option>
<option value="digitalout">{{l.mapping.digitalout}}</option>
<option value="ircanon">{{l.mapping.ircanon}}</option>
<option value="irnikon">{{l.mapping.irnikon}}</option>
<option value="irsony">{{l.mapping.irsony}}</option>
<option value="trigger">{{l.mapping.trigger}}</option>
<option value="none">{{l.mapping.notconn}}</option>
</select></span
<span><input type="checkbox" name="inverted_{{pin}}" id="inverted_{{pin}}"{{#inverted}} checked="checked"{{/inverted}}/><label for="inverted_{{pin}}">{{l.mapping.inverted}}</label></span
<span><input type="text" name="name_{{pin}}" value="{{name}}" /></span>
</div>
{{/pinmapping}}
<input type="submit" value="{{l.generic.save}}" />
</table>
</div></form>
</script>

View File

@ -0,0 +1,8 @@
<script id="tplUpdate" type="x-tmpl-mustache">
<form action="api/upload" method="post" enctype="multipart/form-data" id="update"><div class="fform">
<h2>{{l.update.header}}</h2>
<label for="update">{{l.update.fileselect}}</label>
<input type="file" name="update" /><br />
<input type="submit" value="{{l.update.submit}}" class="green" />
</div></form>
</script>

28
templates/base/wifi.mst Normal file
View File

@ -0,0 +1,28 @@
<script id="tplWifi" type="x-tmpl-mustache">
<form action="api/wifi" method="post" class="fform" id="wifi">
<div class="hsplit">
<div>
<h2>{{l.wifi.accessmode}}</h2>
<div><input type="radio" name="mode" id="ap" value="ap"><label for="ap">{{l.wifi.accesspoint}}</label></div>
<div><input type="radio" name="mode" id="client" value="client"><label for="client">{{l.wifi.client}}</label></div>
</div>
<div>
<h2>{{l.wifi.accessdetails}}</h2>
<div class="hidden" id="ssidscan">
<button class="smallani">{{l.wifi.rescan}}</button>
<ul class="small none"></ul>
</div>
<div><input type="text" name="ssid" id="ssid" value="{{ssid}}" /><label for="ssid" placeholder="{{l.wifi.ssid}}"></label></div>
<div><input type="password" name="password" id="password" value="" /><label for="password" placeholder="{{l.wifi.password}}"></label></div>
</div>
</div>
<div class="center">
<button id="save" class="smallani">{{l.generic.save}}</button>
</div>
</form>
</script>
<script id="tplWifiSSID" type="x-tmpl-mustache">
{{#ssids}}
<li><input type="radio" name="ssidlist" id="radio{{index}}" value="{{name}}"{{#active}} checked="checked"{{/active}}><label for="radio{{index}}">{{name}} {{quality}}% {{l.wifi.channel}} {{channel}} {{l.wifi.encryptionType}} {{encryption}}</label></li>
{{/ssids}}
</script>

70
ts/api.ts Normal file
View File

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/// <reference path="cameratrigger.ts" />
class Api {
/**
* ajax get call
* @param {string} path url path to call
* @param {object} parameter parameters to pass to api call
* @param {object} context optional parameter for setting the context the success and error function will be called
* @param {function} success optional success callback function
* @param {function} error optional error callback function
*/
static get(path: string, parameter, context, success, error) {
jQuery.ajax({
url : "api" + (path[0] != '/' ? '/' : '') + path,
dataType : 'json',
type : "GET",
data : parameter,
success : success,
error : error,
context : context
});
};
/**
* brief ajax post call api
* @param {string} path url path to call
* @param {object} parameter parameters to pass to api call
* @param {object} context optional parameter for setting the context the success and error function will be called
* @param {function} success optional success callback function
* @param {function} error optional error callback function
*/
static post(path: string, parameter, context, success, error) {
jQuery.ajax({
url : "api" + (path[0] != '/' ? '/' : '') + path,
dataType : 'json',
type : "POST",
data : parameter,
processData: !(parameter instanceof FormData),
success : success,
error : error,
context : context,
contentType: parameter instanceof FormData ? false : "application/x-www-form-urlencoded; charset=UTF-8"
});
};
/**
* brief ajax del call api
* @param {string} path url path to call
* @param {object} parameter parameters to pass to api call
* @param {object} context optional parameter for setting the context the success and error function will be called
* @param {function} success optional success callback function
* @param {function} error optional error callback function
*/
static del(path: string, context, success, error) {
jQuery.ajax({
url : "api" + (path[0] != '/' ? '/' : '') + path,
dataType : 'json',
type : "DELETE",
success : success,
error : error,
context : context
});
};
}

189
ts/cameratrigger.ts Normal file
View File

@ -0,0 +1,189 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/// <reference path="forminfo.ts" />
/// <reference path="loader.ts" />
/// <reference path="view.ts" />
/// <reference path="toast.d.ts" />
/// <reference path="api.ts" />
interface Wifi {
mode:string;
ssid:string;
}
type PinMappings = Record<string,[string, string, boolean]>;
class CameraTrigger {
public form:FormInfo;
public lang: any;
public loader: Loader;
public routing: any;
public oldhash: string;
public view: View;
public wifi: Wifi;
public pinMapping: PinMappings;
constructor() {
this.routing = {
"": "MainView",
"#wifi": "WifiView",
"#pinmapping": "PinMappingView",
"#update": "UpdateView"
};
this.loader = new Loader(this);
this.loadConfig();
this.loader.template("base", this, this.start, null);
}
public getVersion() : string {
return "beta1";
}
/**
* default error function
* - disables loading indicator
* - fills in fields with class errormsg
* - show error toaster
*/
public error(jqXHR, textStatus, errorThrown) {
var error="error";
if (jqXHR.responseJSON) {
error = jqXHR.responseJSON.error;
} else {
if (errorThrown in app.lang.error) {
error = app.lang.error[errorThrown];
} else {
error = errorThrown;
}
}
if (this.form) {
$(this.form.target).removeClass("loading");
$(this.form.target).find('.errormsg').text(error);
}
$.toast({
text: error,
position: 'top-right',
icon: 'error'
});
};
/**
* default success function
* - disables loading indicator
* - fills in fields with class successmsg
* - show toaster
*/
public success(jqXHR, textStatus, errorThrown) {
var message="ok";
if (jqXHR.responseJSON) {
message = jqXHR.responseJSON.message;
if (message in app.lang.message) {
message = app.lang.message[message];
}
}
if (this.form) {
$(this.form.target).removeClass("loading");
$(this.form.target).find('.successmsg').text(message);
}
$.toast({
text: message,
position: 'top-right',
icon: 'success'
});
};
loadConfig() {
Api.get("config.json", {}, this, this.applyConfig, this.error);
}
applyConfig(config) {
// load language file
this.loader.addLang("en", "main", this, this.start, this.error);
this.wifi = {mode: config.wifimode, ssid: config.ssid};
}
start() {
if (!this.lang || $("#tplMenu").length == 0) {
// try again the next time
return;
}
// render menu
var template = $("#tplMenu").html();
var data:any = {};
data.l = this.lang;
var html = Mustache.render(template, data);
$("#menu").prepend(html);
$(window).on('hashchange', this, this.doRouting);
this.doRouting();
}
doRouting(event?: JQuery.TriggeredEvent) {
if (event && event.data) {
event.data.doRouting();
return false;
}
var hash = document.location.hash;
if (hash != this.oldhash && this.view) {
this.view.finish();
}
this. oldhash = hash;
var className = this.routing[hash];
if (className) {
this.newClass(className);
}
}
/**
* create a new instance of given classname with optional construction parameter
* - loads class if not yet known, filename: js/lowerCaseClassName.min.js
* - creates instance of class
* @param {any} classname class name
* @param {object} parameter optional parameter for new instance
* @private
*/
newClass(classname: any, parameter?: object) : any {
if (typeof classname === "object") {
let newclass: any = window[classname.name];
new newclass(this, classname.parameter);
return;
}
if (!window[classname]) {
this.loader.js(classname.toLowerCase(), this, this.newClass, {
name : classname,
parameter : parameter
});
return;
}
let newclass: any = window[classname];
new newclass(this, parameter);
};
getPinMapping(context: any, callback: any) {
Api.get("pinmapping.json",{}, {t:this, context:context, callback:callback}, this.gotPinMapping, this.error);
}
gotPinMapping(this:any, data: any) {
var self = this.t;
var cbcontext = this.context;
var callback = this.callback;
self.pinMapping = data;
if (callback) {
callback.call(cbcontext);
}
}
setPinMapping(data: PinMappings) {
this.pinMapping = data;
}
}
var app: CameraTrigger;
$(document).ready(function() {
app = new CameraTrigger();
});

12
ts/forminfo.ts Normal file
View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
class FormInfo {
public enctype: string;
public target: JQuery.Node;
public path: string;
public parentElement: JQuery<HTMLElement>;
}

178
ts/loader.ts Normal file
View File

@ -0,0 +1,178 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/// <reference path="cameratrigger.ts" />
// IE stuff
interface HTMLElement {
readyState: any;
onreadystatechange: any;
}
class Loader {
private loading: object;
private app: CameraTrigger;
constructor(app: CameraTrigger) {
this.loading = {};
this.app = app;
}
/**
* load css
* @param {string} file file to load
* - checks for duplicates
* - file name: /css/ + file + .css?v=version
*/
css(file: string) {
if (!document.getElementById("css_" + file)) {
var link = document.createElement('link');
link.setAttribute("rel", "stylesheet");
link.setAttribute("type", "text/css");
link.setAttribute("id", "css_" + file);
if (file[0] === "/") {
link.setAttribute("href", file);
} else {
link.setAttribute("href", "css/" + file + ".css?v=" + this.app.getVersion());
}
document.getElementsByTagName("head")[0].appendChild(link);
}
};
/**
* gerneic callback wrapper supporting App scope or optional other scope
* @param {object} scope to use (undefined or false uses App instance)
* @param {Function} funct function to call
* @param {object} parameter paramter for funct
* @private
*/
private callback(scope: object, funct: Function, parameter: object) {
if (!scope) {
funct.call(this, parameter);
} else {
funct.call(scope, parameter);
}
};
/**
* load javascript
* @param {string} file file to load
* @param {object} scope scope for callback
* @param {Function} callback callback function
* @param {object} parameter parameter for callback
* - checks for duplicates
* - file name: /js/ + file + .min.js?v=version
*/
js(file: string, scope?: object, callback?: Function, parameter?: object) : boolean {
if (this.loading[file]) {
this.loading[file].push([scope,callback, parameter]);
return true;
}
if (!document.getElementById("script_" + file.replace("/","_"))) {
var script:HTMLElement = document.createElement('script');
if (file[0] === "/") {
script.setAttribute("src", file);
} else {
script.setAttribute("src", file + ".min.js?v=" + this.app.getVersion());
}
if (!callback) {
callback = function() {
script.setAttribute("id", "script_" + file);
// console.log("trigger", "scriptload." + file);
$(document).trigger("scriptload." + file);
};
}
this.loading[file] = [[scope,callback, parameter]];
if (callback) {
if (script.readyState) { // IE
script.onreadystatechange = function() {
if (script.readyState === "loaded" || script.readyState === "complete") {
script.onreadystatechange = null;
script.setAttribute("id", "script_" + file.replace("/","_"));
this.callbackList(file);
delete this.loading[file];
}
}.bind(this);
} else { // Others
script.onload = function() {
script.setAttribute("id", "script_" + file.replace("/","_"));
this.callbackList(file);
delete this.loading[file];
}.bind(this);
}
}
document.getElementsByTagName("head")[0].appendChild(script);
return true;
}
if (callback) {
this.callback(scope, callback, parameter);
} else {
$(document).trigger("scriptload." + file);
}
return false;
};
/**
* calls multiple callsbacks if a file has been loaded
* @param {string} file file that has been loaded
* @private
*/
callbackList(file: string) {
for (var i = 0; i < this.loading[file].length; ++i) {
var c = this.loading[file][i];
if (c[0]) {
this.callback(c[0], c[1], c[2]);
}
}
$(document).trigger("scriptload." + file);
};
/**
* add translation file to global translation object
* @param {string} language
* @param {string} group language group
* @param {object} scope scope for callback function
* @param {Function} callback callback function
* @param {object} parameter parameter for callback
*/
addLang(lang: string, group: string, scope?: object, callback?:Function, parameter?: object) {
if (this.app.lang && this.app.lang[group]) {
if (callback) {
this.callback(scope, callback, parameter);
}
} else {
jQuery.ajax({
url : lang + "_" + group + ".json?v=" + this.app.getVersion(),
dataType : 'json',
type : "GET",
data : parameter,
success : function(a) {
this.app.lang = jQuery.extend(this.app.lang, a);
if (callback) {
this.callback(scope, callback, parameter);
}
},
error : this.app.error,
context : this
});
}
};
template(file: string, scope: object, callback: Function, param?: object) {
jQuery.ajax({
url: file+'.mst?v=' + this.app.getVersion(),
dataType: 'text',
context: this,
success: function(template) {
$("body").append(template);
if (callback) {
this.callback(scope, callback, param);
}
}
});
}
}

158
ts/mainview.ts Normal file
View File

@ -0,0 +1,158 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/// <reference path="view.ts" />
/// <reference path="cameratrigger.ts" />
class MainView extends View {
constructor(app: CameraTrigger) {
super(app);
if (!this.app.pinMapping) {
this.app.getPinMapping(this, this.render);
} else {
this.render();
}
}
finish() {
}
render() {
var template = $("#tplMain").html();
var data:any = {};
data.l = this.app.lang;
var keys = Object.keys(this.app.pinMapping);
data.pinmapping = [];
keys.forEach(function(key){
var pin = this.app.pinMapping[key];
if (pin[1] !== "none" && pin[1] !== "trigger") {
data.pinmapping.push({pin: key, name: pin[0]});
}
});
var html = Mustache.render(template, data);
this.root.html(html);
this.root.find("button.add").on("click", this, this.addTime);
this.root.on("click","button.del", this, this.delTime);
this.root.find(".minmax").on("click", this, this.toggleExpansion);
this.root.find("#trigger button.trigger").on("click", this, this.trigger);
this.root.find("#trigger button.save").on("click", this, this.save);
}
addTime(event: JQuery.TriggeredEvent) {
event.preventDefault();
var target = $(event.currentTarget);
var template = $("#tplMainTime").html();
var data:any = {};
var fform = target.parents(".fform");
data.name = fform.data("pin");
data.index = fform.find(".time").length;
data.delta = event.data.app.pinMapping[data.name][1]==="digitalout";
data.l = event.data.app.lang;
var html = Mustache.render(template, data);
target.before(html);
}
delTime(event: JQuery.TriggeredEvent) {
event.preventDefault();
var target = $(event.currentTarget).parent();
target.remove();
}
toggleExpansion(event: JQuery.TriggeredEvent) {
event.preventDefault();
var form = $(event.currentTarget).parents("div.fform")
form.toggleClass("min");
return false;
}
trigger(event: JQuery.TriggeredEvent) {
event.preventDefault();
event.data.realTrigger(true)
}
save(event: JQuery.TriggeredEvent) {
event.preventDefault();
event.data.realTrigger(false)
}
realTrigger(alsoTrigger: boolean) {
// get list of active pins
var times={};
var inputs = this.root.find("form").serializeArray();
for (var i = 0 ; i < inputs.length; ++i) {
var tokens = inputs[i].name.split("_");
var type = tokens[0]
var pin = parseInt(tokens[1].substr(1));
var time = parseInt(inputs[i].value);
if (type === "delta") {
time += parseInt(inputs[i-1].value);
}
if (!times[time]) {
times[time] = [];
}
var start = type === "start";
if (this.app.pinMapping[tokens[1]][2]) // inverted
start = !start;
switch (this.app.pinMapping[tokens[1]][1]) { // type
case "digitalout":
times[time].push((start ? "E" : "D") + pin);
break;
case "ircanon":
times[time].push("C" + pin);
break;
case "irnikon":
times[time].push("N" + pin);
break;
case "irsony":
times[time].push("S" + pin);
break;
}
}
var keys = Object.keys(times).sort(function(a,b){var A=parseInt(a);var B=parseInt(b);return A<B ? -1 : ((A==B) ? 0 : 1);});
var cmdString = "";
var curTime = 0;
var needDelay = 0;
this.root.find(".error").html("");
for (var j = 0; j < keys.length ; ++j) {
var diff = parseInt(keys[j]) - curTime;
if (needDelay > diff) {
var err = this.app.lang.main.needpause.replace("$1", needDelay);
$.toast({
text: err,
position: 'top-right',
icon: 'error'
});
this.root.find(".error").html(err);
return;
}
if (diff>0) {
cmdString += "d" + diff + ";";
}
needDelay = 0;
times[keys[j]].forEach(function(a) {
if (a[0] === 'C')
needDelay = 10;
else if (a[0] === 'N')
needDelay = 136;
else if (a[0] === 'S')
needDelay = 39;
});
cmdString += times[keys[j]].join(";") + ";";
curTime = parseInt(keys[j]);
}
//check for trigger
keys = Object.keys(this.app.pinMapping)
for (var k = 0; k < keys.length ; ++k) {
if (this.app.pinMapping[keys[k]][1] === "trigger") {
cmdString += "T" + keys[k].substr(1) + ";";
}
console.log(this.app.pinMapping[keys[k]]);
}
Api.post("/run", {"cmd": cmdString, "trigger": alsoTrigger}, this, this.app.success, this.app.error);
}
}

67
ts/pinmappingview.ts Normal file
View File

@ -0,0 +1,67 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/// <reference path="view.ts" />
/// <reference path="cameratrigger.ts" />
class PinMappingView extends View {
public form: any;
constructor(app: CameraTrigger) {
super(app);
if (!this.app.pinMapping) {
this.app.getPinMapping(this, this.render);
} else {
this.render();
}
}
finish() {
this.root.find("input[type=submit]").off("click", this.saveEdit);
}
render() {
var template = $("#tplPinMapping").html();
var data:any = {};
data.l = this.app.lang;
data.pinmapping = [];
var keys = Object.keys(this.app.pinMapping);
keys.forEach(function(key){
var pin = this.app.pinMapping[key];
data.pinmapping.push({pin: key, name: pin[0], type: pin[1], inverted: pin[2]});
});
var html = Mustache.render(template, data);
this.root.html(html);
this.root.find("input[type=submit]").on("click", this, this.saveEdit);
// set select boxes
for (var i = 0; i < 8; ++i) {
this.root.find("select[name=type_d" + i + "]").val(this.app.pinMapping["d" + i][1]);
}
}
saveEdit(event?: JQuery.TriggeredEvent) {
if (event && event.data) {
event.preventDefault();
event.data.saveEdit();
return;
}
var data = {};
var keys = Object.keys(this.app.pinMapping);
var root = this.root;
keys.forEach(function(key){
var pin = this.app.pinMapping[key];
data[key] = [root.find("input[name=name_" + key + "]").val(), root.find("#type_" + key).val(), root.find("input[name=inverted_" + key + "]").prop("checked")===true];
});
Api.post("pinmapping", JSON.stringify(data), this, this.success, this.app.error);
}
success(data: PinMappings) {
this.app.setPinMapping(data);
$.toast({
text: this.app.lang.generic.savesuccess,
position: 'top-right',
icon: 'success'
});
}
}

10
ts/toast.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
interface JQueryStatic {
toast(options?: any, callback?: Function) : any;
}

26
ts/updateview.ts Normal file
View File

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/// <reference path="view.ts" />
/// <reference path="cameratrigger.ts" />
class UpdateView extends View {
public form: any;
constructor(app: CameraTrigger) {
super(app);
this.render();
}
finish() {
}
render() {
var template = $("#tplUpdate").html();
var data:any = {};
data.l = this.app.lang;
var html = Mustache.render(template, data);
this.root.html(html);
}
}

19
ts/view.ts Normal file
View File

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
class View {
protected app: CameraTrigger;
protected root:JQuery<HTMLElement>;
constructor(app: CameraTrigger) {
this.app = app;
app.view = this;
this.root = $("#main");
this.root.empty();
}
finish() {
}
}

145
ts/wifiview.ts Normal file
View File

@ -0,0 +1,145 @@
/*
* SPDX-FileCopyrightText: 2023 Sascha Nitsch (@grumpydevelop@contentnation.net) https://contentnation.net/en/grumpydevelop
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
/// <reference path="view.ts" />
/// <reference path="cameratrigger.ts" />
interface SSID {
name: string;
quality: number;
active: boolean;
channel: number;
encryption: string;
index: number;
}
class WifiView extends View {
private ssidlist: Array<SSID>;
public form: any;
constructor(app: CameraTrigger) {
super(app);
this.render();
this.ssidlist = [];
}
finish() {
this.root.off("change", "input[name=mode]");
this.root.find("#ssidscan button").off("click");
this.root.find("#ssidscan ul").off("change", "input[name=ssidlist]");
this.root.find("#save").off("click");
}
render() {
var template = $("#tplWifi").html();
var data:any = {};
data.l = this.app.lang;
data.ssid = this.app.wifi.ssid;
var html = Mustache.render(template, data);
this.root.html(html);
var active = $("#" + this.app.wifi.mode);
active.prop("checked", true);
this.root.on("change", "input[name=mode]", this, this.modechange);
this.root.find("#ssidscan button").on("click", this, this.loadSSIDList);
this.root.find("#save").on("click", this, this.save);
this.modechange();
}
modechange(event?: JQuery.TriggeredEvent) {
if (event && event.data) {
event.data.modechange();
return false;
}
var mode = this.root.find("input[name=mode]:checked").val();
var sync = this.root.find("#ssidscan");
var ssid = this.root.find("#ssid");
if (mode == "client") {
// show sync button and optionally load first list
sync.removeClass("hidden");
ssid.attr("disabled","disabled");
if (!this.ssidlist || this.ssidlist.length == 0) {
this.loadSSIDList();
}
} else {
// hide sync button
sync.addClass("hidden");
ssid.removeAttr("disabled");
}
}
loadSSIDList(event?: JQuery.TriggeredEvent) {
if (event && event.data) {
event.preventDefault();
event.data.loadSSIDList()
return false;
}
var button = this.root.find("#ssidscan button");
button.addClass("loading");
Api.get("ssid.json", {}, this, this.ssidListReceived, this.app.error);
}
ssidListReceived(listdata) {
var i;
this.ssidlist = [];
var button = this.root.find("#ssidscan button");
button.removeClass("loading");
var existing = this.root.find("#ssid").val();
for (i=0; i < listdata.length; ++i) {
this.ssidlist.push({name:listdata[i][0], quality: -listdata[i][1], active: listdata[i][0] === existing, channel:listdata[i][2], encryption: this.app.lang.wifi["encryption" + listdata[i][3]], index:i});
}
var template = $("#tplWifiSSID").html();
var data:any = {};
data.l = this.app.lang;
data.ssids = this.ssidlist;
var html = Mustache.render(template, data);
var list = this.root.find("#ssidscan ul");
list.html(html);
list.on("change", "input[name=ssidlist]", this, this.selectSSID);
}
selectSSID(event?: JQuery.TriggeredEvent) {
if (event && event.data) {
event.data.selectSSID();
return false;
}
var ssid = this.root.find("#ssidscan input[name=ssidlist]:checked").val();
this.root.find("#ssid").val(ssid);
}
save(event?: JQuery.TriggeredEvent) {
if (event && event.data) {
event.preventDefault();
event.data.save();
return false;
}
var save = this.root.find("#save");
save.addClass("loading");
this.form = {target: save[0]};
var data = {
ssid: this.root.find("input[name=ssid]").val(),
mode: this.root.find("input[name=mode]:checked").val(),
password: this.root.find("input[name=password]").val()
};
Api.post("wifi",data, this, this.saved, this.app.error);
}
saved(response: any) {
this.root.find("#save").removeClass("loading");
if (response.error) {
$.toast({
text: this.app.lang.error[response.error],
position: 'top-right',
icon: 'error'
});
} else {
$.toast({
text: this.app.lang.generic.savesuccess,
position: 'top-right',
icon: 'success'
});
window.setTimeout(function(){document.location.href="http://" + response.redirect;}, 3000);
}
}
}