Merge branch 'master' of https://gitea.youb.fr/youen/vehicle-monitor
@ -18,4 +18,19 @@ namespace utils
|
||||
return (biggestValue - from) + to + 1;
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t elapsed(uint32_t from, uint32_t to)
|
||||
{
|
||||
if(to >= from)
|
||||
{
|
||||
return to - from;
|
||||
}
|
||||
else
|
||||
{
|
||||
// if the counter overflowed, this computes the real duration
|
||||
// of course it won't work if the counter made a "full turn" or more
|
||||
const uint32_t biggestValue = (uint32_t)-1;
|
||||
return (biggestValue - from) + to + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,12 +33,20 @@ const int8_t I2C_SCL = 4;
|
||||
const float wheelDiameterInches = 20;
|
||||
const int numImpulsesPerTurn = 2;
|
||||
const float wheelCircumferenceMeters = wheelDiameterInches * 0.0254f * 3.1415f / (float)numImpulsesPerTurn;
|
||||
const uint32_t wheelCircumferenceMillimeters = (uint32_t)(wheelCircumferenceMeters * 1000.0f + 0.5f);
|
||||
|
||||
uint16_t batteryVoltage = 0; // in mV
|
||||
uint16_t batteryOutputCurrent = 0; // in mV
|
||||
int16_t temperature = 0; // in tenth of °C
|
||||
int32_t altitude = 0; // in mm above sea level (can be negative if below sea level, or depending on atmospheric conditions)
|
||||
|
||||
// current trip
|
||||
uint32_t tripDistance = 0; // in meters
|
||||
uint16_t tripMovingTime = 0; // cumulated seconds, only when moving at non-zero speed
|
||||
uint16_t tripTotalTime = 0; // total trip time in seconds
|
||||
uint32_t tripAscendingElevation = 0; // cumulated ascending elevation, in millimeters
|
||||
uint32_t tripMotorEnergy = 0; // in Joules
|
||||
|
||||
WiFiMulti wifiMulti;
|
||||
wl_status_t wifi_STA_status = WL_NO_SHIELD;
|
||||
unsigned long wifiConnexionBegin = 0;
|
||||
@ -52,6 +60,7 @@ volatile bool speedSensorState = false;
|
||||
volatile unsigned long speedSensorRiseTime = 0;
|
||||
volatile unsigned long speedSensorLastImpulseTime = 0;
|
||||
volatile unsigned long speedSensorLastImpulseInterval = (unsigned long)-1; // in milliseconds
|
||||
volatile uint32_t speedSensorDistance = 0; // Cumulated measured distance, in millimeters. This value will overflow after about 4000km.
|
||||
void IRAM_ATTR onSpeedSensorChange(bool newState)
|
||||
{
|
||||
if(speedSensorState == newState) return;
|
||||
@ -78,16 +87,21 @@ void IRAM_ATTR onSpeedSensorChange(bool newState)
|
||||
{
|
||||
// too little time between impulses, probably some bouncing, ignore it
|
||||
}
|
||||
else if(timeSinceLastImpulse < 4000)
|
||||
{
|
||||
speedSensorLastImpulseTime = now;
|
||||
speedSensorLastImpulseInterval = timeSinceLastImpulse;
|
||||
}
|
||||
else
|
||||
{
|
||||
// too much time between impulses, can't compute speed from that
|
||||
speedSensorLastImpulseTime = now;
|
||||
speedSensorLastImpulseInterval = (unsigned long)-1;
|
||||
speedSensorDistance += wheelCircumferenceMillimeters;
|
||||
|
||||
if(timeSinceLastImpulse < 4000)
|
||||
{
|
||||
speedSensorLastImpulseTime = now;
|
||||
speedSensorLastImpulseInterval = timeSinceLastImpulse;
|
||||
}
|
||||
else
|
||||
{
|
||||
// too much time between impulses, can't compute speed from that
|
||||
speedSensorLastImpulseTime = now;
|
||||
speedSensorLastImpulseInterval = (unsigned long)-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -200,17 +214,23 @@ void setup()
|
||||
int v = batteryVoltage;
|
||||
int c = batteryOutputCurrent;
|
||||
int s = (int)(getSpeed() * 1000.0f + 0.5f);
|
||||
int t = temperature;
|
||||
int temp = temperature;
|
||||
int alt = altitude;
|
||||
|
||||
int td = tripDistance;
|
||||
int ttt = tripTotalTime;
|
||||
int tmt = tripMovingTime;
|
||||
int tae = tripAscendingElevation / 100; // convert mm to dm
|
||||
int tme = tripMotorEnergy / 360; // convert Joules to dWh (tenth of Wh)
|
||||
|
||||
const char* logFileName = DataLogger::get().currentLogFileName();
|
||||
if(String(logFileName).startsWith("/log/")) logFileName += 5;
|
||||
|
||||
int totalSize = (int)(SPIFFS.totalBytes() / 1000);
|
||||
int usedSize = (int)(SPIFFS.usedBytes() / 1000);
|
||||
|
||||
char json[128];
|
||||
sprintf(json, "{\"v\":%d,\"c\":%d,\"s\":%d,\"t\":%d,\"alt\":%d,\"log\":\"%s\",\"tot\":%d,\"used\":%d}", v, c, s, t, alt, logFileName, totalSize, usedSize);
|
||||
char json[256];
|
||||
sprintf(json, "{\"v\":%d,\"c\":%d,\"s\":%d,\"td\":%d,\"ttt\":%d,\"tmt\":%d,\"tae\":%d,\"tme\":%d,\"temp\":%d,\"alt\":%d,\"log\":\"%s\",\"tot\":%d,\"used\":%d}", v, c, s, td, ttt, tmt, tae, tme, temp, alt, logFileName, totalSize, usedSize);
|
||||
request->send(200, "text/json", json);
|
||||
});
|
||||
|
||||
@ -399,6 +419,10 @@ void loop()
|
||||
handle_pressure_measure(); // also measures temperature
|
||||
|
||||
unsigned long now = millis();
|
||||
static unsigned long lastLoopMillis = now;
|
||||
unsigned long dt = utils::elapsed(lastLoopMillis, now);
|
||||
lastLoopMillis = now;
|
||||
|
||||
static DataLogger::Entry entry;
|
||||
entry.batteryVoltage = (float)batteryVoltage / 1000.0f;
|
||||
entry.batteryOutputCurrent = (float)batteryOutputCurrent / 1000.0f;
|
||||
@ -413,6 +437,11 @@ void loop()
|
||||
{
|
||||
DebugLog.println("Starting DataLogger");
|
||||
DataLogger::get().open();
|
||||
tripDistance = 0;
|
||||
tripMovingTime = 0;
|
||||
tripTotalTime = 0;
|
||||
tripAscendingElevation = 0;
|
||||
tripMotorEnergy = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
@ -432,5 +461,41 @@ void loop()
|
||||
}
|
||||
DataLogger::get().log(now, entry);
|
||||
|
||||
delay(DataLogger::get().isOpen() ? 10 : 1000);
|
||||
bool isOnTrip = DataLogger::get().isOpen();
|
||||
bool isMoving = entry.speed > 0.0f;
|
||||
|
||||
static unsigned long cumulatedMillis = 0;
|
||||
cumulatedMillis += dt;
|
||||
unsigned long newSeconds = cumulatedMillis / 1000;
|
||||
cumulatedMillis -= newSeconds * 1000;
|
||||
|
||||
uint32_t currentMillimeters = speedSensorDistance;
|
||||
static uint32_t lastLoopMillimeters = currentMillimeters;
|
||||
uint32_t newMillimeters = utils::elapsed(lastLoopMillimeters, currentMillimeters);
|
||||
lastLoopMillimeters = currentMillimeters;
|
||||
|
||||
static uint32_t cumulatedMillimeters = 0;
|
||||
cumulatedMillimeters += newMillimeters;
|
||||
uint32_t newMeters = cumulatedMillimeters / 1000;
|
||||
cumulatedMillimeters -= newMeters * 1000;
|
||||
|
||||
uint32_t altitudeMillimeters = (uint32_t)(entry.altitude * 1000.0f + 0.5f);
|
||||
static uint32_t lastLoopAltitude = altitudeMillimeters;
|
||||
uint32_t altitudeChange = altitudeMillimeters - lastLoopAltitude;
|
||||
lastLoopAltitude = altitudeMillimeters;
|
||||
|
||||
if(isOnTrip)
|
||||
{
|
||||
tripTotalTime += newSeconds;
|
||||
if(isMoving) tripMovingTime += newSeconds;
|
||||
tripDistance += newMeters;
|
||||
|
||||
if(altitudeChange > 0)
|
||||
tripAscendingElevation += altitudeChange;
|
||||
|
||||
uint32_t newEnergy = entry.batteryVoltage * entry.batteryOutputCurrent * ((float)dt / 1000.0f);
|
||||
tripMotorEnergy += newEnergy;
|
||||
}
|
||||
|
||||
delay(isOnTrip ? 10 : 1000);
|
||||
}
|
||||
|
46
README.md
@ -1,3 +1,45 @@
|
||||
# vehicle-monitor
|
||||
vehicle-monitor is a monitoring system for electric vehicles (log various sensors, such as consumed power, solar production, speed, slope, etc.)
|
||||
|
||||
Monitoring system for electric vehicles (log various sensors, such as consumed power, solar production, speed, slope, apparent wind, etc.)
|
||||
# Architecture
|
||||
|
||||
While nothing is very complicated in the vehicle-monitor, it does require knowledge in different domains to understand how it works, and be able to build your own, and especially if you want to modify it. Everything is well documented on the internet (Arduino, ESP32, the used libraries, mithril.js, web development, etc.), and a lot of people from different communities can help you, but be aware that if you are a beginner, you will need time to learn everything that is needed to tinker with this project.
|
||||
|
||||
The system is made of an [ESP32 microcontroller](https://en.wikipedia.org/wiki/ESP32) connected to various sensors. The ESP32 also has an integrated wifi interface, which is used to host a web server. Using any web browser, typically from a smartphone or tablet (also works from PC), the user can then connect to the ESP32 to display the graphical interface and interact with it.
|
||||
|
||||
## ESP32 (hardware)
|
||||
|
||||
The main electronic circuit, showing how sensors are connected to the ESP32 is described in folder `schema/MCU_board`, in [Kicad](https://www.kicad.org/) format. Some sensors are connected to the `I2C` port, this is not shown on this schema (it is possible to link multiple sensors on the same `I2C` port).
|
||||
|
||||
## ESP32 (software)
|
||||
|
||||
The microcontroller is programmed in C++, using the Arduino framework (but keep in mind we target an ESP32 microcontroller,whose capabilities are quite different from the ATmega328P used on Arduino Uno boards).
|
||||
|
||||
The web server is implemented using the [ESPAsyncWebServer](https://github.com/me-no-dev/ESPAsyncWebServer) library. While the ESP32 hosts the entirety of the web app, most of it are just static files (a single HTML file, which links to CSS and javascript resources). The dynamic part is very simple and consists of a few web services used to retrieve data in JSON format.
|
||||
|
||||
## Web app
|
||||
|
||||
The user interface is implemented as a `single page application`, using the [mithril.js](https://mithril.js.org/) framework.
|
||||
|
||||
# Building
|
||||
|
||||
## Web app
|
||||
|
||||
The web application consists of several files in the `WebApp/src` folder, which are packaged to three files which are generated in the `WebApp/www` folder using [webpack](https://webpack.js.org/).
|
||||
|
||||
Webpack is based on `node.js`, so the first step is to [install node.js](https://nodejs.org/en/) (you should select the "LTS" latest version).
|
||||
|
||||
Once `node.js` is installed, go in folder `vehicle-monitor/WebApp` and run the command `npm install`. This should download all the dependencies needed to build the web app. You need to do this only once.
|
||||
|
||||
Then execute `npm run build` to build the web app. This should generate new files in `vehicle-monitor/WebApp/www`. If you want to work on the code, you can also use `npm run dev` which will generate the files, and then will keep listening for code changes. Each time you save a source file, it will automatically rebuild.
|
||||
|
||||
Now the web app has been built, you can open `vehicle-monitor/WebApp/www/index.html` with you preferred web browser, and this should display the user interface in `test mode`. In this mode, the user interface will display fake data, since it is not connected to any real device and can't collect data from real sensors.
|
||||
|
||||
## ESP32
|
||||
|
||||
This project has been developped using [PlatformIO](https://platformio.org/) to compile the code. It may or may not be possible to build it in the Arduino IDE, this has not been tested. The first step is to install PlatformIO. You have several choices to use PlatfomIO, ranging from command line to various IDE integrations. The command line interface will be described here for simplicity, but you should consider using an IDE if you want to work on the code. Here are the instructions to [install PlatformIO for command line usage](https://docs.platformio.org/en/latest/core/installation.html).
|
||||
|
||||
Once installed, go in folder `vehicle-monitor/ESP32` and run the command `pio run`. This should compile the program. However, it should complain that the file `vehicle-monitor/ESP32/src/wifi-credentials.h` is missing. You have to copy the existing file `wifi-credentials.h.template` and rename it to `wifi-credentials.h`, and modify its contents to configure your wifi network (details are documented in the file itself). Once this is done, execute `pio run` again, and if everything goes smoothly, this time it should work.
|
||||
|
||||
You can upload to the ESP32 chip with the command `pio run -t upload`. Please refer to the PlatformIO documentation to learn how to configure the connection to your ESP32.
|
||||
|
||||
You will also need to upload some files to the ESP32 file system (SPIFFS). These files are stored in `vehicle-monitor/ESP32/data`, and the subfolder `www` is actually a symlink to the web app you built in the previous section. To upload the files, run `pio run -t uploadfs`. Again, if needed, refer to the PlatformIO documentation for details.
|
BIN
WebApp/doc/altitude.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
WebApp/doc/bike.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
WebApp/doc/dashboard.png
Normal file
After Width: | Height: | Size: 106 KiB |
BIN
WebApp/doc/distance.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
WebApp/doc/electricity.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
WebApp/doc/temperature.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
WebApp/doc/time.png
Normal file
After Width: | Height: | Size: 6.0 KiB |
@ -19,6 +19,7 @@
|
||||
"purgecss-webpack-plugin": "^4.1.3",
|
||||
"style-loader": "^3.3.1",
|
||||
"ts-loader": "^9.2.8",
|
||||
"tsconfig-paths-webpack-plugin": "^3.5.2",
|
||||
"typescript": "^4.6.2",
|
||||
"webpack": "^5.70.0",
|
||||
"webpack-cli": "^4.9.2"
|
||||
|
@ -1,8 +1,8 @@
|
||||
import m from 'mithril';
|
||||
|
||||
import Layout from './layout';
|
||||
import RawDataPage from './raw-data-page';
|
||||
import DashboardPage from './dashboard-page';
|
||||
import { RawDataPage } from 'pages/raw-data/raw-data-page';
|
||||
import { DashboardPage } from 'pages/dashboard/dashboard-page';
|
||||
|
||||
require('../node_modules/bulma/css/bulma.css');
|
||||
|
||||
|
1
WebApp/src/assets.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module "*.png";
|
BIN
WebApp/src/assets/bike.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
WebApp/src/assets/icons/altitude.png
Normal file
After Width: | Height: | Size: 767 B |
BIN
WebApp/src/assets/icons/ascending-elevation.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
WebApp/src/assets/icons/average.png
Normal file
After Width: | Height: | Size: 1.0 KiB |
BIN
WebApp/src/assets/icons/distance-electricity.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
WebApp/src/assets/icons/distance.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
WebApp/src/assets/icons/electricity.png
Normal file
After Width: | Height: | Size: 602 B |
BIN
WebApp/src/assets/icons/temperature.png
Normal file
After Width: | Height: | Size: 385 B |
BIN
WebApp/src/assets/icons/time.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
23
WebApp/src/components/component.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import m from 'mithril';
|
||||
|
||||
export abstract class Component {
|
||||
abstract view(vnode: m.Vnode): m.Children;
|
||||
|
||||
constructor(vnode?: m.Vnode) {
|
||||
|
||||
}
|
||||
|
||||
oninit(vnode: m.Vnode) {}
|
||||
|
||||
oncreate(vnode: m.Vnode) {}
|
||||
|
||||
onbeforeupdate(newVnode: m.Vnode, oldVnode: m.Vnode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
onupdate(vnode: m.Vnode) {}
|
||||
|
||||
onbeforeremove(vnode: m.Vnode): Promise<void> | void {}
|
||||
|
||||
onremove(vnode: m.Vnode) {}
|
||||
}
|
5
WebApp/src/components/page.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Component } from 'components/component';
|
||||
|
||||
export abstract class Page extends Component {
|
||||
|
||||
}
|
3
WebApp/src/components/widgets/chronometer.css
Normal file
@ -0,0 +1,3 @@
|
||||
div.widget.chronometer span.integral-value {
|
||||
font-size: 2.0rem;
|
||||
}
|
54
WebApp/src/components/widgets/chronometer.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { NumericValue } from 'components/widgets/numeric-value';
|
||||
|
||||
require('./chronometer.css');
|
||||
|
||||
export class Chronometer extends NumericValue {
|
||||
private realTimeValue = 0;
|
||||
private realTimeReference = 0;
|
||||
|
||||
private animating = false;
|
||||
private shuttingDown = false;
|
||||
|
||||
protected mainClassName() { return 'numeric-value chronometer'; }
|
||||
|
||||
protected onValueChange(value: number) {
|
||||
let now = Date.now() / 1000;
|
||||
|
||||
let realTimeValue = this.realTimeValue + (now - this.realTimeReference);
|
||||
|
||||
if(Math.abs(value - realTimeValue) < 2.0)
|
||||
return;
|
||||
|
||||
this.realTimeValue = value;
|
||||
this.realTimeReference = now;
|
||||
realTimeValue = value;
|
||||
this.startAnimation();
|
||||
}
|
||||
|
||||
private startAnimation() {
|
||||
if(this.animating)
|
||||
return;
|
||||
this.animating = true;
|
||||
this.animate();
|
||||
}
|
||||
|
||||
onbeforeremove(vnode: any) {
|
||||
this.shuttingDown = true;
|
||||
super.onbeforeremove(vnode);
|
||||
}
|
||||
|
||||
private animate() {
|
||||
if(this.shuttingDown) return;
|
||||
|
||||
let now = Date.now() / 1000;
|
||||
let realTimeValue = this.realTimeValue + (now - this.realTimeReference);
|
||||
|
||||
let hours = Math.floor(realTimeValue / 3600);
|
||||
let minutes = Math.floor((realTimeValue - hours * 3600)/60);
|
||||
let seconds = Math.floor(realTimeValue - hours * 3600 - minutes * 60);
|
||||
|
||||
this.integralValueElement.innerText = (hours < 10 ? '0' : '') + hours + ':' + (minutes < 10 ? '0' : '') + minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
|
||||
|
||||
setTimeout(() => this.animate(), (Math.ceil(realTimeValue) + 0.05 - realTimeValue) * 1000);
|
||||
}
|
||||
}
|
15
WebApp/src/components/widgets/clock.css
Normal file
@ -0,0 +1,15 @@
|
||||
div.clock {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.clock div.date {
|
||||
display: inline-block;
|
||||
text-aligne: center;
|
||||
}
|
||||
|
||||
div.clock p.time {
|
||||
display: inline-block;
|
||||
font-family: monospace;
|
||||
font-size: 3.5rem;
|
||||
margin-left: 0.8rem;
|
||||
}
|
25
WebApp/src/components/widgets/clock.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import m from 'mithril';
|
||||
import { Widget } from 'components/widgets/widget';
|
||||
|
||||
require("./clock.css");
|
||||
|
||||
export class Clock extends Widget {
|
||||
constructor(vnode: any) {
|
||||
super(vnode);
|
||||
}
|
||||
|
||||
view(vnode: m.Vnode<{}, {}>): m.Children {
|
||||
var now = new Date();
|
||||
var days = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
|
||||
var nextUpdateDelay = 61 - now.getSeconds();
|
||||
setTimeout(() => m.redraw(), nextUpdateDelay * 1000);
|
||||
|
||||
return <div class="widget clock">
|
||||
<div class="date">
|
||||
<p class="day">{days[now.getDay()]}</p>
|
||||
<p class="date">{('0'+now.getDate()).slice(-2) + '/' + ('0'+now.getMonth()).slice(-2)}</p>
|
||||
</div>
|
||||
<p class="time">{('0'+now.getHours()).slice(-2) + ':' + ('0'+now.getMinutes()).slice(-2)}</p>
|
||||
</div>;
|
||||
}
|
||||
}
|
27
WebApp/src/components/widgets/gauge-battery.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import m from 'mithril';
|
||||
import { GaugeLinear } from 'components/widgets/gauge-linear';
|
||||
|
||||
export class GaugeBattery extends GaugeLinear {
|
||||
constructor(vnode: any) {
|
||||
super(vnode);
|
||||
this.svgPaddingTop = 0.06;
|
||||
}
|
||||
|
||||
createSvg(svgElement: SVGElement) {
|
||||
super.createSvg(svgElement);
|
||||
|
||||
let w = Math.round(svgElement.clientWidth);
|
||||
let h = Math.round(svgElement.clientHeight);
|
||||
let paddingTop = Math.round(svgElement.clientHeight * this.svgPaddingTop);
|
||||
|
||||
const batteryTopWidth = 0.5;
|
||||
|
||||
let batteryTop = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
batteryTop.setAttribute('width', (w * batteryTopWidth).toFixed(1));
|
||||
batteryTop.setAttribute('height', (paddingTop).toString());
|
||||
batteryTop.setAttribute('x', (w * (1.0 - batteryTopWidth)*0.5).toFixed(1));
|
||||
batteryTop.setAttribute('y', '0');
|
||||
batteryTop.classList.add('gauge-bg');
|
||||
svgElement.append(batteryTop);
|
||||
}
|
||||
}
|
9
WebApp/src/components/widgets/gauge-circular.css
Normal file
@ -0,0 +1,9 @@
|
||||
div.gauge svg.gauge-circular path.gauge-bg-outline {
|
||||
stroke: rgb(0,0,0);
|
||||
fill: none;
|
||||
}
|
||||
|
||||
div.gauge svg.gauge-circular path.gauge-bg {
|
||||
stroke: rgb(255,255,255);
|
||||
fill: none;
|
||||
}
|
94
WebApp/src/components/widgets/gauge-circular.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import m from 'mithril';
|
||||
import { Gauge } from 'components/widgets/gauge';
|
||||
import { Pipe } from 'utilities/pipe';
|
||||
|
||||
require('./gauge-circular.css');
|
||||
|
||||
export class GaugeCircular extends Gauge {
|
||||
constructor(vnode: any) {
|
||||
super(vnode);
|
||||
}
|
||||
|
||||
createSvg(svgElement: SVGElement) {
|
||||
let w = Math.round(svgElement.clientWidth);
|
||||
let h = Math.round(svgElement.clientHeight);
|
||||
|
||||
svgElement.classList.add('gauge-circular');
|
||||
|
||||
svgElement.setAttribute('viewBox', "0 0 "+w+" "+h);
|
||||
|
||||
let svgRound = (v: number) => Math.round(v * 100.0) / 100.0;
|
||||
|
||||
const arcRatio = 0.75; // from 0.5 for half circle to 1.0 for full circle
|
||||
const gaugeWidthRatio = 0.15; // relative to the smallest dimension of the drawing area
|
||||
const lineThickness = 1;
|
||||
|
||||
let gaugeWidth = Math.round(Math.min(w, h) * gaugeWidthRatio);
|
||||
let gaugeRadius = Math.round(Math.min(w, h) * 0.5 - gaugeWidth * 0.5 - lineThickness - 1);
|
||||
let cx = Math.round(w * 0.5); let cy = Math.round(h * 0.5);
|
||||
|
||||
let openingHalfAngle = Math.PI * (1.0 - arcRatio);
|
||||
let arcLength = Math.PI * 2.0 * gaugeRadius * arcRatio;
|
||||
let startx = svgRound(cx - Math.sin(openingHalfAngle) * gaugeRadius);
|
||||
let starty = svgRound(cy + Math.cos(openingHalfAngle) * gaugeRadius);
|
||||
let dx = svgRound(2.0 * Math.sin(openingHalfAngle) * gaugeRadius);
|
||||
|
||||
let outlineArcRatio = (arcLength + lineThickness*2.0) / (Math.PI * 2.0 * gaugeRadius);
|
||||
let outlineOpeningHalfAngle = Math.PI * (1.0 - outlineArcRatio);
|
||||
let outlineStartx = svgRound(cx - Math.sin(outlineOpeningHalfAngle) * gaugeRadius);
|
||||
let outlineStarty = svgRound(cy + Math.cos(outlineOpeningHalfAngle) * gaugeRadius);
|
||||
let outlineDx = svgRound(2.0 * Math.sin(outlineOpeningHalfAngle) * gaugeRadius);
|
||||
|
||||
let bgOutline = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
bgOutline.setAttribute('d', 'M'+outlineStartx+' '+outlineStarty+' a'+gaugeRadius+' '+gaugeRadius+' 0 1 1 '+outlineDx+' 0');
|
||||
bgOutline.setAttribute('style', 'stroke-width: '+(gaugeWidth+lineThickness*2));
|
||||
bgOutline.classList.add('gauge-bg-outline');
|
||||
svgElement.append(bgOutline);
|
||||
|
||||
let bg = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
bg.setAttribute('d', 'M'+startx+' '+starty+' a'+gaugeRadius+' '+gaugeRadius+' 0 1 1 '+dx+' 0');
|
||||
bg.setAttribute('style', 'stroke-width: '+(gaugeWidth));
|
||||
bg.classList.add('gauge-bg');
|
||||
svgElement.append(bg);
|
||||
|
||||
let barHeight = 10;
|
||||
const barGap = barHeight * 0.2;
|
||||
let numBars = Math.round(arcLength / (barHeight + barGap));
|
||||
|
||||
barHeight = (arcLength - (numBars+1)*barGap) / numBars;
|
||||
|
||||
let computeCircleCoords = function(d: number, r: number, out: {x: number, y: number}) {
|
||||
let a = outlineOpeningHalfAngle + (2.0 * Math.PI - outlineOpeningHalfAngle * 2.0) * (d / arcLength);
|
||||
out.x = svgRound(cx - Math.sin(a) * r);
|
||||
out.y = svgRound(cy + Math.cos(a) * r);
|
||||
};
|
||||
|
||||
let points: {x: number, y: number}[] = [];
|
||||
for(let idx = 0; idx < 4; ++idx) { points[idx] = {x: 0, y: 0}; }
|
||||
for(let barIdx = 0; barIdx < numBars; ++barIdx) {
|
||||
let bar = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
bar.classList.add('gauge-bar');
|
||||
bar.classList.toggle('lit', false);
|
||||
|
||||
let barStartLen = barGap + barIdx * (barHeight + barGap);
|
||||
let barEndLen = barStartLen + barHeight;
|
||||
computeCircleCoords(barStartLen, gaugeRadius - gaugeWidth*0.5 + barGap, points[0]);
|
||||
computeCircleCoords(barStartLen, gaugeRadius + gaugeWidth*0.5 - barGap, points[1]);
|
||||
computeCircleCoords(barEndLen, gaugeRadius + gaugeWidth*0.5 - barGap, points[2]);
|
||||
computeCircleCoords(barEndLen, gaugeRadius - gaugeWidth*0.5 + barGap, points[3]);
|
||||
|
||||
bar.setAttribute('d', 'M'+points[0].x+' '+points[0].y+' L'+points[1].x+' '+points[1].y+' L'+points[2].x+' '+points[2].y+' L'+points[3].x+' '+points[3].y+' Z');
|
||||
|
||||
svgElement.append(bar);
|
||||
|
||||
this.bars.push(bar);
|
||||
}
|
||||
}
|
||||
|
||||
oncreate(vnode: any) {
|
||||
let svgElement: SVGElement = vnode.dom.querySelector('svg');
|
||||
this.createSvg(svgElement);
|
||||
|
||||
super.oncreate(vnode);
|
||||
}
|
||||
}
|
0
WebApp/src/components/widgets/gauge-linear.css
Normal file
68
WebApp/src/components/widgets/gauge-linear.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import m from 'mithril';
|
||||
import { Gauge } from 'components/widgets/gauge';
|
||||
import { Pipe } from 'utilities/pipe';
|
||||
|
||||
require('./gauge-linear.css');
|
||||
|
||||
export class GaugeLinear extends Gauge {
|
||||
protected svgPaddingTop = 0.0;
|
||||
private bottomWidth: number;
|
||||
private topWidth: number;
|
||||
|
||||
constructor(vnode: any) {
|
||||
super(vnode);
|
||||
|
||||
this.bottomWidth = vnode.attrs.bottomWidth || 1.0;
|
||||
this.topWidth = vnode.attrs.topWidth || 1.0;
|
||||
}
|
||||
|
||||
createSvg(svgElement: SVGElement) {
|
||||
let w = Math.round(svgElement.clientWidth);
|
||||
let h = Math.round(svgElement.clientHeight);
|
||||
let paddingTop = Math.round(svgElement.clientHeight * this.svgPaddingTop);
|
||||
|
||||
svgElement.setAttribute('viewBox', "0 0 "+w+" "+h);
|
||||
|
||||
let svgRound = (v: number) => Math.round(v * 100.0) / 100.0;
|
||||
|
||||
let bg = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
//bg.setAttribute('width', w.toString());
|
||||
//bg.setAttribute('height', (h - paddingTop).toString());
|
||||
//bg.setAttribute('y', paddingTop.toString());
|
||||
bg.setAttribute('d', 'M0,'+paddingTop+' L'+svgRound(w*this.topWidth)+','+paddingTop+' L'+svgRound(w*this.bottomWidth)+','+h+' L0,'+h+' Z');
|
||||
bg.classList.add('gauge-bg');
|
||||
svgElement.append(bg);
|
||||
|
||||
let barHeight = 10;
|
||||
const barGap = barHeight * 0.2;
|
||||
let numBars = Math.round((h - paddingTop) / (barHeight + barGap));
|
||||
|
||||
let bh = ((h - paddingTop) - (numBars+1)*barGap) / numBars;
|
||||
barHeight = Math.round(bh);
|
||||
|
||||
for(let barIdx = 0; barIdx < numBars; ++barIdx) {
|
||||
let bar = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
let barW = w * ((barGap + barIdx * (bh+barGap))/(h - paddingTop) * (this.topWidth - this.bottomWidth) + this.bottomWidth);
|
||||
let barW1 = w * ((barGap + barIdx * (bh+barGap) + bh)/(h - paddingTop) * (this.topWidth - this.bottomWidth) + this.bottomWidth);
|
||||
barW = Math.min(barW, barW1);
|
||||
bar.setAttribute('width', (barW - barGap * 2).toString());
|
||||
bar.setAttribute('height', barHeight.toString());
|
||||
bar.classList.add('gauge-bar');
|
||||
bar.classList.toggle('lit', false);
|
||||
|
||||
bar.setAttribute('x', barGap.toString());
|
||||
bar.setAttribute('y', (h - barIdx * (bh+barGap) - barGap - barHeight).toString());
|
||||
|
||||
svgElement.append(bar);
|
||||
|
||||
this.bars.push(bar);
|
||||
}
|
||||
}
|
||||
|
||||
oncreate(vnode: any) {
|
||||
let svgElement: SVGElement = vnode.dom.querySelector('svg');
|
||||
this.createSvg(svgElement);
|
||||
|
||||
super.oncreate(vnode);
|
||||
}
|
||||
}
|
27
WebApp/src/components/widgets/gauge.css
Normal file
@ -0,0 +1,27 @@
|
||||
div.gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.gauge svg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gauge-bg {
|
||||
fill: rgb(255,255,255);
|
||||
stroke-width: 2;
|
||||
stroke: rgb(0,0,0)
|
||||
}
|
||||
|
||||
div.gauge .gauge-bar {
|
||||
fill: rgb(230,230,230);
|
||||
stroke-width: 1;
|
||||
stroke: rgb(180,180,180)
|
||||
}
|
||||
|
||||
div.gauge .gauge-bar.lit {
|
||||
fill: rgb(180,180,180);
|
||||
stroke-width: 1;
|
||||
stroke: rgb(0,0,0)
|
||||
}
|
105
WebApp/src/components/widgets/gauge.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import m from 'mithril';
|
||||
import { NumericValue } from 'components/widgets/numeric-value';
|
||||
import { Pipe } from 'utilities/pipe';
|
||||
|
||||
require('./gauge.css');
|
||||
|
||||
export class Gauge extends NumericValue {
|
||||
protected bars: SVGGeometryElement[] = [];
|
||||
|
||||
private displayedValue = 0.0;
|
||||
private targetValue = 0.0;
|
||||
private valueChangeRate = 0.0;
|
||||
private litBars = 0;
|
||||
|
||||
private animating = false;
|
||||
private shuttingDown = false;
|
||||
|
||||
private lastGaugeUpdate = 0.0;
|
||||
private lastAnimationTick = 0.0;
|
||||
|
||||
constructor(vnode: any) {
|
||||
super(vnode);
|
||||
}
|
||||
|
||||
onbeforeremove(vnode: m.Vnode<{}, {}>) {
|
||||
this.shuttingDown = true;
|
||||
super.onbeforeremove(vnode);
|
||||
}
|
||||
|
||||
protected mainClassName() { return 'gauge'; }
|
||||
|
||||
protected graphicalRepresentation(vnode: m.Vnode<{}, {}>): m.Children {
|
||||
return <svg></svg>;
|
||||
}
|
||||
|
||||
protected onValueChange(value: number) {
|
||||
let now = Date.now() / 1000.0;
|
||||
|
||||
super.onValueChange(value);
|
||||
|
||||
let ratio = value / (this.maxValue || 1.0);
|
||||
let numBars = this.bars.length;
|
||||
let threshold = 0.33 / numBars;
|
||||
|
||||
if(ratio > this.targetValue + threshold || ratio < this.targetValue - threshold) {
|
||||
let animationTime = this.lastGaugeUpdate == 0.0 ? 0.0001 : Math.max(0.0001, Math.min(0.75, now - this.lastGaugeUpdate));
|
||||
|
||||
this.valueChangeRate = (ratio - this.displayedValue) / animationTime;
|
||||
this.targetValue = ratio;
|
||||
|
||||
this.enableAnimation();
|
||||
}
|
||||
|
||||
this.lastGaugeUpdate = now;
|
||||
}
|
||||
|
||||
private enableAnimation() {
|
||||
if(this.animating) return;
|
||||
this.animating = true;
|
||||
this.lastAnimationTick = Date.now() / 1000.0 - 0.016;
|
||||
requestAnimationFrame(() => this.animate());
|
||||
}
|
||||
|
||||
private animate() {
|
||||
if(this.shuttingDown) return;
|
||||
|
||||
if(this.valueChangeRate == 0.0) {
|
||||
this.animating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.animate());
|
||||
|
||||
let now = Date.now() / 1000.0;
|
||||
if(now > this.lastAnimationTick + 0.75) {
|
||||
this.displayedValue = this.targetValue;
|
||||
this.valueChangeRate = 0.0;
|
||||
}
|
||||
else {
|
||||
let dt = now - this.lastAnimationTick;
|
||||
if(dt < 0.04) return; // limit framerate to save battery
|
||||
|
||||
this.displayedValue += this.valueChangeRate * dt;
|
||||
if((this.displayedValue > this.targetValue) == (this.valueChangeRate > 0.0)) {
|
||||
this.displayedValue = this.targetValue;
|
||||
this.valueChangeRate = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastAnimationTick = now;
|
||||
|
||||
let numBars = this.bars.length;
|
||||
let litBars = Math.round(this.displayedValue * numBars);
|
||||
if(litBars == this.litBars)
|
||||
return;
|
||||
this.litBars = litBars;
|
||||
|
||||
for(let barIdx = 0; barIdx < numBars; ++barIdx) {
|
||||
let bar = this.bars[barIdx];
|
||||
let isLit = barIdx < litBars;
|
||||
if(bar.classList.contains('lit') != isLit)
|
||||
bar.classList.toggle('lit', isLit);
|
||||
}
|
||||
}
|
||||
}
|
37
WebApp/src/components/widgets/numeric-value.css
Normal file
@ -0,0 +1,37 @@
|
||||
div.widget span.integral-value {
|
||||
font-size: 2.2rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
div.widget span.decimal-value {
|
||||
font-size: 1.6rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
div.widget span.unit {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
div.widget div.value-icon {
|
||||
display: inline-block;
|
||||
height: 2.3rem;
|
||||
width: 4rem;
|
||||
text-align: center;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
|
||||
div.widget div.value-icon img {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
top: 0.2rem;
|
||||
}
|
||||
|
||||
div.numeric-value {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
div.numeric-value span.unit {
|
||||
font-size: 1.1rem;
|
||||
margin-left: 0.2rem;
|
||||
}
|
72
WebApp/src/components/widgets/numeric-value.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import m from 'mithril';
|
||||
import { Widget } from 'components/widgets/widget';
|
||||
import { Pipe } from 'utilities/pipe';
|
||||
|
||||
require('./numeric-value.css');
|
||||
|
||||
export class NumericValue extends Widget {
|
||||
protected value: Pipe<number>;
|
||||
protected minValue?: number;
|
||||
protected maxValue?: number;
|
||||
protected unit: string;
|
||||
protected decimals: number;
|
||||
|
||||
protected icon?: string;
|
||||
|
||||
protected integralValueElement: HTMLElement;
|
||||
protected decimalValueElement: HTMLElement;
|
||||
|
||||
constructor(vnode: any) {
|
||||
super(vnode);
|
||||
|
||||
this.value = vnode.attrs.value || new Pipe(0.0);
|
||||
this.minValue = vnode.attrs.minValue || null;
|
||||
this.maxValue = vnode.attrs.maxValue || null;
|
||||
this.unit = vnode.attrs.unit || '';
|
||||
this.decimals = vnode.attrs.decimals || 0;
|
||||
|
||||
this.icon = vnode.attrs.icon || null;
|
||||
|
||||
this.value.onChange(() => this.onValueChange(this.getClampedValue()));
|
||||
}
|
||||
|
||||
view(vnode: m.Vnode<{}, {}>): m.Children {
|
||||
return <div class={'widget ' + this.mainClassName()} style={'flex: ' + this.widgetWidth}>
|
||||
<p>
|
||||
{this.icon ? <div class="value-icon"><img src={this.icon}></img></div> : null}
|
||||
<span class="integral-value"></span>
|
||||
<span class="decimal-value"></span>
|
||||
<span class="unit">{this.unit}</span>
|
||||
</p>
|
||||
{this.graphicalRepresentation(vnode)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
protected mainClassName() { return 'numeric-value'; }
|
||||
|
||||
protected graphicalRepresentation(vnode: m.Vnode<{}, {}>): m.Children {
|
||||
return [];
|
||||
}
|
||||
|
||||
private getClampedValue() {
|
||||
let value = this.value.get();
|
||||
if(this.minValue !== null) value = Math.max(value, this.minValue);
|
||||
if(this.maxValue !== null) value = Math.min(value, this.maxValue);
|
||||
return value;
|
||||
}
|
||||
|
||||
protected onValueChange(newValue: number) {
|
||||
let valueStr = newValue.toFixed(this.decimals);
|
||||
let parts = valueStr.split('.');
|
||||
|
||||
this.integralValueElement.innerText = parts[0];
|
||||
this.decimalValueElement.innerText = this.decimals > 0 ? '.' + parts[1] : '';
|
||||
}
|
||||
|
||||
oncreate(vnode: any) {
|
||||
this.integralValueElement = vnode.dom.querySelector('span.integral-value');
|
||||
this.decimalValueElement = vnode.dom.querySelector('span.decimal-value');
|
||||
|
||||
this.onValueChange(this.getClampedValue());
|
||||
}
|
||||
}
|
11
WebApp/src/components/widgets/widget.css
Normal file
@ -0,0 +1,11 @@
|
||||
div.widgets-row {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
div.widget {
|
||||
flex: 1;
|
||||
margin: 0.2rem;
|
||||
}
|
19
WebApp/src/components/widgets/widget.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import m from 'mithril';
|
||||
import { Component } from 'components/component'
|
||||
|
||||
require('./widget.css');
|
||||
|
||||
interface WidgetAttrs
|
||||
{
|
||||
widgetWidth: number;
|
||||
}
|
||||
|
||||
export abstract class Widget extends Component {
|
||||
public widgetWidth = 1.0;
|
||||
|
||||
constructor(vnode?: m.Vnode<WidgetAttrs>) {
|
||||
super(vnode);
|
||||
if(vnode.attrs.widgetWidth !== undefined)
|
||||
this.widgetWidth = vnode.attrs.widgetWidth;
|
||||
}
|
||||
};
|
@ -1,29 +0,0 @@
|
||||
import m from 'mithril';
|
||||
import { MonitorApi, Status } from './monitor-api';
|
||||
|
||||
export default class DashboardPage {
|
||||
status: Status = null;
|
||||
autoRefresh = true;
|
||||
|
||||
oninit() {
|
||||
this.status = MonitorApi.get().getStatus();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
onbeforeremove() {
|
||||
this.autoRefresh = false;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.status = await MonitorApi.get().fetchStatus();
|
||||
if(this.autoRefresh)
|
||||
setTimeout(() => { if(this.autoRefresh) this.refresh(); }, 500);
|
||||
}
|
||||
|
||||
view() {
|
||||
return this.status
|
||||
? <div class="dashboard-page">
|
||||
</div>
|
||||
: <p>Chargement...</p>;
|
||||
}
|
||||
}
|
@ -1,3 +1,13 @@
|
||||
/*html body {
|
||||
font-size: 3rem;
|
||||
}*/
|
||||
html, html > body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html > body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
html > body > section {
|
||||
flex: 1;
|
||||
}
|
||||
|
@ -4,8 +4,11 @@ require("./layout.css");
|
||||
|
||||
export default class Layout {
|
||||
private menuActive = false;
|
||||
private drawCount = 0;
|
||||
|
||||
view(vnode: m.Vnode) {
|
||||
this.drawCount = this.drawCount + 1;
|
||||
|
||||
return [
|
||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
@ -16,6 +19,8 @@ export default class Layout {
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
|
||||
<span>{this.drawCount}</span>
|
||||
</div>
|
||||
|
||||
<div id="mainMenu" class={'navbar-menu ' + (this.menuActive ? 'is-active' : '')}>
|
||||
|
@ -4,16 +4,26 @@ export interface Status {
|
||||
batteryVoltage: number; // in Volts
|
||||
motorCurrent: number; // in Amperes
|
||||
speed: number; // in meters per second
|
||||
tripDistance: number; // in meters
|
||||
tripTotalTime: number; // in seconds
|
||||
tripMovingTime: number; // in seconds
|
||||
tripAscendingElevation: number; // in meters
|
||||
tripMotorEnergy: number; // in Watt-hour
|
||||
temperature: number; // in Celcius degrees
|
||||
altitude: number; // in meters above sea level
|
||||
};
|
||||
|
||||
interface ApiStatus {
|
||||
v: number;
|
||||
c: number;
|
||||
s: number;
|
||||
t: number;
|
||||
alt: number;
|
||||
v: number; // voltage (mV)
|
||||
c: number; // current (mA)
|
||||
s: number; // speed (mm/s)
|
||||
td: number; // trip distance (m)
|
||||
ttt: number; // trip total time (s)
|
||||
tmt: number; // trip moving time (s)
|
||||
tae: number; // trip ascending elevation (m)
|
||||
tme: number; // trip motor energy (Wh)
|
||||
temp: number; // temperature (tenth of °C)
|
||||
alt: number; // altitude (mm)
|
||||
}
|
||||
|
||||
export class MonitorApi {
|
||||
@ -21,6 +31,15 @@ export class MonitorApi {
|
||||
private lastStatus: Status = null;
|
||||
private static api: MonitorApi = null;
|
||||
|
||||
private lastFetchTime = 0;
|
||||
private lastFetchAltitude = -100000;
|
||||
|
||||
private mockedTripDistance = 0;
|
||||
private mockedTripTotalTime = 0;
|
||||
private mockedTripMovingTime = 0;
|
||||
private mockedTripAscendingElevation = 0;
|
||||
private mockedTripMotorEnergy = 0;
|
||||
|
||||
constructor() {
|
||||
this.mockServer = window.location.protocol == "file:";
|
||||
}
|
||||
@ -38,18 +57,60 @@ export class MonitorApi {
|
||||
|
||||
if(this.mockServer) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
let t = Date.now() / 1000.0;
|
||||
if(this.lastFetchTime == 0) this.lastFetchTime = t;
|
||||
let dt = t - this.lastFetchTime;
|
||||
this.lastFetchTime = t;
|
||||
|
||||
let speed = (Math.cos(t*0.3)+1.0)*0.5 * 14; // in meters per second
|
||||
if(speed < 0.25) speed = 0;
|
||||
|
||||
this.mockedTripDistance += speed * dt;
|
||||
this.mockedTripTotalTime += dt;
|
||||
if(speed > 0)
|
||||
this.mockedTripMovingTime += dt;
|
||||
|
||||
let altitude = (Math.cos(t*0.0001)+1.0)*0.5 * 4500 - 200 + (Math.cos(t*0.03))*0.5 * 60; // in meters
|
||||
if(this.lastFetchAltitude == -100000) this.lastFetchAltitude = altitude;
|
||||
let altitudeChange = altitude - this.lastFetchAltitude;
|
||||
this.lastFetchAltitude = altitude;
|
||||
|
||||
if(altitudeChange > 0)
|
||||
this.mockedTripAscendingElevation += altitudeChange;
|
||||
|
||||
let voltage = (Math.cos(t*0.4)+1.0)*0.5 * 6 + 36;
|
||||
let current = (Math.cos(t*0.7)+1.0)*0.5 * 30;
|
||||
let newEnergy = voltage * current * dt / 3600;
|
||||
this.mockedTripMotorEnergy += newEnergy;
|
||||
|
||||
apiStatus = {
|
||||
v: Math.random() * 20000 + 20000,
|
||||
c: Math.random() * 30000,
|
||||
s: Math.random() * 14000,
|
||||
t: Math.random() * 400 - 100,
|
||||
alt: Math.random() * 4500000 - 200000
|
||||
}
|
||||
setTimeout(() => m.redraw(), 0);
|
||||
v: Math.round(voltage * 1000),
|
||||
c: Math.round(current * 1000),
|
||||
s: Math.round(speed * 1000),
|
||||
td: Math.round(this.mockedTripDistance),
|
||||
ttt: Math.round(this.mockedTripTotalTime),
|
||||
tmt: Math.round(this.mockedTripMovingTime),
|
||||
tae: Math.round(this.mockedTripAscendingElevation*10.0),
|
||||
tme: Math.round(this.mockedTripMotorEnergy*10.0),
|
||||
temp: Math.round((Math.cos(t*0.2)+1.0)*0.5 * 400 - 100),
|
||||
alt: Math.round(altitude * 1000)
|
||||
};
|
||||
} else {
|
||||
apiStatus = await m.request({
|
||||
method: "GET",
|
||||
url: "/api/status"
|
||||
apiStatus = await new Promise<ApiStatus>((resolve, error) => {
|
||||
let request = new XMLHttpRequest();
|
||||
request.onreadystatechange = () => {
|
||||
if(request.readyState == 4) {
|
||||
if(request.status == 200) {
|
||||
resolve(JSON.parse(request.response));
|
||||
}
|
||||
else {
|
||||
error();
|
||||
}
|
||||
}
|
||||
};
|
||||
request.open('GET', '/api/status', true);
|
||||
request.send();
|
||||
});
|
||||
}
|
||||
|
||||
@ -57,7 +118,12 @@ export class MonitorApi {
|
||||
batteryVoltage: apiStatus.v / 1000,
|
||||
motorCurrent: apiStatus.c / 1000,
|
||||
speed: apiStatus.s / 1000,
|
||||
temperature: apiStatus.t / 10,
|
||||
tripDistance: apiStatus.td,
|
||||
tripTotalTime: apiStatus.ttt,
|
||||
tripMovingTime: apiStatus.tmt,
|
||||
tripAscendingElevation: apiStatus.tae / 10,
|
||||
tripMotorEnergy: apiStatus.tme / 10,
|
||||
temperature: apiStatus.temp / 10,
|
||||
altitude: apiStatus.alt / 1000
|
||||
};
|
||||
|
||||
|
4
WebApp/src/pages/dashboard/dashboard-page.css
Normal file
@ -0,0 +1,4 @@
|
||||
div.dashboard-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
115
WebApp/src/pages/dashboard/dashboard-page.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import m from 'mithril';
|
||||
import { Page } from 'components/page';
|
||||
import { MonitorApi, Status } from 'monitor-api';
|
||||
import { Pipe } from 'utilities/pipe';
|
||||
|
||||
import { Clock } from 'components/widgets/clock';
|
||||
import { NumericValue } from 'components/widgets/numeric-value';
|
||||
import { Chronometer } from 'components/widgets/chronometer';
|
||||
import { GaugeLinear } from 'components/widgets/gauge-linear';
|
||||
import { GaugeCircular } from 'components/widgets/gauge-circular';
|
||||
import { GaugeBattery } from 'components/widgets/gauge-battery';
|
||||
|
||||
import AltitudeIcon from 'assets/icons/altitude.png';
|
||||
import AscendingElevationIcon from 'assets/icons/ascending-elevation.png';
|
||||
import AverageIcon from 'assets/icons/average.png';
|
||||
import DistanceIcon from 'assets/icons/distance.png';
|
||||
import ElectricityIcon from 'assets/icons/electricity.png';
|
||||
import DistanceElectricityIcon from 'assets/icons/distance-electricity.png';
|
||||
import TemperatureIcon from 'assets/icons/temperature.png';
|
||||
import TimeIcon from 'assets/icons/time.png';
|
||||
|
||||
require('./dashboard-page.css');
|
||||
|
||||
export class DashboardPage extends Page {
|
||||
status: Status = null;
|
||||
autoRefresh = true;
|
||||
|
||||
private power = new Pipe(0.0);
|
||||
private battery = new Pipe(0.0);
|
||||
private speed = new Pipe(0.0);
|
||||
|
||||
private tripTotalTime = new Pipe(0.0);
|
||||
private tripDistance = new Pipe(0.0);
|
||||
private tripAverageSpeed = new Pipe(0.0);
|
||||
private tripEnergy = new Pipe(0.0);
|
||||
private tripAverageConsumption = new Pipe(0.0);
|
||||
private tripAscendingElevation = new Pipe(0.0);
|
||||
|
||||
private altitude = new Pipe(0.0);
|
||||
private temperature = new Pipe(0.0);
|
||||
|
||||
oninit() {
|
||||
this.status = MonitorApi.get().getStatus();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
onbeforeremove(vnode: m.Vnode<{}, {}>) {
|
||||
this.autoRefresh = false;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
let newStatus = await MonitorApi.get().fetchStatus();
|
||||
if(this.status == null)
|
||||
m.redraw();
|
||||
this.status = newStatus;
|
||||
|
||||
let power = Math.max(0.0, this.status.batteryVoltage * this.status.motorCurrent);
|
||||
this.power.set(power);
|
||||
|
||||
const minVoltage = 36.0;
|
||||
const maxVoltage = 42.0;
|
||||
let batteryRatio = Math.max(0.0, (this.status.batteryVoltage - minVoltage) / (maxVoltage - minVoltage));
|
||||
this.battery.set(batteryRatio * batteryRatio * 100.0); // TODO: find a better formula to estimate remaining energy from battery voltage and power
|
||||
|
||||
this.speed.set(this.status.speed * 3.6); // convert m/s to km/h
|
||||
|
||||
this.tripTotalTime.set(this.status.tripTotalTime);
|
||||
this.tripDistance.set(this.status.tripDistance/1000);
|
||||
this.tripAverageSpeed.set(this.status.tripDistance/1000 / (Math.max(1.0, this.status.tripMovingTime)/3600));
|
||||
this.tripEnergy.set(this.status.tripMotorEnergy);
|
||||
this.tripAverageConsumption.set(this.status.tripMotorEnergy / Math.max(0.1, this.status.tripDistance/1000));
|
||||
this.tripAscendingElevation.set(this.status.tripAscendingElevation);
|
||||
|
||||
this.temperature.set(this.status.temperature);
|
||||
this.altitude.set(this.status.altitude);
|
||||
|
||||
if(this.autoRefresh)
|
||||
setTimeout(() => { if(this.autoRefresh) this.refresh(); }, 400);
|
||||
}
|
||||
|
||||
view() {
|
||||
console.log(DistanceIcon);
|
||||
return this.status
|
||||
? <div class="dashboard-page">
|
||||
<div class="widgets-row">
|
||||
<Clock/>
|
||||
</div>
|
||||
|
||||
<div class="widgets-row" style="height: 40%">
|
||||
<GaugeLinear widgetWidth={0.2} value={this.power} minValue={0} maxValue={999.0} bottomWidth={0.4} unit="W" />
|
||||
<GaugeCircular widgetWidth={0.6} value={this.speed} minValue={0} maxValue={50.0} decimals={1} unit="km/h" />
|
||||
<GaugeBattery widgetWidth={0.2} value={this.battery} minValue={0} maxValue={100.0} unit="%" />
|
||||
</div>
|
||||
|
||||
<div class="widgets-row">
|
||||
<Chronometer icon={TimeIcon} widgetWidth={0.5} value={this.tripTotalTime} />
|
||||
<NumericValue icon={DistanceIcon} widgetWidth={0.5} value={this.tripDistance} decimals={1} unit="km" />
|
||||
</div>
|
||||
<div class="widgets-row">
|
||||
<NumericValue icon={AverageIcon} widgetWidth={0.5} value={this.tripAverageSpeed} decimals={1} unit="km/h" />
|
||||
<NumericValue icon={AscendingElevationIcon} widgetWidth={0.5} value={this.tripAscendingElevation} decimals={1} unit="m" />
|
||||
</div>
|
||||
<div class="widgets-row">
|
||||
<NumericValue icon={ElectricityIcon} widgetWidth={0.5} value={this.tripAverageConsumption} decimals={1} unit="Wh/km" />
|
||||
<NumericValue icon={DistanceElectricityIcon} widgetWidth={0.5} value={this.tripEnergy} decimals={1} unit="Wh" />
|
||||
</div>
|
||||
|
||||
<div class="widgets-row">
|
||||
<NumericValue icon={TemperatureIcon} widgetWidth={0.5} value={this.temperature} decimals={1} unit="°C" />
|
||||
<NumericValue icon={AltitudeIcon} widgetWidth={0.5} value={this.altitude} decimals={1} unit="m" />
|
||||
</div>
|
||||
</div>
|
||||
: <p>Chargement...</p>;
|
||||
}
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
import m from 'mithril';
|
||||
import { MonitorApi, Status } from './monitor-api';
|
||||
import { Page } from 'components/page';
|
||||
import { MonitorApi, Status } from 'monitor-api';
|
||||
|
||||
require("./raw-data-page.css");
|
||||
|
||||
export default class RawDataPage {
|
||||
export class RawDataPage extends Page {
|
||||
status: Status = null;
|
||||
autoRefresh = true;
|
||||
|
||||
@ -18,6 +19,7 @@ export default class RawDataPage {
|
||||
|
||||
async refresh() {
|
||||
this.status = await MonitorApi.get().fetchStatus();
|
||||
m.redraw();
|
||||
if(this.autoRefresh)
|
||||
setTimeout(() => { if(this.autoRefresh) this.refresh(); }, 500);
|
||||
}
|
21
WebApp/src/utilities/pipe.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export class Pipe<ValueType>
|
||||
{
|
||||
private changeCallbacks: ((newValue: ValueType) => void)[] = [];
|
||||
|
||||
constructor(private value: ValueType) {
|
||||
}
|
||||
|
||||
get() { return this.value; }
|
||||
|
||||
set(value: ValueType)
|
||||
{
|
||||
this.value = value;
|
||||
for(let callback of this.changeCallbacks) {
|
||||
callback(value);
|
||||
}
|
||||
}
|
||||
|
||||
onChange(callback: (newValue: ValueType) => void) {
|
||||
this.changeCallbacks.push(callback);
|
||||
}
|
||||
}
|
@ -1,12 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"noImplicitAny": true,
|
||||
"module": "CommonJS",
|
||||
"module": "ES6",
|
||||
"esModuleInterop": true,
|
||||
"target": "es5",
|
||||
"jsx": "react",
|
||||
"jsxFactory": "m",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "node"
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const PurgecssPlugin = require('purgecss-webpack-plugin');
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
entry: './src/app.ts',
|
||||
@ -19,10 +20,15 @@ module.exports = {
|
||||
test: /\.css$/i,
|
||||
use: [MiniCssExtractPlugin.loader, "css-loader"],
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|jpeg|gif)$/i,
|
||||
type: "asset/resource",
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
plugins: [new TsconfigPathsPlugin({})]
|
||||
},
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
@ -39,6 +45,7 @@ module.exports = {
|
||||
}),
|
||||
new PurgecssPlugin({
|
||||
paths: glob.sync('./src/**/*', { nodir: true }),
|
||||
safelist: ['html', 'body']
|
||||
})
|
||||
]
|
||||
};
|
||||
|