Added OTA firmware update
Added range customization (untested) Added static mode easing (untested)
This commit is contained in:
parent
abd12a3714
commit
b39f3ba826
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@
|
|||||||
src/credentials.h
|
src/credentials.h
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
|
web/update/
|
||||||
|
3
build.ps1
Normal file
3
build.ps1
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
& .\updateversion.ps1
|
||||||
|
& platformio run
|
||||||
|
Copy-Item .\.pioenvs\esp12e\firmware.elf .\web\update\
|
@ -23,6 +23,7 @@ The first byte of a request is the command. Further data depends on the command.
|
|||||||
| 0x04 | SetMode |
|
| 0x04 | SetMode |
|
||||||
| 0x05 | GetRange |
|
| 0x05 | GetRange |
|
||||||
| 0x06 | SetRange |
|
| 0x06 | SetRange |
|
||||||
|
| 0xFF | UpdateFirmware |
|
||||||
|
|
||||||
|
|
||||||
Response
|
Response
|
||||||
@ -101,6 +102,31 @@ Output parameters:<br>
|
|||||||
_Same as input parameters_
|
_Same as input parameters_
|
||||||
|
|
||||||
|
|
||||||
|
### UpdateFirmware
|
||||||
|
Updates the firmware over WiFi. This functionality may be limited in the configuration which is hardcoded into the firmware. There are three modes of operation:
|
||||||
|
|
||||||
|
1. Disabled, firmware can not be updated over WiFi
|
||||||
|
2. Enabled with a hardcoded URL. You may request an update check but any source provided will be ignored.
|
||||||
|
3. Enabled with the URL as input parameters.
|
||||||
|
|
||||||
|
You can only request an update once every 5 seconds (by default), otherwise an error will be returned.
|
||||||
|
|
||||||
|
Please note that the entire packet must not exceed 254 bytes or the remainder will be discarded.
|
||||||
|
|
||||||
|
|
||||||
|
For further information on implementing an update server, see [Arduino ESP8266's HTTPUpdate reference](https://github.com/esp8266/Arduino/blob/master/doc/ota_updates/readme.md#advanced-updater-1).
|
||||||
|
|
||||||
|
|
||||||
|
Input parameters:<br>
|
||||||
|
_If enabled_
|
||||||
|
*port* (word): the port on which the HTTP update server runs
|
||||||
|
*host* (null-terminated string): the host name or IP address on which the HTTP update server runs
|
||||||
|
*path* (null-terminated string): the path to
|
||||||
|
|
||||||
|
Output parameters:<br>
|
||||||
|
1 if succesful, 0 if no updates are available.
|
||||||
|
|
||||||
|
|
||||||
Modes
|
Modes
|
||||||
=====
|
=====
|
||||||
|
|
||||||
|
28
src/config.h
28
src/config.h
@ -1,9 +1,14 @@
|
|||||||
#ifndef __Config
|
#ifndef __Config
|
||||||
#define __Config
|
#define __Config
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include "credentials.h"
|
#include "credentials.h"
|
||||||
|
|
||||||
|
|
||||||
|
#define SerialDebug
|
||||||
|
|
||||||
|
|
||||||
// The name of this device on the network
|
// The name of this device on the network
|
||||||
static const char* WiFiHostname = "Stairs";
|
static const char* WiFiHostname = "Stairs";
|
||||||
|
|
||||||
@ -25,4 +30,27 @@ static const uint8_t PinSCL = 12;
|
|||||||
static const uint8_t PWMDriverAddress = 0x40;
|
static const uint8_t PWMDriverAddress = 0x40;
|
||||||
static const uint16_t PWMDriverPWMFrequency = 1600;
|
static const uint16_t PWMDriverPWMFrequency = 1600;
|
||||||
|
|
||||||
|
|
||||||
|
// Determines if OTA firmware updates are enabled
|
||||||
|
// 0 - Disabled
|
||||||
|
// 1 - Enabled (fixed URL)
|
||||||
|
// 2 - Enabled (use URL in command)
|
||||||
|
static const uint8_t OTAUpdateEnabled = 2;
|
||||||
|
|
||||||
|
static const char* OTAUpdateFixedHost = "";
|
||||||
|
static const uint16_t OTAUpdateFixedPort = 80;
|
||||||
|
static const char* OTAUpdateFixedPath = "/";
|
||||||
|
|
||||||
|
// The minimum amount of time (in milliseconds) between update requests
|
||||||
|
static const uint32_t OTAUpdateThrottle = 5000;
|
||||||
|
|
||||||
|
|
||||||
|
#ifdef SerialDebug
|
||||||
|
#define _d(msg) Serial.print(msg)
|
||||||
|
#define _dln(msg) Serial.println(msg)
|
||||||
|
#else
|
||||||
|
#define _d(msg) do { } while (0)
|
||||||
|
#define _dln(msg) do { } while (0)
|
||||||
|
#endif
|
||||||
|
|
||||||
#endif
|
#endif
|
172
src/main.cpp
172
src/main.cpp
@ -6,6 +6,7 @@
|
|||||||
#include <Stream.h>
|
#include <Stream.h>
|
||||||
#include <ESP8266WiFi.h>
|
#include <ESP8266WiFi.h>
|
||||||
#include <WiFiUdp.h>
|
#include <WiFiUdp.h>
|
||||||
|
#include <ESP8266httpUpdate.h>
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "protocol.h"
|
#include "protocol.h"
|
||||||
@ -16,6 +17,7 @@
|
|||||||
#include "modes\slide.h"
|
#include "modes\slide.h"
|
||||||
#include "modes\static.h"
|
#include "modes\static.h"
|
||||||
#include "stairs.h"
|
#include "stairs.h"
|
||||||
|
#include "version.h"
|
||||||
|
|
||||||
|
|
||||||
PCA9685* pwmDriver;
|
PCA9685* pwmDriver;
|
||||||
@ -33,20 +35,35 @@ void setCurrentMode(IMode *mode, uint8_t identifier);
|
|||||||
void handleCurrentMode();
|
void handleCurrentMode();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void setup()
|
void setup()
|
||||||
{
|
{
|
||||||
|
#ifdef SerialDebug
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(5000);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
_dln("Initializing PCA9685");
|
||||||
|
_d("Version: ");
|
||||||
|
_dln(FirmwareVersion);
|
||||||
|
|
||||||
pwmDriver = new PCA9685();
|
pwmDriver = new PCA9685();
|
||||||
pwmDriver->setAddress(PWMDriverAddress, PinSDA, PinSCL);
|
pwmDriver->setAddress(PWMDriverAddress, PinSDA, PinSCL);
|
||||||
pwmDriver->setPWMFrequency(PWMDriverPWMFrequency);
|
pwmDriver->setPWMFrequency(PWMDriverPWMFrequency);
|
||||||
|
|
||||||
|
_dln("Initializing Stairs");
|
||||||
|
|
||||||
stairs = new Stairs();
|
stairs = new Stairs();
|
||||||
stairs->init(pwmDriver);
|
stairs->init(pwmDriver);
|
||||||
|
|
||||||
|
_dln("Initializing WiFi");
|
||||||
WiFi.hostname(WiFiHostname);
|
WiFi.hostname(WiFiHostname);
|
||||||
WiFi.begin(WiFiSSID, WiFiPassword);
|
WiFi.begin(WiFiSSID, WiFiPassword);
|
||||||
|
|
||||||
|
|
||||||
// Run a little startup test sequence
|
_dln("Starting initialization sequence");
|
||||||
stairs->setAll(IStairs::Off);
|
stairs->setAll(IStairs::Off);
|
||||||
stairs->set(0, IStairs::On);
|
stairs->set(0, IStairs::On);
|
||||||
delay(300);
|
delay(300);
|
||||||
@ -61,6 +78,8 @@ void setup()
|
|||||||
stairs->set(StepCount - 1, IStairs::Off);
|
stairs->set(StepCount - 1, IStairs::Off);
|
||||||
|
|
||||||
|
|
||||||
|
_dln("Waiting for WiFi");
|
||||||
|
|
||||||
// Pulsate the bottom step while WiFi is connecting
|
// Pulsate the bottom step while WiFi is connecting
|
||||||
uint16_t brightness = 0;
|
uint16_t brightness = 0;
|
||||||
uint16_t speed = 16;
|
uint16_t speed = 16;
|
||||||
@ -78,6 +97,11 @@ void setup()
|
|||||||
setCurrentMode(new StaticMode(), Mode::Static);
|
setCurrentMode(new StaticMode(), Mode::Static);
|
||||||
|
|
||||||
|
|
||||||
|
_d("IP address: ");
|
||||||
|
_dln(WiFi.localIP());
|
||||||
|
|
||||||
|
_dln("Starting UDP server");
|
||||||
|
|
||||||
// Start the UDP server
|
// Start the UDP server
|
||||||
udpServer.begin(UDPPort);
|
udpServer.begin(UDPPort);
|
||||||
}
|
}
|
||||||
@ -88,11 +112,9 @@ uint32_t currentTime;
|
|||||||
// Note: the packet size must at least be able to accomodate the
|
// Note: the packet size must at least be able to accomodate the
|
||||||
// command with the largest parameter list, there is no overflow
|
// command with the largest parameter list, there is no overflow
|
||||||
// checking in the mode classes!
|
// checking in the mode classes!
|
||||||
//
|
const uint8_t maxPacketSize = 255;
|
||||||
// At the time of writing, Get/SetRange is the largest:
|
|
||||||
// Set Range (1) + UseScaling (1) + StepCount * 2x Word (4)
|
uint8_t packet[maxPacketSize];
|
||||||
// = 66 for the maximum of 16 steps
|
|
||||||
uint8_t packet[100];
|
|
||||||
uint8_t* packetRef;
|
uint8_t* packetRef;
|
||||||
|
|
||||||
void loop()
|
void loop()
|
||||||
@ -109,8 +131,10 @@ void checkRequest()
|
|||||||
int packetSize = udpServer.parsePacket();
|
int packetSize = udpServer.parsePacket();
|
||||||
if (packetSize)
|
if (packetSize)
|
||||||
{
|
{
|
||||||
|
_dln("Handling incoming packet");
|
||||||
|
|
||||||
memset(packet, 0, sizeof(packet));
|
memset(packet, 0, sizeof(packet));
|
||||||
int length = udpServer.read(packet, 50);
|
int length = udpServer.read(packet, maxPacketSize - 1);
|
||||||
if (length && packet[0])
|
if (length && packet[0])
|
||||||
{
|
{
|
||||||
packetRef = packet;
|
packetRef = packet;
|
||||||
@ -120,32 +144,28 @@ void checkRequest()
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void handleRequest(uint8_t* packet)
|
void handlePing(uint8_t* packet)
|
||||||
{
|
{
|
||||||
// Every request will result in a reply, either containing the
|
_dln("Handling Ping");
|
||||||
// requested data or a copy of the input parameters for verification.
|
|
||||||
//
|
|
||||||
// Apparantly this also makes the ESP8266 more stable, as reports
|
|
||||||
// have been made that UDP communication can stall if no replies are sent.
|
|
||||||
udpServer.beginPacket(udpServer.remoteIP(), udpServer.remotePort());
|
|
||||||
udpServer.write(Command::Reply);
|
|
||||||
|
|
||||||
switch (*packet)
|
|
||||||
{
|
|
||||||
case Command::Ping:
|
|
||||||
udpServer.write(Command::Ping);
|
udpServer.write(Command::Ping);
|
||||||
udpServer.write(StepCount);
|
udpServer.write(StepCount);
|
||||||
break;
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void handleGetMode(uint8_t* packet)
|
||||||
|
{
|
||||||
|
_dln("Handling GetMode");
|
||||||
|
|
||||||
case Command::GetMode:
|
|
||||||
udpServer.write(Command::GetMode);
|
udpServer.write(Command::GetMode);
|
||||||
udpServer.write(currentModeIdentifier);
|
udpServer.write(currentModeIdentifier);
|
||||||
currentMode->write(&udpServer);
|
currentMode->write(&udpServer);
|
||||||
break;
|
}
|
||||||
|
|
||||||
case Command::SetMode:
|
|
||||||
{
|
void handleSetMode(uint8_t* packet)
|
||||||
packet++;
|
{
|
||||||
|
_dln("Handling SetMode");
|
||||||
uint8_t newIdentifier = *packet;
|
uint8_t newIdentifier = *packet;
|
||||||
packet++;
|
packet++;
|
||||||
|
|
||||||
@ -159,6 +179,7 @@ void handleRequest(uint8_t* packet)
|
|||||||
udpServer.write(newIdentifier);
|
udpServer.write(newIdentifier);
|
||||||
newMode->write(&udpServer);
|
newMode->write(&udpServer);
|
||||||
|
|
||||||
|
_dln("Updating current mode");
|
||||||
setCurrentMode(newMode, newIdentifier);
|
setCurrentMode(newMode, newIdentifier);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -167,13 +188,114 @@ void handleRequest(uint8_t* packet)
|
|||||||
udpServer.write(Command::SetMode);
|
udpServer.write(Command::SetMode);
|
||||||
udpServer.write(newIdentifier);
|
udpServer.write(newIdentifier);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint32_t lastUpdateCheck = 0;
|
||||||
|
|
||||||
|
void handleUpdateFirmware(uint8_t* packet)
|
||||||
|
{
|
||||||
|
_dln("Handling UpdateFirmware");
|
||||||
|
|
||||||
|
HTTPUpdateResult result;
|
||||||
|
|
||||||
|
if (currentTime - lastUpdateCheck <= OTAUpdateThrottle)
|
||||||
|
{
|
||||||
|
udpServer.write(Command::Error);
|
||||||
|
udpServer.write(Command::UpdateFirmware);
|
||||||
|
udpServer.write((uint8_t)2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdateCheck = currentTime;
|
||||||
|
|
||||||
|
switch (OTAUpdateEnabled)
|
||||||
|
{
|
||||||
|
case 1:
|
||||||
|
result = ESPhttpUpdate.update(OTAUpdateFixedHost, OTAUpdateFixedPort, OTAUpdateFixedPath, FirmwareVersion);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
{
|
||||||
|
uint16_t port;
|
||||||
|
memcpy(&port, packet, sizeof(port));
|
||||||
|
packet += sizeof(port);
|
||||||
|
|
||||||
|
_d("Port: ");
|
||||||
|
_dln(port);
|
||||||
|
|
||||||
|
char host[255];
|
||||||
|
char path[255];
|
||||||
|
|
||||||
|
strcpy(host, (char*)packet);
|
||||||
|
packet += strlen(host) + 1;
|
||||||
|
|
||||||
|
strcpy(path, (char*)packet);
|
||||||
|
|
||||||
|
_d("Host: ");
|
||||||
|
_dln(host);
|
||||||
|
_d("Path: ");
|
||||||
|
_dln(path);
|
||||||
|
|
||||||
|
result = ESPhttpUpdate.update(host, port, path, FirmwareVersion);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
udpServer.write(Command::Error);
|
udpServer.write(Command::Error);
|
||||||
udpServer.write(*packet);
|
udpServer.write(Command::UpdateFirmware);
|
||||||
|
udpServer.write((uint8_t)0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case HTTP_UPDATE_NO_UPDATES:
|
||||||
|
udpServer.write(Command::UpdateFirmware);
|
||||||
|
udpServer.write((uint8_t)0);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case HTTP_UPDATE_OK:
|
||||||
|
udpServer.write(Command::UpdateFirmware);
|
||||||
|
udpServer.write((uint8_t)1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
udpServer.write(Command::Error);
|
||||||
|
udpServer.write(Command::UpdateFirmware);
|
||||||
|
udpServer.write((uint8_t)1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void handleRequest(uint8_t* packet)
|
||||||
|
{
|
||||||
|
_d("Handling request: ");
|
||||||
|
_dln(*packet);
|
||||||
|
|
||||||
|
|
||||||
|
// Every request will result in a reply, either containing the
|
||||||
|
// requested data or a copy of the input parameters for verification.
|
||||||
|
//
|
||||||
|
// Apparantly this also makes the ESP8266 more stable, as reports
|
||||||
|
// have been made that UDP communication can stall if no replies are sent.
|
||||||
|
udpServer.beginPacket(udpServer.remoteIP(), udpServer.remotePort());
|
||||||
|
udpServer.write(Command::Reply);
|
||||||
|
|
||||||
|
uint8_t command = *packet;
|
||||||
|
packet++;
|
||||||
|
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case Command::Ping: handlePing(packet); break;
|
||||||
|
case Command::GetMode: handleGetMode(packet); break;
|
||||||
|
case Command::SetMode: handleSetMode(packet); break;
|
||||||
|
case Command::UpdateFirmware: handleUpdateFirmware(packet); break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
udpServer.write(Command::Error);
|
||||||
|
udpServer.write(command);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <Stream.h>
|
#include <Stream.h>
|
||||||
|
#include "../config.h"
|
||||||
#include "../mode.h"
|
#include "../mode.h"
|
||||||
|
|
||||||
|
|
||||||
@ -15,12 +16,16 @@ class BaseMode : public IMode
|
|||||||
public:
|
public:
|
||||||
virtual void read(uint8_t* data)
|
virtual void read(uint8_t* data)
|
||||||
{
|
{
|
||||||
this->parameters = *reinterpret_cast<T*>(data);
|
_d("Reading parameters, size ");
|
||||||
|
_dln(sizeof(T));
|
||||||
|
memcpy(&this->parameters, data, sizeof(T));
|
||||||
}
|
}
|
||||||
|
|
||||||
virtual void write(Stream* stream)
|
virtual void write(Stream* stream)
|
||||||
{
|
{
|
||||||
stream->write(reinterpret_cast<uint8_t*>(&this->parameters), sizeof(T));
|
_d("Writing parameters, size ");
|
||||||
|
_dln(sizeof(T));
|
||||||
|
stream->write((uint8_t*)&this->parameters, sizeof(T));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ void CustomMode::read(uint8_t* data)
|
|||||||
|
|
||||||
void CustomMode::write(Stream* stream)
|
void CustomMode::write(Stream* stream)
|
||||||
{
|
{
|
||||||
stream->write(reinterpret_cast<uint8_t*>(&this->values), sizeof(this->values));
|
stream->write((uint8_t*)&this->values, sizeof(this->values));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -2,10 +2,48 @@
|
|||||||
|
|
||||||
void StaticMode::init(IStairs* stairs, uint32_t currentTime)
|
void StaticMode::init(IStairs* stairs, uint32_t currentTime)
|
||||||
{
|
{
|
||||||
|
_dln("Initializing static mode:");
|
||||||
|
_d("currentBrightness: "); _dln(this->currentBrightness);
|
||||||
|
_d("brightness: "); _dln(this->parameters.brightness);
|
||||||
|
_d("easeTime: "); _dln(this->parameters.easeTime);
|
||||||
|
|
||||||
|
if (this->parameters.easeTime > 0 && this->parameters.brightness != this->currentBrightness)
|
||||||
|
{
|
||||||
|
_dln("Easing...");
|
||||||
|
|
||||||
|
this->easeStartTime = currentTime;
|
||||||
|
this->easeStartBrightness = currentBrightness;
|
||||||
|
|
||||||
|
if (this->parameters.brightness > this->currentBrightness)
|
||||||
|
this->easeState = Up;
|
||||||
|
else
|
||||||
|
this->easeState = Down;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_dln("Updating immediately...");
|
||||||
|
this->easeState = None;
|
||||||
|
|
||||||
stairs->setAll(this->parameters.brightness);
|
stairs->setAll(this->parameters.brightness);
|
||||||
|
this->currentBrightness = this->parameters.brightness;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void StaticMode::tick(IStairs* stairs, uint32_t currentTime)
|
void StaticMode::tick(IStairs* stairs, uint32_t currentTime)
|
||||||
{
|
{
|
||||||
|
if (this->easeState == None)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this->currentBrightness = (currentTime - this->easeStartTime) * ((this->parameters.brightness - this->easeStartBrightness) / this->parameters.easeTime);
|
||||||
|
|
||||||
|
|
||||||
|
if ((this->easeState == Up && this->currentBrightness >= this->parameters.brightness) ||
|
||||||
|
(this->easeState == Down && this->currentBrightness <= this->parameters.brightness))
|
||||||
|
{
|
||||||
|
this->currentBrightness = this->parameters.brightness;
|
||||||
|
this->easeState = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
stairs->setAll(this->currentBrightness);
|
||||||
}
|
}
|
@ -8,16 +8,34 @@
|
|||||||
|
|
||||||
struct StaticModeParameters
|
struct StaticModeParameters
|
||||||
{
|
{
|
||||||
uint16_t brightness = 0;
|
uint16_t brightness;
|
||||||
|
uint32_t easeTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum EaseState
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Up,
|
||||||
|
Down
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
class StaticMode : public BaseMode<StaticModeParameters>
|
class StaticMode : public BaseMode<StaticModeParameters>
|
||||||
{
|
{
|
||||||
|
private:
|
||||||
|
uint16_t currentBrightness;
|
||||||
|
uint32_t easeStartTime;
|
||||||
|
uint16_t easeStartBrightness;
|
||||||
|
EaseState easeState;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
StaticMode()
|
StaticMode()
|
||||||
{
|
{
|
||||||
parameters.brightness = 0;
|
parameters.brightness = 0;
|
||||||
|
parameters.easeTime = 0;
|
||||||
|
|
||||||
|
easeState = None;
|
||||||
|
currentBrightness = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void init(IStairs* stairs, uint32_t currentTime);
|
void init(IStairs* stairs, uint32_t currentTime);
|
||||||
|
@ -12,8 +12,12 @@ class Command
|
|||||||
static const uint8_t Reply = 0x02;
|
static const uint8_t Reply = 0x02;
|
||||||
static const uint8_t GetMode = 0x03;
|
static const uint8_t GetMode = 0x03;
|
||||||
static const uint8_t SetMode = 0x04;
|
static const uint8_t SetMode = 0x04;
|
||||||
|
/*
|
||||||
static const uint8_t GetRange = 0x05;
|
static const uint8_t GetRange = 0x05;
|
||||||
static const uint8_t SetRange = 0x06;
|
static const uint8_t SetRange = 0x06;
|
||||||
|
*/
|
||||||
|
|
||||||
|
static const uint8_t UpdateFirmware = 0xFF;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,8 +17,12 @@ struct Header
|
|||||||
|
|
||||||
void Stairs::init(PCA9685* pwmDriver)
|
void Stairs::init(PCA9685* pwmDriver)
|
||||||
{
|
{
|
||||||
|
this->useScaling = false;
|
||||||
|
memset(this->ranges, 0, sizeof(this->ranges));
|
||||||
|
|
||||||
this->pwmDriver = pwmDriver;
|
this->pwmDriver = pwmDriver;
|
||||||
|
|
||||||
|
_dln("Loading range configuration");
|
||||||
SPIFFS.begin();
|
SPIFFS.begin();
|
||||||
this->readRange();
|
this->readRange();
|
||||||
}
|
}
|
||||||
@ -53,7 +57,7 @@ uint16_t Stairs::getPWMValue(uint8_t step, uint16_t brightness)
|
|||||||
if (step < 0 || step >= StepCount)
|
if (step < 0 || step >= StepCount)
|
||||||
return brightness;
|
return brightness;
|
||||||
|
|
||||||
Range* range = this->ranges[step];
|
Range* range = &this->ranges[step];
|
||||||
|
|
||||||
if (this->useScaling)
|
if (this->useScaling)
|
||||||
{
|
{
|
||||||
@ -73,7 +77,7 @@ uint16_t Stairs::getPWMValue(uint8_t step, uint16_t brightness)
|
|||||||
void Stairs::getRange(Stream* stream)
|
void Stairs::getRange(Stream* stream)
|
||||||
{
|
{
|
||||||
stream->write(this->useScaling ? 1 : 0);
|
stream->write(this->useScaling ? 1 : 0);
|
||||||
stream->write(reinterpret_cast<uint8_t*>(&this->ranges), sizeof(this->ranges));
|
stream->write((uint8_t*)&this->ranges, sizeof(this->ranges));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -96,16 +100,17 @@ void Stairs::readRange()
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
Header header;
|
Header header;
|
||||||
f.readBytes(reinterpret_cast<char*>(&header), sizeof(Header));
|
f.readBytes((char*)&header, sizeof(Header));
|
||||||
|
|
||||||
if (header.version != 1)
|
if (header.version != 1)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this->useScaling = (header.useScaling == 1);
|
this->useScaling = (header.useScaling == 1);
|
||||||
|
f.readBytes((char*)&this->ranges, header.rangeCount * sizeof(Range));
|
||||||
memset(this->ranges, 0, sizeof(this->ranges));
|
|
||||||
f.readBytes(reinterpret_cast<char*>(&this->ranges), header.rangeCount * sizeof(Range));
|
|
||||||
f.close();
|
f.close();
|
||||||
|
|
||||||
|
_d("- useScaling: ");
|
||||||
|
_dln(this->useScaling);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -120,7 +125,7 @@ void Stairs::writeRange()
|
|||||||
header.useScaling = this->useScaling;
|
header.useScaling = this->useScaling;
|
||||||
header.rangeCount = StepCount;
|
header.rangeCount = StepCount;
|
||||||
|
|
||||||
f.write(reinterpret_cast<uint8_t*>(&header), sizeof(Header));
|
f.write((uint8_t*)&header, sizeof(Header));
|
||||||
f.write(reinterpret_cast<uint8_t*>(&this->ranges), sizeof(this->ranges));
|
f.write((uint8_t*)&this->ranges, sizeof(this->ranges));
|
||||||
f.close();
|
f.close();
|
||||||
}
|
}
|
@ -19,7 +19,7 @@ class Stairs : public IStairs
|
|||||||
PCA9685* pwmDriver;
|
PCA9685* pwmDriver;
|
||||||
|
|
||||||
bool useScaling;
|
bool useScaling;
|
||||||
Range* ranges[StepCount];
|
Range ranges[StepCount];
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void readRange();
|
void readRange();
|
||||||
|
6
src/version.h
Normal file
6
src/version.h
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#ifndef __Version
|
||||||
|
#define __Version
|
||||||
|
|
||||||
|
static const char* FirmwareVersion = "0.1.0+8";
|
||||||
|
|
||||||
|
#endif
|
23
updateversion.ps1
Normal file
23
updateversion.ps1
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
$output = & GitVersion /output json /nofetch
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Verbose "$output"
|
||||||
|
throw "GitVersion failed with exit code: $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = $output | ConvertFrom-Json
|
||||||
|
|
||||||
|
@"
|
||||||
|
#ifndef __Version
|
||||||
|
#define __Version
|
||||||
|
|
||||||
|
static const char* FirmwareVersion = "{0}";
|
||||||
|
|
||||||
|
#endif
|
||||||
|
"@ -f $version.FullSemVer | Out-File -Encoding UTF8 .\src\version.h
|
||||||
|
|
||||||
|
@"
|
||||||
|
module.exports =
|
||||||
|
{{
|
||||||
|
Version: "{0}"
|
||||||
|
}};
|
||||||
|
"@ -f $version.FullSemVer | Out-File -Encoding UTF8 .\web\version.js
|
@ -1 +0,0 @@
|
|||||||
@platformio run --target upload
|
|
2
upload.ps1
Normal file
2
upload.ps1
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
& .\updateversion.ps1
|
||||||
|
& platformio run --target upload
|
69
web/app.js
69
web/app.js
@ -1,11 +1,38 @@
|
|||||||
|
var fs = require('fs');
|
||||||
var express = require('express');
|
var express = require('express');
|
||||||
|
var semverUtils = require('semver-utils')
|
||||||
var client = require('./client');
|
var client = require('./client');
|
||||||
|
|
||||||
var httpPort = 3127;
|
var httpPort = 3127;
|
||||||
|
|
||||||
var stairsHost = '10.138.2.12';
|
var stairsHost = '10.138.2.25';
|
||||||
var stairsUdpPort = 3126;
|
var stairsUdpPort = 3126;
|
||||||
|
|
||||||
|
var firmwareFile = './update/firmware.elf';
|
||||||
|
|
||||||
|
|
||||||
|
function requireNoCache(filename)
|
||||||
|
{
|
||||||
|
delete require.cache[require.resolve(filename)];
|
||||||
|
return require(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function isNewer(version1, version2)
|
||||||
|
{
|
||||||
|
if (version1.major > version2.major) return true;
|
||||||
|
if (version1.major < version2.major) return false;
|
||||||
|
|
||||||
|
if (version1.minor > version2.minor) return true;
|
||||||
|
if (version1.minor < version2.minor) return false;
|
||||||
|
|
||||||
|
if (version1.patch > version2.patch) return true;
|
||||||
|
if (version1.patch < version2.patch) return false;
|
||||||
|
|
||||||
|
if (version1.build > version2.build) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
client.init(stairsHost, stairsUdpPort);
|
client.init(stairsHost, stairsUdpPort);
|
||||||
|
|
||||||
@ -45,6 +72,46 @@ app.get('/setMode/:mode', function(req, res)
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/updateFirmware', function(req, res)
|
||||||
|
{
|
||||||
|
client.updateFirmware(req.query, function(data, error)
|
||||||
|
{
|
||||||
|
if (error)
|
||||||
|
res.status(500);
|
||||||
|
|
||||||
|
res.send(data);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/checkUpdate', function(req, res)
|
||||||
|
{
|
||||||
|
if (!fs.existsSync(firmwareFile))
|
||||||
|
{
|
||||||
|
console.log('checkUpdate: ' + firmwareFile + ' not found!');
|
||||||
|
res.sendStatus(304);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = requireNoCache('./version.js');
|
||||||
|
var deviceVersion = semverUtils.parse(req.headers['x-esp8266-version']);
|
||||||
|
var localVersion = semverUtils.parse(version.Version);
|
||||||
|
|
||||||
|
console.log('checkUpdate:');
|
||||||
|
console.log(' Device version = ' + semverUtils.stringify(deviceVersion));
|
||||||
|
console.log(' Local version = ' + semverUtils.stringify(localVersion));
|
||||||
|
|
||||||
|
if (isNewer(localVersion, deviceVersion))
|
||||||
|
{
|
||||||
|
res.download(firmwareFile);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
res.sendStatus(304);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(304);
|
||||||
|
});
|
||||||
|
|
||||||
app.use(express.static(__dirname + '/static'));
|
app.use(express.static(__dirname + '/static'));
|
||||||
|
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ client.on('message', function (message, remote)
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
function requestResponse(buffer, callback)
|
function requestResponse(buffer, callback, withTimeout)
|
||||||
{
|
{
|
||||||
if (buffer === null || buffer.length == 0) return;
|
if (buffer === null || buffer.length == 0) return;
|
||||||
console.log('> ' + buffer.toString('hex'));
|
console.log('> ' + buffer.toString('hex'));
|
||||||
@ -61,17 +61,21 @@ function requestResponse(buffer, callback)
|
|||||||
var command = buffer.readInt8(0);
|
var command = buffer.readInt8(0);
|
||||||
var cancelled = false;
|
var cancelled = false;
|
||||||
|
|
||||||
|
if (typeof(withTimeout) == 'undefined') withTimeout = true;
|
||||||
|
if (withTimeout)
|
||||||
|
{
|
||||||
var timeout = setTimeout(function()
|
var timeout = setTimeout(function()
|
||||||
{
|
{
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
callback(null, true);
|
callback(null, true);
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
registerResponseHandler(command, function(reader, error)
|
registerResponseHandler(command, function(reader, error)
|
||||||
{
|
{
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
clearTimeout(timeout);
|
if (withTimeout) clearTimeout(timeout);
|
||||||
|
|
||||||
callback(reader, error);
|
callback(reader, error);
|
||||||
return true;
|
return true;
|
||||||
@ -149,11 +153,11 @@ function writeModeData(mode, data)
|
|||||||
|
|
||||||
var valueCount = Math.min(16, brightness.length);
|
var valueCount = Math.min(16, brightness.length);
|
||||||
var buffer = Buffer.alloc(2 + (valueCount * 2));
|
var buffer = Buffer.alloc(2 + (valueCount * 2));
|
||||||
buffer.writeInt8(protocol.Command.SetMode, 0);
|
buffer.writeUInt8(protocol.Command.SetMode, 0);
|
||||||
buffer.writeInt8(mode, 1);
|
buffer.writeUInt8(mode, 1);
|
||||||
|
|
||||||
for (var index = 0; index < valueCount; index++)
|
for (var index = 0; index < valueCount; index++)
|
||||||
buffer.writeInt16LE(getBrightness(brightness[index]), 2 + (index * 2));
|
buffer.writeUInt16LE(getBrightness(brightness[index]), 2 + (index * 2));
|
||||||
|
|
||||||
return buffer;
|
return buffer;
|
||||||
|
|
||||||
@ -239,5 +243,40 @@ module.exports =
|
|||||||
else
|
else
|
||||||
callback(null, true);
|
callback(null, true);
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateFirmware: function(data, callback)
|
||||||
|
{
|
||||||
|
if (typeof(data.host) == 'undefined') data.host = '';
|
||||||
|
if (typeof(data.port) == 'undefined') data.port = 80;
|
||||||
|
if (typeof(data.path) == 'undefined') data.path = '';
|
||||||
|
|
||||||
|
var buffer = Buffer.alloc(1 + (data.host.length + 1) + 2 + (data.path.length + 1));
|
||||||
|
buffer.writeUInt8(protocol.Command.UpdateFirmware, 0);
|
||||||
|
var position = 1;
|
||||||
|
|
||||||
|
buffer.writeUInt16LE(data.port, position);
|
||||||
|
position += 2;
|
||||||
|
|
||||||
|
buffer.write(data.host, position);
|
||||||
|
position += data.host.length;
|
||||||
|
buffer.writeUInt8(0, position);
|
||||||
|
position++;
|
||||||
|
|
||||||
|
buffer.write(data.path, position);
|
||||||
|
position += data.path.length;
|
||||||
|
buffer.writeUInt8(0, position);
|
||||||
|
|
||||||
|
requestResponse(buffer,
|
||||||
|
function(reader, error)
|
||||||
|
{
|
||||||
|
if (!error)
|
||||||
|
{
|
||||||
|
var data = { hasUpdates: reader.nextInt8() == 1 };
|
||||||
|
callback(data, false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
callback(null, true);
|
||||||
|
}, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -7,6 +7,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-reader": "^0.1.0",
|
"buffer-reader": "^0.1.0",
|
||||||
"express": "^4.15.2"
|
"express": "^4.15.2",
|
||||||
|
"semver-utils": "^1.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,8 @@ module.exports =
|
|||||||
Ping: 0x01,
|
Ping: 0x01,
|
||||||
Reply: 0x02,
|
Reply: 0x02,
|
||||||
GetMode: 0x03,
|
GetMode: 0x03,
|
||||||
SetMode: 0x04
|
SetMode: 0x04,
|
||||||
|
UpdateFirmware: 0xFF
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
@ -20,23 +20,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">Mode</div>
|
<div class="tabs">
|
||||||
|
<div class="tab" data-bind="css: { active: tab() == 'mode' }, click: setTab.bind(this, 'mode')">Mode</div>
|
||||||
|
<div class="tab" data-bind="css: { active: tab() == 'settings' }, click: setTab.bind(this, 'settings')">Settings</div>
|
||||||
|
<div class="tab" data-bind="css: { active: tab() == 'update' }, click: setTab.bind(this, 'update')">Update</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ko if:tab() == 'mode' -->
|
||||||
<div class="mode">
|
<div class="mode">
|
||||||
<div class="selection"><input type="radio" name="mode" value="Static" id="Static" data-bind="checked: mode" /><label for="Static">Static</label></div>
|
<div class="selection"><input type="radio" name="mode" value="Static" id="Static" data-bind="checked: mode" /><label for="Static">Static</label></div>
|
||||||
<div class="selection"><input type="radio" name="mode" value="Custom" id="Custom" data-bind="checked: mode" /><label for="Custom">Custom</label></div>
|
<div class="selection"><input type="radio" name="mode" value="Custom" id="Custom" data-bind="checked: mode" /><label for="Custom">Custom</label></div>
|
||||||
<div class="selection"><input type="radio" name="mode" value="Alternate" id="Alternate" data-bind="checked: mode" /><label for="Alternate">Alternating</label></div>
|
<div class="selection"><input type="radio" name="mode" value="Alternate" id="Alternate" data-bind="checked: mode" /><label for="Alternate">Alternating</label></div>
|
||||||
|
<!--
|
||||||
<div class="selection"><input type="radio" name="mode" value="Slide" id="Slide" data-bind="checked: mode" /><label for="Slide">Sliding</label></div>
|
<div class="selection"><input type="radio" name="mode" value="Slide" id="Slide" data-bind="checked: mode" /><label for="Slide">Sliding</label></div>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="parameters" data-bind="visible: mode() == 'Static'" style="display: none">
|
<div class="parameters" data-bind="visible: mode() == 'Static'" style="display: none">
|
||||||
<div class="header">Static parameters</div>
|
|
||||||
<div class="parameter">
|
<div class="parameter">
|
||||||
Brightness: <input type="range" min="0" max="4095" data-bind="value: static.brightness, valueUpdate: 'input'" /> <input type="number" min="0" max="4095" data-bind="value: static.brightness" />
|
Brightness: <input type="range" min="0" max="4095" data-bind="value: static.brightness, valueUpdate: 'input'" /> <input type="number" min="0" max="4095" data-bind="value: static.brightness" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="parameters" data-bind="visible: mode() == 'Custom'" style="display: none">
|
<div class="parameters" data-bind="visible: mode() == 'Custom'" style="display: none">
|
||||||
<div class="header">Custom parameters</div>
|
|
||||||
<!-- ko foreach: custom.brightness -->
|
<!-- ko foreach: custom.brightness -->
|
||||||
<div class="parameter">
|
<div class="parameter">
|
||||||
Step <span data-bind="text: $root.custom.brightness().length - $index()"></span>: <input type="range" min="0" max="4095" data-bind="value: $data.value, valueUpdate: 'input'" /> <input type="number" min="0" max="4095" data-bind="value: $data.value" />
|
Step <span data-bind="text: $root.custom.brightness().length - $index()"></span>: <input type="range" min="0" max="4095" data-bind="value: $data.value, valueUpdate: 'input'" /> <input type="number" min="0" max="4095" data-bind="value: $data.value" />
|
||||||
@ -45,7 +51,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="parameters" data-bind="visible: mode() == 'Alternate'" style="display: none">
|
<div class="parameters" data-bind="visible: mode() == 'Alternate'" style="display: none">
|
||||||
<div class="header">Alternating parameters</div>
|
|
||||||
<div class="parameter">
|
<div class="parameter">
|
||||||
Interval: <input type="number" data-bind="value: alternate.interval" />
|
Interval: <input type="number" data-bind="value: alternate.interval" />
|
||||||
</div>
|
</div>
|
||||||
@ -54,12 +59,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
<div class="parameters" data-bind="visible: mode() == 'Slide'" style="display: none">
|
<div class="parameters" data-bind="visible: mode() == 'Slide'" style="display: none">
|
||||||
<div class="header">Sliding parameters</div>
|
|
||||||
<div class="parameter">
|
<div class="parameter">
|
||||||
Brightness: <input type="range" min="0" max="4095" data-bind="value: slide.brightness, valueUpdate: 'input'" /> <input type="number" min="0" max="4095" data-bind="value: slide.brightness" />
|
Brightness: <input type="range" min="0" max="4095" data-bind="value: slide.brightness, valueUpdate: 'input'" /> <input type="number" min="0" max="4095" data-bind="value: slide.brightness" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
-->
|
||||||
|
<!-- /ko -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ko if:tab() == 'settings' -->
|
||||||
|
<!-- /ko -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ko if:tab() == 'update' -->
|
||||||
|
<div class="parameters">
|
||||||
|
<div class="parameter">
|
||||||
|
Host: <input type="text" data-bind="value: firmware.host" />
|
||||||
|
</div>
|
||||||
|
<div class="parameter">
|
||||||
|
Port: <input type="number" data-bind="value: firmware.port" min="1" max="65536" />
|
||||||
|
</div>
|
||||||
|
<div class="parameter">
|
||||||
|
Path: <input type="text" data-bind="value: firmware.path" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button data-bind="click: updateFirmware">Check for update</button>
|
||||||
|
</div>
|
||||||
|
<!-- /ko -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
@ -2,6 +2,8 @@ var StairsViewModel = function()
|
|||||||
{
|
{
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
|
self.tab = ko.observable('mode');
|
||||||
|
|
||||||
self.mode = ko.observable('Static');
|
self.mode = ko.observable('Static');
|
||||||
self.static =
|
self.static =
|
||||||
{
|
{
|
||||||
@ -27,6 +29,13 @@ var StairsViewModel = function()
|
|||||||
fadeOutTime: ko.observable(0)
|
fadeOutTime: ko.observable(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.firmware =
|
||||||
|
{
|
||||||
|
host: ko.observable(window.location.hostname),
|
||||||
|
port: ko.observable(window.location.port),
|
||||||
|
path: ko.observable('/checkUpdate')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
self.loading = ko.observable(true);
|
self.loading = ko.observable(true);
|
||||||
self.updatingFromServer = false;
|
self.updatingFromServer = false;
|
||||||
@ -129,6 +138,37 @@ var StairsViewModel = function()
|
|||||||
setTimeout(self.ping, 1000);
|
setTimeout(self.ping, 1000);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
self.setTab = function(tab)
|
||||||
|
{
|
||||||
|
self.tab(tab);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
self.updateFirmware = function()
|
||||||
|
{
|
||||||
|
$.ajax(
|
||||||
|
{
|
||||||
|
url: '/updateFirmware',
|
||||||
|
data: {
|
||||||
|
host: self.firmware.host(),
|
||||||
|
port: self.firmware.port(),
|
||||||
|
path: self.firmware.path()
|
||||||
|
},
|
||||||
|
dataType: 'json',
|
||||||
|
cache: false
|
||||||
|
})
|
||||||
|
.done(function(data)
|
||||||
|
{
|
||||||
|
alert('Update check running');
|
||||||
|
})
|
||||||
|
.fail(function()
|
||||||
|
{
|
||||||
|
alert('Could not check for firmware update');
|
||||||
|
});
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,3 +76,29 @@ body
|
|||||||
{
|
{
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tabs
|
||||||
|
{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tab
|
||||||
|
{
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
font-size: 14pt;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
border: solid 1px #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tab.active
|
||||||
|
{
|
||||||
|
background-color: #bce3ff;
|
||||||
|
border: solid 1px #0092ff;
|
||||||
|
}
|
4
web/version.js
Normal file
4
web/version.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
module.exports =
|
||||||
|
{
|
||||||
|
Version: "0.1.0+8"
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user