Initial commit

This commit is contained in:
Djuri 2023-11-17 01:05:35 +01:00
commit 96d609a89e
34 changed files with 4978 additions and 0 deletions

47
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,47 @@
<script lang="ts">
import {
Navbar,
NavbarBrand,
Nav,
NavItem,
NavLink,
Collapse,
Dropdown,
DropdownMenu,
DropdownItem,
DropdownToggle
} from 'sveltestrap';
import { locale, locales } from 'svelte-i18n';
export const setLocale = (lang: string) => () => {
locale.set(lang);
}
</script>
<Navbar expand="md">
<NavbarBrand>&#8383;TClock</NavbarBrand>
<Collapse navbar expand="md">
<Nav class="me-auto" navbar>
<NavItem>
<NavLink href="/">Home</NavLink>
</NavItem>
<NavItem>
<NavLink href="/api">API</NavLink>
</NavItem>
</Nav>
<Dropdown inNavbar>
<DropdownToggle nav caret>{$locale}</DropdownToggle>
<DropdownMenu end>
{#each $locales as locale}
<DropdownItem on:click={setLocale(locale)}>{locale}</DropdownItem>
{/each}
</DropdownMenu>
</Dropdown>
</Collapse>
</Navbar>
<!-- +layout.svelte -->
<slot />

16
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,16 @@
import "$lib/style/app.scss";
import { browser } from '$app/environment'
import '$lib/i18n' // Import to initialize. Important :)
import { locale, waitLocale } from 'svelte-i18n'
import type { LayoutLoad } from './$types'
export const load: LayoutLoad = async () => {
if (browser) {
locale.set(window.navigator.language)
}
await waitLocale()
}
export const prerender = true;

32
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,32 @@
<script lang="ts">
import { PUBLIC_BASE_URL } from '$env/static/public';
import { _ } from 'svelte-i18n';
import { Col, Container, Row } from 'sveltestrap';
import Control from './Control.svelte';
import Status from './Status.svelte';
import Settings from './Settings.svelte';
import { writable } from 'svelte/store';
import { onMount } from 'svelte';
let settings = writable({});
onMount(() => {
fetch( PUBLIC_BASE_URL + `/api/settings`)
.then((res) => res.json())
.then((data) => {
data.fgColor = String(data.fgColor);
data.bgColor = String(data.bgColor);
settings.set(data);
});
});
</script>
<Container fluid>
<Row>
<Control bind:settings></Control>
<Status bind:settings></Status>
<Settings bind:settings></Settings>
</Row>
</Container>

71
src/routes/Control.svelte Normal file
View file

@ -0,0 +1,71 @@
<script lang="ts">
import { PUBLIC_BASE_URL } from '$env/static/public';
import { _ } from 'svelte-i18n';
import {
Button,
ButtonGroup,
Card,
CardBody,
CardHeader,
Col,
Container,
Form,
Input,
Label,
Row
} from 'sveltestrap';
export let settings = {};
export let customText:String;
const setCustomText = () => {
fetch(`${PUBLIC_BASE_URL}/api/show/text/${customText}`).catch(err => { });
};
const setLEDcolor = () => {
};
</script>
<Col>
<Card>
<CardHeader>
<h2>{$_('section.control.title', { default: 'Control' })}</h2>
</CardHeader>
<CardBody>
<Form>
<Row>
<Label md={6} for="customText" size="sm">Text</Label>
<Col md="6">
<Input type="text" id="customText"bind:value={customText} bsSize="sm" maxLength="{$settings.numScreens}"/>
</Col>
</Row>
<Button color="primary" on:click={setCustomText}>Show text</Button>
</Form>
<hr />
<h3>LEDs</h3>
<Form>
<Row>
<Label md={6} for="ledColorPicker" size="sm">LEDs color</Label>
<Col md="6">
<Input type="color" id="ledColorPicker" />
</Col>
</Row>
<Button color="secondary" id="turnOffLedsBtn">Turn off</Button>
<Button color="primary">Set color</Button>
</Form>
<hr />
<h3>System info</h3>
<ul class="small system_info">
<li>Version: {$settings.gitRev}</li>
<li>Build time: {new Date(($settings.lastBuildTime * 1000)).toLocaleString()}</li>
<li>IP: {$settings.ip}</li>
<li>Hostname: {$settings.hostname}</li>
</ul>
<Button color="danger" id="restartBtn">Restart</Button>
<Button color="warning" id="forceFullRefresh">Force full refresh</Button>
</CardBody>
</Card>
</Col>

0
src/routes/Control.ts Normal file
View file

View file

@ -0,0 +1,25 @@
<script lang="ts">
export let status = {};
const isSplitText = (str:String) => {
return str.includes("/");
}
</script>
<div class="btcclock-wrapper" id="btcclock-wrapper">
<div class="btclock">
{#each status.data as char}
{#if isSplitText(char)}
<div class="splitText">
{#each char.split("/") as part}
<div class="flex-items">{part}</div>
{/each}
</div>
{:else if char.length === 0 || char === " "}
<div class="digit">&nbsp;&nbsp;</div>
{:else}
<div class="digit">{char}</div>
{/if}
{/each}
</div>
</div>

155
src/routes/Settings.svelte Normal file
View file

@ -0,0 +1,155 @@
<script lang="ts">
import { onMount } from 'svelte';
import { readonly, writable } from 'svelte/store';
import { _ } from 'svelte-i18n';
import {
Col,
Container,
Row,
Card,
CardHeader,
CardBody,
Form,
FormGroup,
FormText,
Label,
Input,
InputGroup,
InputGroupText,
Button
} from 'sveltestrap';
export let settings;
</script>
<Col>
<Card>
<CardHeader>
<h2>{$_('section.settings.title', { default: 'Settings' })}</h2>
</CardHeader>
<CardBody>
<Form>
<Row>
<Label md={6} for="fgColor" size="sm">Text color</Label>
<Col md="6">
<Input
type="select"
value={$settings.fgColor}
name="select"
id="fgColor"
bsSize="sm"
class="form-select-sm"
>
<option value="0">Black</option>
<option value="65535">White</option>
</Input>
</Col>
</Row>
<Row>
<Label md={6} for="bgColor" size="sm">Background color</Label>
<Col md="6">
<Input
type="select"
bind:value={$settings.bgColor}
name="select"
id="bgColor"
bsSize="sm"
class="form-select-sm"
>
<option value="0">Black</option>
<option value="65535">White</option>
</Input>
</Col>
</Row>
<Row>
<Label md={6} for="timePerScreen" size="sm">Time per screen</Label>
<Col md="6">
<InputGroup size="sm">
<Input type="number" min={1} step="1" bind:value={$settings.timerSeconds} />
<InputGroupText>minutes</InputGroupText>
</InputGroup>
</Col>
</Row>
<Row>
<Label md={6} for="fullRefreshMin" size="sm">Full refresh every</Label>
<Col md="6">
<InputGroup size="sm">
<Input type="number" min={1} step="1" bind:value={$settings.fullRefreshMin} />
<InputGroupText>minutes</InputGroupText>
</InputGroup>
</Col>
</Row>
<Row>
<Label md={6} for="minSecPriceUpd" size="sm">Time between price updates</Label>
<Col md="6">
<InputGroup size="sm">
<Input type="number" min={1} step="1" bind:value={$settings.minSecPriceUpd} />
<InputGroupText>seconds</InputGroupText>
</InputGroup>
<FormText>Short amounts might shorten lifespan.</FormText>
</Col>
</Row>
<Row>
<Label md={6} for="tzOffset" size="sm">Timezone offset</Label>
<Col md="6">
<InputGroup size="sm">
<Input
type="number"
min={1}
step="1"
name="tzOffset"
id="tzOffset"
bind:value={$settings.tzOffset}
/>
<InputGroupText>minutes</InputGroupText>
</InputGroup>
<FormText>A restart is required to apply TZ offset.</FormText>
</Col>
</Row>
<Row>
<Label md={6} for="ledBrightness" size="sm">LED brightness</Label>
<Col md="6">
<Input
type="range"
name="ledBrightness"
id="ledBrightness"
bind:value={$settings.ledBrightness}
min={0}
max={255}
step={1}
/>
</Col>
</Row>
<Row>
<Label md={6} for="mempoolInstance" size="sm">Mempool Instance</Label>
<Col md="6">
<Input
type="text"
bind:value={$settings.mempoolInstance}
name="mempoolInstance"
id="mempoolInstance"
bsSize="sm"
>
</Input>
</Col>
</Row>
<Row>
<Label md={6} for="hostnamePrefix" size="sm">Hostname prefix</Label>
<Col md="6">
<Input
type="text"
bind:value={$settings.hostnamePrefix}
name="hostnamePrefix"
id="hostnamePrefix"
bsSize="sm"
>
</Input>
</Col>
</Row>
<Button color="secondary">Reset</Button>
<Button color="primary">Save</Button>
</Form>
</CardBody>
</Card>
</Col>

131
src/routes/Status.svelte Normal file
View file

@ -0,0 +1,131 @@
<script lang="ts">
import { PUBLIC_BASE_URL } from '$env/static/public';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { writable } from 'svelte/store';
import { Button, ButtonGroup, Card, CardBody, CardHeader, Col, Progress } from 'sveltestrap';
import Rendered from './Rendered.svelte';
const status = writable({
data: ["L", "O", "A", "D", "I", "N", "G"],
espFreeHeap: 0,
espHeapSize: 0,
connectionStatus: {
"price": false,
"blocks": false
}
});
onMount(() => {
fetch(`${PUBLIC_BASE_URL}/api/status`)
.then((res) => res.json())
.then((data) => {
status.set(data);
});
const evtSource = new EventSource(`${PUBLIC_BASE_URL}/events`);
evtSource.addEventListener('status', (e) => {
let dataObj = (JSON.parse(e.data));
status.set(dataObj);
});
});
const toTime = (secs:Number) => {
var hours = Math.floor(secs / (60 * 60));
var divisor_for_minutes = secs % (60 * 60);
var minutes = Math.floor(divisor_for_minutes / 60);
var divisor_for_seconds = divisor_for_minutes % 60;
var seconds = Math.ceil(divisor_for_seconds);
var obj = {
"h": hours,
"m": minutes,
"s": seconds
};
return obj;
}
const toUptimeString = (secs:Number):String => {
let time = toTime(secs);
return `${time.h}h ${time.m}m ${time.s}s`;
}
let memoryFreePercent:number = 50;
status.subscribe((value) => {
memoryFreePercent = Math.round(value.espFreeHeap / value.espHeapSize * 100);
});
const setScreen = (id:number) => () => {
fetch(`${PUBLIC_BASE_URL}/api/show/screen/${id}`).catch(err => { });
}
const toggleTimer = (currentStatus:boolean) => () => {
if (currentStatus) {
fetch(`${PUBLIC_BASE_URL}/api/action/pause`);
} else {
fetch(`${PUBLIC_BASE_URL}/api/action/timer_restart`);
}
}
export let settings;
</script>
<Col>
<Card>
<CardHeader>
<h2>{$_('section.status.title', { default: 'Status' })}</h2>
</CardHeader>
<CardBody>
{#if $settings.screens}
<ButtonGroup size="sm">
{#each $settings.screens as s}
<Button color="outline-primary" active={$status.currentScreen == s.id} on:click={setScreen(s.id)}>{s.name}</Button>
{/each}
</ButtonGroup>
<hr>
{#if $status.data}
<Rendered status="{$status}"></Rendered>
Screen cycle: {#if status.timerRunning}&#9205; running{:else}&#9208; stopped{/if}
{/if}
{/if}
<hr>
<Progress striped value={memoryFreePercent}>{ memoryFreePercent }%</Progress>
<div class="d-flex justify-content-between">
<div>Memory free </div>
<div>{ Math.round($status.espFreeHeap / 1024) } / { Math.round($status.espHeapSize / 1024) } KiB</div>
</div>
<hr>
Uptime: {toUptimeString($status.espUptime)}
<br>
<p>
WS Price connection:
<span>
{#if $status.connectionStatus && $status.connectionStatus.price}
&#9989;
{:else}
&#10060;
{/if}
</span>
-
WS Mempool.space connection:
<span>
{#if $status.connectionStatus && $status.connectionStatus.blocks}
&#9989;
{:else}
&#10060;
{/if}
</span><br>
<small>If you use "Fetch &euro; price" the WS Price connection will show &#10060; since it uses another data source.</small>
</p>
</CardBody>
</Card>
</Col>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { Col, Container, Row } from 'sveltestrap';
import { onMount } from 'svelte';
import * as swaggerJson from './swagger.json';
//import SwaggerUI from 'swagger-ui';
//import 'swagger-ui/dist/swagger-ui.css';
onMount(async () => {
// SwaggerUI({
// spec: swaggerJson,
// dom_id: '#swagger-ui-container'
// });
});
</script>
<svelte:head>
<title>API playground</title>
</svelte:head>
<Container fluid>
<div id="swagger-ui-container" />
</Container>

345
src/routes/api/swagger.json Normal file
View file

@ -0,0 +1,345 @@
{
"openapi": "3.0.3",
"info": {
"title": "BTClock API",
"version": "3.0",
"description": "BTClock V3 API"
},
"servers": [
{
"url": "/api/"
}
],
"paths": {
"/status": {
"get": {
"tags": [
"system"
],
"summary": "Get current status",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/system_status": {
"get": {
"tags": [
"system"
],
"summary": "Get system status",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/settings": {
"get": {
"tags": [
"system"
],
"summary": "Get current settings",
"responses": {
"200": {
"description": "successful operation"
}
}
},
"post": {
"tags": [
"system"
],
"summary": "Save current settings",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Settings"
}
}
}
},
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/action/pause": {
"get": {
"tags": [
"timer"
],
"summary": "Pause screen rotation",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/action/timer_restart": {
"get": {
"tags": [
"timer"
],
"summary": "Restart screen rotation",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/show/screen/{id}": {
"get": {
"tags": [
"screens"
],
"summary": "Set screen to show",
"parameters": [
{
"in": "path",
"name": "id",
"schema": {
"type": "integer",
"default": 1
},
"required": true,
"description": "ID of screen to show"
}
],
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/show/text/{text}": {
"get": {
"tags": [
"screens"
],
"summary": "Set text to show",
"parameters": [
{
"in": "path",
"name": "text",
"schema": {
"type": "string",
"default": "text"
},
"required": true,
"description": "Text to show"
}
],
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/show/custom": {
"post": {
"tags": [
"screens"
],
"summary": "Set text to show (advanced)",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CustomText"
}
}
}
},
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/full_refresh": {
"get": {
"tags": [
"system"
],
"summary": "Force full refresh of all displays",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/lights/{color}": {
"get": {
"tags": [
"lights"
],
"summary": "Turn on LEDs with specific color",
"parameters": [
{
"in": "path",
"name": "color",
"schema": {
"type": "string",
"default": "FFCC00"
},
"required": true,
"description": "Color in RGB hex"
}
],
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/lights/off": {
"get": {
"tags": [
"lights"
],
"summary": "Turn LEDs off",
"responses": {
"200": {
"description": "successful operation"
}
}
}
},
"/restart": {
"get": {
"tags": [
"system"
],
"summary": "Restart BTClock",
"responses": {
"200": {
"description": "successful operation"
}
}
}
}
},
"components": {
"schemas": {
"Settings": {
"type": "object",
"properties": {
"fetchEurPrice": {
"type": "boolean",
"description": "Fetch EUR price instead of USD"
},
"fgColor": {
"type": "string",
"default": 16777215,
"description": "ePaper foreground (text) color"
},
"bgColor": {
"type": "string",
"default": 0,
"description": "ePaper background color"
},
"ledTestOnPower": {
"type": "boolean",
"default": true,
"description": "Do LED test on power-on"
},
"ledFlashOnUpd": {
"type": "boolean",
"default": false,
"description": "Flash LEDs on new block"
},
"mdnsEnabled": {
"type": "boolean",
"default": true,
"description": "Enable mDNS"
},
"otaEnabled": {
"type": "boolean",
"default": true,
"description": "Enable over-the-air updates"
},
"stealFocusOnBlock": {
"type": "boolean",
"default": false,
"description": "Steal focus on new block"
},
"mcapBigChar": {
"type": "boolean",
"default": false,
"description": "Use big characters for market cap screen"
},
"mempoolInstance": {
"type": "string",
"default": "mempool.space",
"description": "Mempool.space instance to connect to"
},
"ledBrightness": {
"type": "integer",
"default": 128,
"description": "Brightness of LEDs"
},
"fullRefreshMin": {
"type": "integer",
"default": 60,
"description": "Full refresh time of ePaper displays in minutes"
},
"screen[0]": {
"type": "boolean"
},
"screen[1]": {
"type": "boolean"
},
"screen[2]": {
"type": "boolean"
},
"screen[3]": {
"type": "boolean"
},
"screen[4]": {
"type": "boolean"
},
"screen[5]": {
"type": "boolean"
},
"tzOffset": {
"type": "integer",
"default": 60,
"description": "Timezone offset in minutes"
},
"minSecPriceUpd": {
"type": "integer",
"default": 30,
"description": "Minimum time between price updates in seconds"
},
"timePerScreen": {
"type": "integer",
"default": 30,
"description": "Time between screens when rotating in minutes"
}
}
},
"CustomText": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 7,
"maxItems": 7
}
}
}
}