Compare commits
50 Commits
main
...
2961b477b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2961b477b4 | ||
|
|
e630c92b60 | ||
|
|
46e10fc00b | ||
|
|
9791ea22ab | ||
|
|
8480d7f71b | ||
|
|
43fda71c1b | ||
|
|
161b226db9 | ||
|
|
2cb6ca0588 | ||
|
|
c87c09dde3 | ||
|
|
1d0a5beb8c | ||
|
|
df21ce581b | ||
|
|
0ca32f4bae | ||
|
|
ab0e2a3bdf | ||
|
|
40495eab61 | ||
|
|
d577530deb | ||
|
|
240af40c39 | ||
|
|
bd0d6da7f4 | ||
|
|
c1f8e3bf4f | ||
|
|
22af3312ba | ||
|
|
4fda42504f | ||
|
|
04064364e2 | ||
|
|
95c872ddc8 | ||
|
|
53fa112aa8 | ||
|
|
fece1603e7 | ||
|
|
77e42910c5 | ||
|
|
397b2c9f30 | ||
|
|
41aac081ca | ||
|
|
c95ebe18b0 | ||
|
|
c7f0d6a195 | ||
|
|
b122aad755 | ||
|
|
ff45cfd7d0 | ||
|
|
9125762d0d | ||
|
|
190bfdd8d3 | ||
|
|
48001a25cc | ||
|
|
e2ba81037d | ||
|
|
b9eb1fafca | ||
|
|
85f66eddad | ||
|
|
8d989e76e8 | ||
|
|
b118ea40ae | ||
|
|
450b45888a | ||
|
|
18510b9efd | ||
|
|
e016f1fbbc | ||
|
|
e1bb15dc5f | ||
|
|
e113be72d0 | ||
|
|
a5be72a455 | ||
|
|
b310a460ca | ||
|
|
c09330b4e9 | ||
|
|
98311a95ba | ||
|
|
d3651ec4fd | ||
|
|
9e424b706c |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -17,3 +17,13 @@ config/
|
|||||||
[Oo]bj/
|
[Oo]bj/
|
||||||
.aider*
|
.aider*
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|||||||
1
configurations/home-assistant/automations.yaml
Normal file
1
configurations/home-assistant/automations.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# TODO add automations
|
||||||
@@ -3,10 +3,6 @@
|
|||||||
# This is a sample configuration. Please create a configuration.production.yml for production use.
|
# This is a sample configuration. Please create a configuration.production.yml for production use.
|
||||||
default_config:
|
default_config:
|
||||||
|
|
||||||
# Text to speech
|
|
||||||
tts:
|
|
||||||
- platform: google_translate
|
|
||||||
|
|
||||||
automation: !include automations.yaml
|
automation: !include automations.yaml
|
||||||
script: !include scripts.yaml
|
script: !include scripts.yaml
|
||||||
scene: !include scenes.yaml
|
scene: !include scenes.yaml
|
||||||
@@ -14,7 +10,78 @@ scene: !include scenes.yaml
|
|||||||
http:
|
http:
|
||||||
use_x_forwarded_for: true
|
use_x_forwarded_for: true
|
||||||
trusted_proxies:
|
trusted_proxies:
|
||||||
- 10.55.8.1
|
- 172.16.0.0/12
|
||||||
|
|
||||||
|
# ---- 1. Track a running monthly average of the EPEX day-ahead price ----
|
||||||
|
# (Replace sensor.nordpool_kwh_be_eur_3_10_0 with your actual Nord Pool entity ID)
|
||||||
|
sensor:
|
||||||
|
- platform: statistics
|
||||||
|
name: "Epex Monthly Average"
|
||||||
|
entity_id: sensor.nordpool_kwh_be_eur_3_10_0
|
||||||
|
state_characteristic: mean
|
||||||
|
max_age:
|
||||||
|
days: 31
|
||||||
|
sampling_size: 9999
|
||||||
|
|
||||||
|
# ---- 2. Apply Mega's formula and add the fixed cost components ----
|
||||||
|
template:
|
||||||
|
- sensor:
|
||||||
|
- name: "Mega Energy Price"
|
||||||
|
unique_id: mega_energy_price
|
||||||
|
unit_of_measurement: "€/kWh"
|
||||||
|
state: >
|
||||||
|
{% set epex = states('sensor.epex_monthly_average') | float(0) %}
|
||||||
|
{% set excl_vat = epex * 1.085 %}
|
||||||
|
{% set incl_vat = excl_vat * 1.06 %}
|
||||||
|
{{ incl_vat | round(5) }}
|
||||||
|
|
||||||
|
- name: "Mega All-in Price"
|
||||||
|
unique_id: mega_allin_price
|
||||||
|
unit_of_measurement: "€/kWh"
|
||||||
|
state: >
|
||||||
|
{% set energy = states('sensor.mega_energy_price') | float(0) %}
|
||||||
|
{% set green_power = 0.01554 %}
|
||||||
|
{% set distribution = 0.066985 %}
|
||||||
|
{% set excise = 0.0503288 %}
|
||||||
|
{% set energy_contribution = 0.0020417 %}
|
||||||
|
{{ (energy + green_power + distribution + excise + energy_contribution) | round(5) }}
|
||||||
|
- sensor:
|
||||||
|
- name: "Mega Injection Price"
|
||||||
|
unique_id: mega_injection_price
|
||||||
|
unit_of_measurement: "€/kWh"
|
||||||
|
state: >
|
||||||
|
{% set epex_spp = states('sensor.epex_spp_monthly_average') | float(0) %}
|
||||||
|
{% set price = (epex_spp * 0.96) - 1 %}
|
||||||
|
{{ [price, 0] | max | round(5) }}
|
||||||
|
|
||||||
|
- sensor:
|
||||||
|
- name: "Cistern Water Height"
|
||||||
|
unique_id: cistern_water_height
|
||||||
|
unit_of_measurement: "mm"
|
||||||
|
state_class: measurement
|
||||||
|
state: >
|
||||||
|
{% set sensor_offset = 290 %} {# distance (mm) from sensor to tank bottom #}
|
||||||
|
{% set distance = states('sensor.regenput_sensor_distance') | float(0) %}
|
||||||
|
{% set height = sensor_offset - distance %}
|
||||||
|
{{ [ [height, 0] | max, sensor_offset] | min | round(1) }}
|
||||||
|
|
||||||
|
- name: "Cistern Volume"
|
||||||
|
unique_id: cistern_volume
|
||||||
|
unit_of_measurement: "L"
|
||||||
|
device_class: volume
|
||||||
|
state_class: measurement
|
||||||
|
state: >
|
||||||
|
{% set diameter = 1450 %} {# mm #}
|
||||||
|
{% set radius = diameter / 2 %}
|
||||||
|
{% set height = states('sensor.cistern_water_height') | float(0) %}
|
||||||
|
{% set volume_mm3 = 3.14159265 * (radius ** 2) * height %}
|
||||||
|
{{ (volume_cm3 / 1000000) | round(1) }} {# convert mm³ to liters #}
|
||||||
|
|
||||||
|
- name: "Cistern Fill Percentage"
|
||||||
|
unique_id: cistern_fill_percentage
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
state_class: measurement
|
||||||
|
state: >
|
||||||
|
{% set sensor_offset = 290 %} {# same as above, = full tank height #}
|
||||||
|
{% set height = states('sensor.cistern_water_height') | float(0) %}
|
||||||
|
{{ (height / sensor_offset * 100) | round(1) }}
|
||||||
|
|||||||
1
configurations/home-assistant/scenes.yaml
Normal file
1
configurations/home-assistant/scenes.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# TODO add scenes
|
||||||
1
configurations/home-assistant/scripts.yaml
Normal file
1
configurations/home-assistant/scripts.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# TODO add scripts
|
||||||
691
dnsmasq.conf
691
dnsmasq.conf
@@ -1,691 +0,0 @@
|
|||||||
# Configuration file for dnsmasq.
|
|
||||||
#
|
|
||||||
# Format is one option per line, legal options are the same
|
|
||||||
# as the long options legal on the command line. See
|
|
||||||
# "/usr/sbin/dnsmasq --help" or "man 8 dnsmasq" for details.
|
|
||||||
|
|
||||||
# Listen on this specific port instead of the standard DNS port
|
|
||||||
# (53). Setting this to zero completely disables DNS function,
|
|
||||||
# leaving only DHCP and/or TFTP.
|
|
||||||
#port=5353
|
|
||||||
|
|
||||||
# The following two options make you a better netizen, since they
|
|
||||||
# tell dnsmasq to filter out queries which the public DNS cannot
|
|
||||||
# answer, and which load the servers (especially the root servers)
|
|
||||||
# unnecessarily. If you have a dial-on-demand link they also stop
|
|
||||||
# these requests from bringing up the link unnecessarily.
|
|
||||||
|
|
||||||
# Never forward plain names (without a dot or domain part)
|
|
||||||
domain-needed
|
|
||||||
# Never forward addresses in the non-routed address spaces.
|
|
||||||
bogus-priv
|
|
||||||
|
|
||||||
# Uncomment these to enable DNSSEC validation and caching:
|
|
||||||
# (Requires dnsmasq to be built with DNSSEC option.)
|
|
||||||
#conf-file=%%PREFIX%%/share/dnsmasq/trust-anchors.conf
|
|
||||||
#dnssec
|
|
||||||
|
|
||||||
# Replies which are not DNSSEC signed may be legitimate, because the domain
|
|
||||||
# is unsigned, or may be forgeries. Setting this option tells dnsmasq to
|
|
||||||
# check that an unsigned reply is OK, by finding a secure proof that a DS
|
|
||||||
# record somewhere between the root and the domain does not exist.
|
|
||||||
# The cost of setting this is that even queries in unsigned domains will need
|
|
||||||
# one or more extra DNS queries to verify.
|
|
||||||
#dnssec-check-unsigned
|
|
||||||
|
|
||||||
# Uncomment this to filter useless windows-originated DNS requests
|
|
||||||
# which can trigger dial-on-demand links needlessly.
|
|
||||||
# Note that (amongst other things) this blocks all SRV requests,
|
|
||||||
# so don't use it if you use eg Kerberos, SIP, XMMP or Google-talk.
|
|
||||||
# This option only affects forwarding, SRV records originating for
|
|
||||||
# dnsmasq (via srv-host= lines) are not suppressed by it.
|
|
||||||
#filterwin2k
|
|
||||||
|
|
||||||
# Change this line if you want dns to get its upstream servers from
|
|
||||||
# somewhere other that /etc/resolv.conf
|
|
||||||
#resolv-file=
|
|
||||||
|
|
||||||
# By default, dnsmasq will send queries to any of the upstream
|
|
||||||
# servers it knows about and tries to favour servers to are known
|
|
||||||
# to be up. Uncommenting this forces dnsmasq to try each query
|
|
||||||
# with each server strictly in the order they appear in
|
|
||||||
# /etc/resolv.conf
|
|
||||||
#strict-order
|
|
||||||
|
|
||||||
# If you don't want dnsmasq to read /etc/resolv.conf or any other
|
|
||||||
# file, getting its servers from this file instead (see below), then
|
|
||||||
# uncomment this.
|
|
||||||
no-resolv
|
|
||||||
|
|
||||||
# If you don't want dnsmasq to poll /etc/resolv.conf or other resolv
|
|
||||||
# files for changes and re-read them then uncomment this.
|
|
||||||
#no-poll
|
|
||||||
|
|
||||||
# Add other name servers here, with domain specs if they are for
|
|
||||||
# non-public domains.
|
|
||||||
server=8.8.8.8
|
|
||||||
server=8.8.4.4
|
|
||||||
|
|
||||||
|
|
||||||
# Example of routing PTR queries to nameservers: this will send all
|
|
||||||
# address->name queries for 192.168.3/24 to nameserver 10.1.2.3
|
|
||||||
#server=/3.168.192.in-addr.arpa/10.1.2.3
|
|
||||||
|
|
||||||
# Add local-only domains here, queries in these domains are answered
|
|
||||||
# from /etc/hosts or DHCP only.
|
|
||||||
#local=/localnet/
|
|
||||||
|
|
||||||
# Add domains which you want to force to an IP address here.
|
|
||||||
# The example below send any host in double-click.net to a local
|
|
||||||
# web-server.
|
|
||||||
#address=/double-click.net/127.0.0.1
|
|
||||||
|
|
||||||
# --address (and --server) work with IPv6 addresses too.
|
|
||||||
#address=/www.thekelleys.org.uk/fe80::20d:60ff:fe36:f83
|
|
||||||
|
|
||||||
# Add the IPs of all queries to yahoo.com, google.com, and their
|
|
||||||
# subdomains to the vpn and search ipsets:
|
|
||||||
#ipset=/yahoo.com/google.com/vpn,search
|
|
||||||
|
|
||||||
# Add the IPs of all queries to yahoo.com, google.com, and their
|
|
||||||
# subdomains to netfilters sets, which is equivalent to
|
|
||||||
# 'nft add element ip test vpn { ... }; nft add element ip test search { ... }'
|
|
||||||
#nftset=/yahoo.com/google.com/ip#test#vpn,ip#test#search
|
|
||||||
|
|
||||||
# Use netfilters sets for both IPv4 and IPv6:
|
|
||||||
# This adds all addresses in *.yahoo.com to vpn4 and vpn6 for IPv4 and IPv6 addresses.
|
|
||||||
#nftset=/yahoo.com/4#ip#test#vpn4
|
|
||||||
#nftset=/yahoo.com/6#ip#test#vpn6
|
|
||||||
|
|
||||||
# You can control how dnsmasq talks to a server: this forces
|
|
||||||
# queries to 10.1.2.3 to be routed via eth1
|
|
||||||
# server=10.1.2.3@eth1
|
|
||||||
|
|
||||||
# and this sets the source (ie local) address used to talk to
|
|
||||||
# 10.1.2.3 to 192.168.1.1 port 55 (there must be an interface with that
|
|
||||||
# IP on the machine, obviously).
|
|
||||||
# server=10.1.2.3@192.168.1.1#55
|
|
||||||
|
|
||||||
# If you want dnsmasq to change uid and gid to something other
|
|
||||||
# than the default, edit the following lines.
|
|
||||||
#user=
|
|
||||||
#group=
|
|
||||||
|
|
||||||
# If you want dnsmasq to listen for DHCP and DNS requests only on
|
|
||||||
# specified interfaces (and the loopback) give the name of the
|
|
||||||
# interface (eg eth0) here.
|
|
||||||
# Repeat the line for more than one interface.
|
|
||||||
#interface=
|
|
||||||
# Or you can specify which interface _not_ to listen on
|
|
||||||
#except-interface=
|
|
||||||
# Or which to listen on by address (remember to include 127.0.0.1 if
|
|
||||||
# you use this.)
|
|
||||||
#listen-address=
|
|
||||||
# If you want dnsmasq to provide only DNS service on an interface,
|
|
||||||
# configure it as shown above, and then use the following line to
|
|
||||||
# disable DHCP and TFTP on it.
|
|
||||||
#no-dhcp-interface=
|
|
||||||
|
|
||||||
# On systems which support it, dnsmasq binds the wildcard address,
|
|
||||||
# even when it is listening on only some interfaces. It then discards
|
|
||||||
# requests that it shouldn't reply to. This has the advantage of
|
|
||||||
# working even when interfaces come and go and change address. If you
|
|
||||||
# want dnsmasq to really bind only the interfaces it is listening on,
|
|
||||||
# uncomment this option. About the only time you may need this is when
|
|
||||||
# running another nameserver on the same machine.
|
|
||||||
#bind-interfaces
|
|
||||||
|
|
||||||
# If you don't want dnsmasq to read /etc/hosts, uncomment the
|
|
||||||
# following line.
|
|
||||||
#no-hosts
|
|
||||||
# or if you want it to read another file, as well as /etc/hosts, use
|
|
||||||
# this.
|
|
||||||
#addn-hosts=/etc/banner_add_hosts
|
|
||||||
|
|
||||||
# Set this (and domain: see below) if you want to have a domain
|
|
||||||
# automatically added to simple names in a hosts-file.
|
|
||||||
#expand-hosts
|
|
||||||
|
|
||||||
# Set the domain for dnsmasq. this is optional, but if it is set, it
|
|
||||||
# does the following things.
|
|
||||||
# 1) Allows DHCP hosts to have fully qualified domain names, as long
|
|
||||||
# as the domain part matches this setting.
|
|
||||||
# 2) Sets the "domain" DHCP option thereby potentially setting the
|
|
||||||
# domain of all systems configured by DHCP
|
|
||||||
# 3) Provides the domain part for "expand-hosts"
|
|
||||||
#domain=thekelleys.org.uk
|
|
||||||
|
|
||||||
# Set a different domain for a particular subnet
|
|
||||||
#domain=wireless.thekelleys.org.uk,192.168.2.0/24
|
|
||||||
|
|
||||||
# Same idea, but range rather then subnet
|
|
||||||
#domain=reserved.thekelleys.org.uk,192.68.3.100,192.168.3.200
|
|
||||||
|
|
||||||
# Uncomment this to enable the integrated DHCP server, you need
|
|
||||||
# to supply the range of addresses available for lease and optionally
|
|
||||||
# a lease time. If you have more than one network, you will need to
|
|
||||||
# repeat this for each network on which you want to supply DHCP
|
|
||||||
# service.
|
|
||||||
#dhcp-range=192.168.0.50,192.168.0.150,12h
|
|
||||||
|
|
||||||
# This is an example of a DHCP range where the netmask is given. This
|
|
||||||
# is needed for networks we reach the dnsmasq DHCP server via a relay
|
|
||||||
# agent. If you don't know what a DHCP relay agent is, you probably
|
|
||||||
# don't need to worry about this.
|
|
||||||
#dhcp-range=192.168.0.50,192.168.0.150,255.255.255.0,12h
|
|
||||||
|
|
||||||
# This is an example of a DHCP range which sets a tag, so that
|
|
||||||
# some DHCP options may be set only for this network.
|
|
||||||
#dhcp-range=set:red,192.168.0.50,192.168.0.150
|
|
||||||
|
|
||||||
# Use this DHCP range only when the tag "green" is set.
|
|
||||||
#dhcp-range=tag:green,192.168.0.50,192.168.0.150,12h
|
|
||||||
|
|
||||||
# Specify a subnet which can't be used for dynamic address allocation,
|
|
||||||
# is available for hosts with matching --dhcp-host lines. Note that
|
|
||||||
# dhcp-host declarations will be ignored unless there is a dhcp-range
|
|
||||||
# of some type for the subnet in question.
|
|
||||||
# In this case the netmask is implied (it comes from the network
|
|
||||||
# configuration on the machine running dnsmasq) it is possible to give
|
|
||||||
# an explicit netmask instead.
|
|
||||||
#dhcp-range=192.168.0.0,static
|
|
||||||
|
|
||||||
# Enable DHCPv6. Note that the prefix-length does not need to be specified
|
|
||||||
# and defaults to 64 if missing/
|
|
||||||
#dhcp-range=1234::2, 1234::500, 64, 12h
|
|
||||||
|
|
||||||
# Do Router Advertisements, BUT NOT DHCP for this subnet.
|
|
||||||
#dhcp-range=1234::, ra-only
|
|
||||||
|
|
||||||
# Do Router Advertisements, BUT NOT DHCP for this subnet, also try and
|
|
||||||
# add names to the DNS for the IPv6 address of SLAAC-configured dual-stack
|
|
||||||
# hosts. Use the DHCPv4 lease to derive the name, network segment and
|
|
||||||
# MAC address and assume that the host will also have an
|
|
||||||
# IPv6 address calculated using the SLAAC algorithm.
|
|
||||||
#dhcp-range=1234::, ra-names
|
|
||||||
|
|
||||||
# Do Router Advertisements, BUT NOT DHCP for this subnet.
|
|
||||||
# Set the lifetime to 46 hours. (Note: minimum lifetime is 2 hours.)
|
|
||||||
#dhcp-range=1234::, ra-only, 48h
|
|
||||||
|
|
||||||
# Do DHCP and Router Advertisements for this subnet. Set the A bit in the RA
|
|
||||||
# so that clients can use SLAAC addresses as well as DHCP ones.
|
|
||||||
#dhcp-range=1234::2, 1234::500, slaac
|
|
||||||
|
|
||||||
# Do Router Advertisements and stateless DHCP for this subnet. Clients will
|
|
||||||
# not get addresses from DHCP, but they will get other configuration information.
|
|
||||||
# They will use SLAAC for addresses.
|
|
||||||
#dhcp-range=1234::, ra-stateless
|
|
||||||
|
|
||||||
# Do stateless DHCP, SLAAC, and generate DNS names for SLAAC addresses
|
|
||||||
# from DHCPv4 leases.
|
|
||||||
#dhcp-range=1234::, ra-stateless, ra-names
|
|
||||||
|
|
||||||
# Do router advertisements for all subnets where we're doing DHCPv6
|
|
||||||
# Unless overridden by ra-stateless, ra-names, et al, the router
|
|
||||||
# advertisements will have the M and O bits set, so that the clients
|
|
||||||
# get addresses and configuration from DHCPv6, and the A bit reset, so the
|
|
||||||
# clients don't use SLAAC addresses.
|
|
||||||
#enable-ra
|
|
||||||
|
|
||||||
# Supply parameters for specified hosts using DHCP. There are lots
|
|
||||||
# of valid alternatives, so we will give examples of each. Note that
|
|
||||||
# IP addresses DO NOT have to be in the range given above, they just
|
|
||||||
# need to be on the same network. The order of the parameters in these
|
|
||||||
# do not matter, it's permissible to give name, address and MAC in any
|
|
||||||
# order.
|
|
||||||
|
|
||||||
# Always allocate the host with Ethernet address 11:22:33:44:55:66
|
|
||||||
# The IP address 192.168.0.60
|
|
||||||
#dhcp-host=11:22:33:44:55:66,192.168.0.60
|
|
||||||
|
|
||||||
# Always set the name of the host with hardware address
|
|
||||||
# 11:22:33:44:55:66 to be "fred"
|
|
||||||
#dhcp-host=11:22:33:44:55:66,fred
|
|
||||||
|
|
||||||
# Always give the host with Ethernet address 11:22:33:44:55:66
|
|
||||||
# the name fred and IP address 192.168.0.60 and lease time 45 minutes
|
|
||||||
#dhcp-host=11:22:33:44:55:66,fred,192.168.0.60,45m
|
|
||||||
|
|
||||||
# Give a host with Ethernet address 11:22:33:44:55:66 or
|
|
||||||
# 12:34:56:78:90:12 the IP address 192.168.0.60. Dnsmasq will assume
|
|
||||||
# that these two Ethernet interfaces will never be in use at the same
|
|
||||||
# time, and give the IP address to the second, even if it is already
|
|
||||||
# in use by the first. Useful for laptops with wired and wireless
|
|
||||||
# addresses.
|
|
||||||
#dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.60
|
|
||||||
|
|
||||||
# Give the machine which says its name is "bert" IP address
|
|
||||||
# 192.168.0.70 and an infinite lease
|
|
||||||
#dhcp-host=bert,192.168.0.70,infinite
|
|
||||||
|
|
||||||
# Always give the host with client identifier 01:02:02:04
|
|
||||||
# the IP address 192.168.0.60
|
|
||||||
#dhcp-host=id:01:02:02:04,192.168.0.60
|
|
||||||
|
|
||||||
# Always give the InfiniBand interface with hardware address
|
|
||||||
# 80:00:00:48:fe:80:00:00:00:00:00:00:f4:52:14:03:00:28:05:81 the
|
|
||||||
# ip address 192.168.0.61. The client id is derived from the prefix
|
|
||||||
# ff:00:00:00:00:00:02:00:00:02:c9:00 and the last 8 pairs of
|
|
||||||
# hex digits of the hardware address.
|
|
||||||
#dhcp-host=id:ff:00:00:00:00:00:02:00:00:02:c9:00:f4:52:14:03:00:28:05:81,192.168.0.61
|
|
||||||
|
|
||||||
# Always give the host with client identifier "marjorie"
|
|
||||||
# the IP address 192.168.0.60
|
|
||||||
#dhcp-host=id:marjorie,192.168.0.60
|
|
||||||
|
|
||||||
# Enable the address given for "judge" in /etc/hosts
|
|
||||||
# to be given to a machine presenting the name "judge" when
|
|
||||||
# it asks for a DHCP lease.
|
|
||||||
#dhcp-host=judge
|
|
||||||
|
|
||||||
# Never offer DHCP service to a machine whose Ethernet
|
|
||||||
# address is 11:22:33:44:55:66
|
|
||||||
#dhcp-host=11:22:33:44:55:66,ignore
|
|
||||||
|
|
||||||
# Ignore any client-id presented by the machine with Ethernet
|
|
||||||
# address 11:22:33:44:55:66. This is useful to prevent a machine
|
|
||||||
# being treated differently when running under different OS's or
|
|
||||||
# between PXE boot and OS boot.
|
|
||||||
#dhcp-host=11:22:33:44:55:66,id:*
|
|
||||||
|
|
||||||
# Send extra options which are tagged as "red" to
|
|
||||||
# the machine with Ethernet address 11:22:33:44:55:66
|
|
||||||
#dhcp-host=11:22:33:44:55:66,set:red
|
|
||||||
|
|
||||||
# Send extra options which are tagged as "red" to
|
|
||||||
# any machine with Ethernet address starting 11:22:33:
|
|
||||||
#dhcp-host=11:22:33:*:*:*,set:red
|
|
||||||
|
|
||||||
# Give a fixed IPv6 address and name to client with
|
|
||||||
# DUID 00:01:00:01:16:d2:83:fc:92:d4:19:e2:d8:b2
|
|
||||||
# Note the MAC addresses CANNOT be used to identify DHCPv6 clients.
|
|
||||||
# Note also that the [] around the IPv6 address are obligatory.
|
|
||||||
#dhcp-host=id:00:01:00:01:16:d2:83:fc:92:d4:19:e2:d8:b2, fred, [1234::5]
|
|
||||||
|
|
||||||
# Ignore any clients which are not specified in dhcp-host lines
|
|
||||||
# or /etc/ethers. Equivalent to ISC "deny unknown-clients".
|
|
||||||
# This relies on the special "known" tag which is set when
|
|
||||||
# a host is matched.
|
|
||||||
#dhcp-ignore=tag:!known
|
|
||||||
|
|
||||||
# Send extra options which are tagged as "red" to any machine whose
|
|
||||||
# DHCP vendorclass string includes the substring "Linux"
|
|
||||||
#dhcp-vendorclass=set:red,Linux
|
|
||||||
|
|
||||||
# Send extra options which are tagged as "red" to any machine one
|
|
||||||
# of whose DHCP userclass strings includes the substring "accounts"
|
|
||||||
#dhcp-userclass=set:red,accounts
|
|
||||||
|
|
||||||
# Send extra options which are tagged as "red" to any machine whose
|
|
||||||
# MAC address matches the pattern.
|
|
||||||
#dhcp-mac=set:red,00:60:8C:*:*:*
|
|
||||||
|
|
||||||
# If this line is uncommented, dnsmasq will read /etc/ethers and act
|
|
||||||
# on the ethernet-address/IP pairs found there just as if they had
|
|
||||||
# been given as --dhcp-host options. Useful if you keep
|
|
||||||
# MAC-address/host mappings there for other purposes.
|
|
||||||
#read-ethers
|
|
||||||
|
|
||||||
# Send options to hosts which ask for a DHCP lease.
|
|
||||||
# See RFC 2132 for details of available options.
|
|
||||||
# Common options can be given to dnsmasq by name:
|
|
||||||
# run "dnsmasq --help dhcp" to get a list.
|
|
||||||
# Note that all the common settings, such as netmask and
|
|
||||||
# broadcast address, DNS server and default route, are given
|
|
||||||
# sane defaults by dnsmasq. You very likely will not need
|
|
||||||
# any dhcp-options. If you use Windows clients and Samba, there
|
|
||||||
# are some options which are recommended, they are detailed at the
|
|
||||||
# end of this section.
|
|
||||||
|
|
||||||
# Override the default route supplied by dnsmasq, which assumes the
|
|
||||||
# router is the same machine as the one running dnsmasq.
|
|
||||||
#dhcp-option=3,1.2.3.4
|
|
||||||
|
|
||||||
# Do the same thing, but using the option name
|
|
||||||
#dhcp-option=option:router,1.2.3.4
|
|
||||||
|
|
||||||
# Override the default route supplied by dnsmasq and send no default
|
|
||||||
# route at all. Note that this only works for the options sent by
|
|
||||||
# default (1, 3, 6, 12, 28) the same line will send a zero-length option
|
|
||||||
# for all other option numbers.
|
|
||||||
#dhcp-option=3
|
|
||||||
|
|
||||||
# Set the NTP time server addresses to 192.168.0.4 and 10.10.0.5
|
|
||||||
#dhcp-option=option:ntp-server,192.168.0.4,10.10.0.5
|
|
||||||
|
|
||||||
# Send DHCPv6 option. Note [] around IPv6 addresses.
|
|
||||||
#dhcp-option=option6:dns-server,[1234::77],[1234::88]
|
|
||||||
|
|
||||||
# Send DHCPv6 option for namservers as the machine running
|
|
||||||
# dnsmasq and another.
|
|
||||||
#dhcp-option=option6:dns-server,[::],[1234::88]
|
|
||||||
|
|
||||||
# Ask client to poll for option changes every six hours. (RFC4242)
|
|
||||||
#dhcp-option=option6:information-refresh-time,6h
|
|
||||||
|
|
||||||
# Set option 58 client renewal time (T1). Defaults to half of the
|
|
||||||
# lease time if not specified. (RFC2132)
|
|
||||||
#dhcp-option=option:T1,1m
|
|
||||||
|
|
||||||
# Set option 59 rebinding time (T2). Defaults to 7/8 of the
|
|
||||||
# lease time if not specified. (RFC2132)
|
|
||||||
#dhcp-option=option:T2,2m
|
|
||||||
|
|
||||||
# Set the NTP time server address to be the same machine as
|
|
||||||
# is running dnsmasq
|
|
||||||
#dhcp-option=42,0.0.0.0
|
|
||||||
|
|
||||||
# Set the NIS domain name to "welly"
|
|
||||||
#dhcp-option=40,welly
|
|
||||||
|
|
||||||
# Set the default time-to-live to 50
|
|
||||||
#dhcp-option=23,50
|
|
||||||
|
|
||||||
# Set the "all subnets are local" flag
|
|
||||||
#dhcp-option=27,1
|
|
||||||
|
|
||||||
# Send the etherboot magic flag and then etherboot options (a string).
|
|
||||||
#dhcp-option=128,e4:45:74:68:00:00
|
|
||||||
#dhcp-option=129,NIC=eepro100
|
|
||||||
|
|
||||||
# Specify an option which will only be sent to the "red" network
|
|
||||||
# (see dhcp-range for the declaration of the "red" network)
|
|
||||||
# Note that the tag: part must precede the option: part.
|
|
||||||
#dhcp-option = tag:red, option:ntp-server, 192.168.1.1
|
|
||||||
|
|
||||||
# The following DHCP options set up dnsmasq in the same way as is specified
|
|
||||||
# for the ISC dhcpcd in
|
|
||||||
# https://web.archive.org/web/20040313070105/http://us1.samba.org/samba/ftp/docs/textdocs/DHCP-Server-Configuration.txt
|
|
||||||
# adapted for a typical dnsmasq installation where the host running
|
|
||||||
# dnsmasq is also the host running samba.
|
|
||||||
# you may want to uncomment some or all of them if you use
|
|
||||||
# Windows clients and Samba.
|
|
||||||
#dhcp-option=19,0 # option ip-forwarding off
|
|
||||||
#dhcp-option=44,0.0.0.0 # set netbios-over-TCP/IP nameserver(s) aka WINS server(s)
|
|
||||||
#dhcp-option=45,0.0.0.0 # netbios datagram distribution server
|
|
||||||
#dhcp-option=46,8 # netbios node type
|
|
||||||
|
|
||||||
# Send an empty WPAD option. This may be REQUIRED to get windows 7 to behave.
|
|
||||||
#dhcp-option=252,"\n"
|
|
||||||
|
|
||||||
# Send RFC-3397 DNS domain search DHCP option. WARNING: Your DHCP client
|
|
||||||
# probably doesn't support this......
|
|
||||||
#dhcp-option=option:domain-search,eng.apple.com,marketing.apple.com
|
|
||||||
|
|
||||||
# Send RFC-3442 classless static routes (note the netmask encoding)
|
|
||||||
#dhcp-option=121,192.168.1.0/24,1.2.3.4,10.0.0.0/8,5.6.7.8
|
|
||||||
|
|
||||||
# Send vendor-class specific options encapsulated in DHCP option 43.
|
|
||||||
# The meaning of the options is defined by the vendor-class so
|
|
||||||
# options are sent only when the client supplied vendor class
|
|
||||||
# matches the class given here. (A substring match is OK, so "MSFT"
|
|
||||||
# matches "MSFT" and "MSFT 5.0"). This example sets the
|
|
||||||
# mtftp address to 0.0.0.0 for PXEClients.
|
|
||||||
#dhcp-option=vendor:PXEClient,1,0.0.0.0
|
|
||||||
|
|
||||||
# Send microsoft-specific option to tell windows to release the DHCP lease
|
|
||||||
# when it shuts down. Note the "i" flag, to tell dnsmasq to send the
|
|
||||||
# value as a four-byte integer - that's what microsoft wants. See
|
|
||||||
# http://technet2.microsoft.com/WindowsServer/en/library/a70f1bb7-d2d4-49f0-96d6-4b7414ecfaae1033.mspx?mfr=true
|
|
||||||
#dhcp-option=vendor:MSFT,2,1i
|
|
||||||
|
|
||||||
# Send the Encapsulated-vendor-class ID needed by some configurations of
|
|
||||||
# Etherboot to allow is to recognise the DHCP server.
|
|
||||||
#dhcp-option=vendor:Etherboot,60,"Etherboot"
|
|
||||||
|
|
||||||
# Send options to PXELinux. Note that we need to send the options even
|
|
||||||
# though they don't appear in the parameter request list, so we need
|
|
||||||
# to use dhcp-option-force here.
|
|
||||||
# See http://syslinux.zytor.com/pxe.php#special for details.
|
|
||||||
# Magic number - needed before anything else is recognised
|
|
||||||
#dhcp-option-force=208,f1:00:74:7e
|
|
||||||
# Configuration file name
|
|
||||||
#dhcp-option-force=209,configs/common
|
|
||||||
# Path prefix
|
|
||||||
#dhcp-option-force=210,/tftpboot/pxelinux/files/
|
|
||||||
# Reboot time. (Note 'i' to send 32-bit value)
|
|
||||||
#dhcp-option-force=211,30i
|
|
||||||
|
|
||||||
# Set the boot filename for netboot/PXE. You will only need
|
|
||||||
# this if you want to boot machines over the network and you will need
|
|
||||||
# a TFTP server; either dnsmasq's built-in TFTP server or an
|
|
||||||
# external one. (See below for how to enable the TFTP server.)
|
|
||||||
#dhcp-boot=pxelinux.0
|
|
||||||
|
|
||||||
# The same as above, but use custom tftp-server instead machine running dnsmasq
|
|
||||||
#dhcp-boot=pxelinux,server.name,192.168.1.100
|
|
||||||
|
|
||||||
# Boot for iPXE. The idea is to send two different
|
|
||||||
# filenames, the first loads iPXE, and the second tells iPXE what to
|
|
||||||
# load. The dhcp-match sets the ipxe tag for requests from iPXE.
|
|
||||||
#dhcp-boot=undionly.kpxe
|
|
||||||
#dhcp-match=set:ipxe,175 # iPXE sends a 175 option.
|
|
||||||
#dhcp-boot=tag:ipxe,http://boot.ipxe.org/demo/boot.php
|
|
||||||
|
|
||||||
# Encapsulated options for iPXE. All the options are
|
|
||||||
# encapsulated within option 175
|
|
||||||
#dhcp-option=encap:175, 1, 5b # priority code
|
|
||||||
#dhcp-option=encap:175, 176, 1b # no-proxydhcp
|
|
||||||
#dhcp-option=encap:175, 177, string # bus-id
|
|
||||||
#dhcp-option=encap:175, 189, 1b # BIOS drive code
|
|
||||||
#dhcp-option=encap:175, 190, user # iSCSI username
|
|
||||||
#dhcp-option=encap:175, 191, pass # iSCSI password
|
|
||||||
|
|
||||||
# Test for the architecture of a netboot client. PXE clients are
|
|
||||||
# supposed to send their architecture as option 93. (See RFC 4578)
|
|
||||||
#dhcp-match=peecees, option:client-arch, 0 #x86-32
|
|
||||||
#dhcp-match=itanics, option:client-arch, 2 #IA64
|
|
||||||
#dhcp-match=hammers, option:client-arch, 6 #x86-64
|
|
||||||
#dhcp-match=mactels, option:client-arch, 7 #EFI x86-64
|
|
||||||
|
|
||||||
# Do real PXE, rather than just booting a single file, this is an
|
|
||||||
# alternative to dhcp-boot.
|
|
||||||
#pxe-prompt="What system shall I netboot?"
|
|
||||||
# or with timeout before first available action is taken:
|
|
||||||
#pxe-prompt="Press F8 for menu.", 60
|
|
||||||
|
|
||||||
# Available boot services. for PXE.
|
|
||||||
#pxe-service=x86PC, "Boot from local disk"
|
|
||||||
|
|
||||||
# Loads <tftp-root>/pxelinux.0 from dnsmasq TFTP server.
|
|
||||||
#pxe-service=x86PC, "Install Linux", pxelinux
|
|
||||||
|
|
||||||
# Loads <tftp-root>/pxelinux.0 from TFTP server at 1.2.3.4.
|
|
||||||
# Beware this fails on old PXE ROMS.
|
|
||||||
#pxe-service=x86PC, "Install Linux", pxelinux, 1.2.3.4
|
|
||||||
|
|
||||||
# Use bootserver on network, found my multicast or broadcast.
|
|
||||||
#pxe-service=x86PC, "Install windows from RIS server", 1
|
|
||||||
|
|
||||||
# Use bootserver at a known IP address.
|
|
||||||
#pxe-service=x86PC, "Install windows from RIS server", 1, 1.2.3.4
|
|
||||||
|
|
||||||
# If you have multicast-FTP available,
|
|
||||||
# information for that can be passed in a similar way using options 1
|
|
||||||
# to 5. See page 19 of
|
|
||||||
# http://download.intel.com/design/archives/wfm/downloads/pxespec.pdf
|
|
||||||
|
|
||||||
|
|
||||||
# Enable dnsmasq's built-in TFTP server
|
|
||||||
#enable-tftp
|
|
||||||
|
|
||||||
# Set the root directory for files available via FTP.
|
|
||||||
#tftp-root=/var/ftpd
|
|
||||||
|
|
||||||
# Do not abort if the tftp-root is unavailable
|
|
||||||
#tftp-no-fail
|
|
||||||
|
|
||||||
# Make the TFTP server more secure: with this set, only files owned by
|
|
||||||
# the user dnsmasq is running as will be send over the net.
|
|
||||||
#tftp-secure
|
|
||||||
|
|
||||||
# This option stops dnsmasq from negotiating a larger blocksize for TFTP
|
|
||||||
# transfers. It will slow things down, but may rescue some broken TFTP
|
|
||||||
# clients.
|
|
||||||
#tftp-no-blocksize
|
|
||||||
|
|
||||||
# Set the boot file name only when the "red" tag is set.
|
|
||||||
#dhcp-boot=tag:red,pxelinux.red-net
|
|
||||||
|
|
||||||
# An example of dhcp-boot with an external TFTP server: the name and IP
|
|
||||||
# address of the server are given after the filename.
|
|
||||||
# Can fail with old PXE ROMS. Overridden by --pxe-service.
|
|
||||||
#dhcp-boot=/var/ftpd/pxelinux.0,boothost,192.168.0.3
|
|
||||||
|
|
||||||
# If there are multiple external tftp servers having a same name
|
|
||||||
# (using /etc/hosts) then that name can be specified as the
|
|
||||||
# tftp_servername (the third option to dhcp-boot) and in that
|
|
||||||
# case dnsmasq resolves this name and returns the resultant IP
|
|
||||||
# addresses in round robin fashion. This facility can be used to
|
|
||||||
# load balance the tftp load among a set of servers.
|
|
||||||
#dhcp-boot=/var/ftpd/pxelinux.0,boothost,tftp_server_name
|
|
||||||
|
|
||||||
# Set the limit on DHCP leases, the default is 150
|
|
||||||
#dhcp-lease-max=150
|
|
||||||
|
|
||||||
# The DHCP server needs somewhere on disk to keep its lease database.
|
|
||||||
# This defaults to a sane location, but if you want to change it, use
|
|
||||||
# the line below.
|
|
||||||
#dhcp-leasefile=/var/lib/misc/dnsmasq.leases
|
|
||||||
|
|
||||||
# Set the DHCP server to authoritative mode. In this mode it will barge in
|
|
||||||
# and take over the lease for any client which broadcasts on the network,
|
|
||||||
# whether it has a record of the lease or not. This avoids long timeouts
|
|
||||||
# when a machine wakes up on a new network. DO NOT enable this if there's
|
|
||||||
# the slightest chance that you might end up accidentally configuring a DHCP
|
|
||||||
# server for your campus/company accidentally. The ISC server uses
|
|
||||||
# the same option, and this URL provides more information:
|
|
||||||
# http://www.isc.org/files/auth.html
|
|
||||||
#dhcp-authoritative
|
|
||||||
|
|
||||||
# Set the DHCP server to enable DHCPv4 Rapid Commit Option per RFC 4039.
|
|
||||||
# In this mode it will respond to a DHCPDISCOVER message including a Rapid Commit
|
|
||||||
# option with a DHCPACK including a Rapid Commit option and fully committed address
|
|
||||||
# and configuration information. This must only be enabled if either the server is
|
|
||||||
# the only server for the subnet, or multiple servers are present and they each
|
|
||||||
# commit a binding for all clients.
|
|
||||||
#dhcp-rapid-commit
|
|
||||||
|
|
||||||
# Run an executable when a DHCP lease is created or destroyed.
|
|
||||||
# The arguments sent to the script are "add" or "del",
|
|
||||||
# then the MAC address, the IP address and finally the hostname
|
|
||||||
# if there is one.
|
|
||||||
#dhcp-script=/bin/echo
|
|
||||||
|
|
||||||
# Set the cachesize here.
|
|
||||||
cache-size=1000
|
|
||||||
|
|
||||||
# If you want to disable negative caching, uncomment this.
|
|
||||||
#no-negcache
|
|
||||||
|
|
||||||
# Normally responses which come from /etc/hosts and the DHCP lease
|
|
||||||
# file have Time-To-Live set as zero, which conventionally means
|
|
||||||
# do not cache further. If you are happy to trade lower load on the
|
|
||||||
# server for potentially stale date, you can set a time-to-live (in
|
|
||||||
# seconds) here.
|
|
||||||
#local-ttl=
|
|
||||||
|
|
||||||
# If you want dnsmasq to detect attempts by Verisign to send queries
|
|
||||||
# to unregistered .com and .net hosts to its sitefinder service and
|
|
||||||
# have dnsmasq instead return the correct NXDOMAIN response, uncomment
|
|
||||||
# this line. You can add similar lines to do the same for other
|
|
||||||
# registries which have implemented wildcard A records.
|
|
||||||
#bogus-nxdomain=64.94.110.11
|
|
||||||
|
|
||||||
# If you want to fix up DNS results from upstream servers, use the
|
|
||||||
# alias option. This only works for IPv4.
|
|
||||||
# This alias makes a result of 1.2.3.4 appear as 5.6.7.8
|
|
||||||
#alias=1.2.3.4,5.6.7.8
|
|
||||||
# and this maps 1.2.3.x to 5.6.7.x
|
|
||||||
#alias=1.2.3.0,5.6.7.0,255.255.255.0
|
|
||||||
# and this maps 192.168.0.10->192.168.0.40 to 10.0.0.10->10.0.0.40
|
|
||||||
#alias=192.168.0.10-192.168.0.40,10.0.0.0,255.255.255.0
|
|
||||||
|
|
||||||
# Change these lines if you want dnsmasq to serve MX records.
|
|
||||||
|
|
||||||
# Return an MX record named "maildomain.com" with target
|
|
||||||
# servermachine.com and preference 50
|
|
||||||
#mx-host=maildomain.com,servermachine.com,50
|
|
||||||
|
|
||||||
# Set the default target for MX records created using the localmx option.
|
|
||||||
#mx-target=servermachine.com
|
|
||||||
|
|
||||||
# Return an MX record pointing to the mx-target for all local
|
|
||||||
# machines.
|
|
||||||
#localmx
|
|
||||||
|
|
||||||
# Return an MX record pointing to itself for all local machines.
|
|
||||||
#selfmx
|
|
||||||
|
|
||||||
# Change the following lines if you want dnsmasq to serve SRV
|
|
||||||
# records. These are useful if you want to serve ldap requests for
|
|
||||||
# Active Directory and other windows-originated DNS requests.
|
|
||||||
# See RFC 2782.
|
|
||||||
# You may add multiple srv-host lines.
|
|
||||||
# The fields are <name>,<target>,<port>,<priority>,<weight>
|
|
||||||
# If the domain part if missing from the name (so that is just has the
|
|
||||||
# service and protocol sections) then the domain given by the domain=
|
|
||||||
# config option is used. (Note that expand-hosts does not need to be
|
|
||||||
# set for this to work.)
|
|
||||||
|
|
||||||
# A SRV record sending LDAP for the example.com domain to
|
|
||||||
# ldapserver.example.com port 389
|
|
||||||
#srv-host=_ldap._tcp.example.com,ldapserver.example.com,389
|
|
||||||
|
|
||||||
# A SRV record sending LDAP for the example.com domain to
|
|
||||||
# ldapserver.example.com port 389 (using domain=)
|
|
||||||
#domain=example.com
|
|
||||||
#srv-host=_ldap._tcp,ldapserver.example.com,389
|
|
||||||
|
|
||||||
# Two SRV records for LDAP, each with different priorities
|
|
||||||
#srv-host=_ldap._tcp.example.com,ldapserver.example.com,389,1
|
|
||||||
#srv-host=_ldap._tcp.example.com,ldapserver.example.com,389,2
|
|
||||||
|
|
||||||
# A SRV record indicating that there is no LDAP server for the domain
|
|
||||||
# example.com
|
|
||||||
#srv-host=_ldap._tcp.example.com
|
|
||||||
|
|
||||||
# The following line shows how to make dnsmasq serve an arbitrary PTR
|
|
||||||
# record. This is useful for DNS-SD. (Note that the
|
|
||||||
# domain-name expansion done for SRV records _does_not
|
|
||||||
# occur for PTR records.)
|
|
||||||
#ptr-record=_http._tcp.dns-sd-services,"New Employee Page._http._tcp.dns-sd-services"
|
|
||||||
|
|
||||||
# Change the following lines to enable dnsmasq to serve TXT records.
|
|
||||||
# These are used for things like SPF and zeroconf. (Note that the
|
|
||||||
# domain-name expansion done for SRV records _does_not
|
|
||||||
# occur for TXT records.)
|
|
||||||
|
|
||||||
#Example SPF.
|
|
||||||
#txt-record=example.com,"v=spf1 a -all"
|
|
||||||
|
|
||||||
#Example zeroconf
|
|
||||||
#txt-record=_http._tcp.example.com,name=value,paper=A4
|
|
||||||
|
|
||||||
# Provide an alias for a "local" DNS name. Note that this _only_ works
|
|
||||||
# for targets which are names from DHCP or /etc/hosts. Give host
|
|
||||||
# "bert" another name, bertrand
|
|
||||||
#cname=bertrand,bert
|
|
||||||
|
|
||||||
# For debugging purposes, log each DNS query as it passes through
|
|
||||||
# dnsmasq.
|
|
||||||
#log-queries
|
|
||||||
|
|
||||||
# Log lots of extra information about DHCP transactions.
|
|
||||||
#log-dhcp
|
|
||||||
|
|
||||||
# Include another lot of configuration options.
|
|
||||||
#conf-file=/etc/dnsmasq.more.conf
|
|
||||||
#conf-dir=/etc/dnsmasq.d
|
|
||||||
|
|
||||||
# Include all the files in a directory except those ending in .bak
|
|
||||||
#conf-dir=/etc/dnsmasq.d,.bak
|
|
||||||
|
|
||||||
# Include all files in a directory which end in .conf
|
|
||||||
#conf-dir=/etc/dnsmasq.d/,*.conf
|
|
||||||
|
|
||||||
# If a DHCP client claims that its name is "wpad", ignore that.
|
|
||||||
# This fixes a security hole. see CERT Vulnerability VU#598349
|
|
||||||
#dhcp-name-match=set:wpad-ignore,wpad
|
|
||||||
#dhcp-ignore-names=tag:wpad-ignore
|
|
||||||
24
docker-compose-backup.yml
Normal file
24
docker-compose-backup.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
backup:
|
||||||
|
restart: no
|
||||||
|
image: alpine
|
||||||
|
volumes:
|
||||||
|
- ${BACKUP_VOLUME}:/data
|
||||||
|
command: tar czvf /backup/${BACKUP_DATE}/${BACKUP_NAME}.tar.gz -C /data .
|
||||||
|
|
||||||
|
restore:
|
||||||
|
restart: no
|
||||||
|
image: alpine
|
||||||
|
volumes:
|
||||||
|
- ${BACKUP_VOLUME}:/data
|
||||||
|
- ${SOURCE_BACKUP_FOLDER:-/tmp}:/backup
|
||||||
|
command: tar xzvf /backup/${BACKUP_NAME}.tar.gz -C /data
|
||||||
|
|
||||||
|
inspect:
|
||||||
|
restart: no
|
||||||
|
image: alpine
|
||||||
|
volumes:
|
||||||
|
- ./contents:/data
|
||||||
|
- ./backup:/backup
|
||||||
|
command: tar xzvf /backup/${BACKUP_NAME}.tar.gz -C /data
|
||||||
|
|
||||||
@@ -5,19 +5,20 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 1883:1883
|
- 1883:1883
|
||||||
volumes:
|
volumes:
|
||||||
- ${REPO_DIR}/home-server/mosquitto/config:/mosquitto/config:rw
|
- ${REPO_DIR}/configurations/mosquitto/config:/mosquitto/config:rw
|
||||||
- ${STORAGE_DIR}/mosquitto/data:/mosquitto/data:rw
|
- mosquitto-data:/mosquitto
|
||||||
- ${STORAGE_DIR}/mosquitto/log:/mosquitto/log:rw
|
|
||||||
traefik:
|
traefik:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
image: traefik:v3.2
|
image: traefik:v3.6.24
|
||||||
labels:
|
labels:
|
||||||
- traefik.http.routers.dashboard.rule=Host(`traefik.pladijs`)
|
- traefik.http.routers.dashboard.rule=Host(`traefik.pladijs`)
|
||||||
- traefik.http.services.dashboard.loadbalancer.server.port=8080
|
- traefik.http.services.dashboard.loadbalancer.server.port=8080
|
||||||
volumes:
|
volumes:
|
||||||
- ${REPO_DIR}/configurations/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
- ${REPO_DIR}/configurations/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
- ${STORAGE_DIR}/traefik/certificates:/var/lib/certificates
|
- traefik-data:/var/lib/certificates
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
extra_hosts:
|
||||||
|
- host.docker.internal:host-gateway
|
||||||
ports:
|
ports:
|
||||||
- 80:80
|
- 80:80
|
||||||
- 443:443
|
- 443:443
|
||||||
@@ -29,25 +30,20 @@ services:
|
|||||||
- traefik.http.services.whoami.loadbalancer.server.port=2001
|
- traefik.http.services.whoami.loadbalancer.server.port=2001
|
||||||
command:
|
command:
|
||||||
- --port=2001
|
- --port=2001
|
||||||
db:
|
|
||||||
image: mariadb:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- 3306:3306
|
|
||||||
env_file:
|
|
||||||
- environment-variables/local.env
|
|
||||||
- environment-variables/production.env
|
|
||||||
volumes:
|
|
||||||
- ${STORAGE_DIR}/mariadb:/var/lib/mysql
|
|
||||||
homeassistant:
|
homeassistant:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./dockerfiles/home-assistant.Dockerfile
|
dockerfile: ./dockerfiles/home-assistant.Dockerfile
|
||||||
|
network_mode: host
|
||||||
labels:
|
labels:
|
||||||
- traefik.http.routers.homeassistant.rule=Host(`homeassistant.pladijs`)
|
- traefik.http.routers.homeassistant.rule=Host(`homeassistant.pladijs`)
|
||||||
- traefik.http.services.homeassistant.loadbalancer.server.port=8123
|
- traefik.http.services.homeassistant.loadbalancer.server.url=http://host.docker.internal:8123
|
||||||
volumes:
|
volumes:
|
||||||
- ${STORAGE_DIR}/homeassistant:/config
|
- homeassistant-data:/config
|
||||||
|
- /run/dbus:/run/dbus:ro
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- environment-variables/local.env
|
- environment-variables/local.env
|
||||||
@@ -62,7 +58,7 @@ services:
|
|||||||
- PGID=1000
|
- PGID=1000
|
||||||
- TZ=Europe/London
|
- TZ=Europe/London
|
||||||
volumes:
|
volumes:
|
||||||
- ${STORAGE_DIR}/syncthing:/var/syncthing
|
- syncthing-data:/var/syncthing
|
||||||
node-exporter:
|
node-exporter:
|
||||||
image: prom/node-exporter:latest
|
image: prom/node-exporter:latest
|
||||||
container_name: node-exporter
|
container_name: node-exporter
|
||||||
@@ -88,7 +84,7 @@ services:
|
|||||||
- traefik.http.services.prometheus.loadbalancer.server.port=9090
|
- traefik.http.services.prometheus.loadbalancer.server.port=9090
|
||||||
volumes:
|
volumes:
|
||||||
- ${REPO_DIR}/configurations/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
- ${REPO_DIR}/configurations/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
- ${STORAGE_DIR}/prometheus:/prometheus:rw
|
- prometheus-data:/prometheus:rw
|
||||||
command:
|
command:
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
- '--storage.tsdb.path=/prometheus'
|
- '--storage.tsdb.path=/prometheus'
|
||||||
@@ -99,7 +95,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
image: grafana/grafana:latest
|
image: grafana/grafana:latest
|
||||||
volumes:
|
volumes:
|
||||||
- ${STORAGE_DIR}/grafana:/var/lib/grafana
|
- grafana-data:/var/lib/grafana
|
||||||
env_file:
|
env_file:
|
||||||
- environment-variables/local.env
|
- environment-variables/local.env
|
||||||
- environment-variables/production.env
|
- environment-variables/production.env
|
||||||
@@ -121,7 +117,7 @@ services:
|
|||||||
- environment-variables/production.env
|
- environment-variables/production.env
|
||||||
volumes:
|
volumes:
|
||||||
- ${STORAGE_DIR}/influxdb/data:/var/lib/influxdb2
|
- ${STORAGE_DIR}/influxdb/data:/var/lib/influxdb2
|
||||||
- ${STORAGE_DIR}/influxdb/config/:/etc/influxdb2
|
- influxdb-data:/etc/influxdb2
|
||||||
ulimits:
|
ulimits:
|
||||||
nofile:
|
nofile:
|
||||||
soft: 32768
|
soft: 32768
|
||||||
@@ -135,7 +131,65 @@ services:
|
|||||||
# Only enable if external services present
|
# Only enable if external services present
|
||||||
# ports:
|
# ports:
|
||||||
# - 4317:4317 # OTLP gRPC receiver
|
# - 4317:4317 # OTLP gRPC receiver
|
||||||
power_control:
|
smappee-api:
|
||||||
|
build: ./smappee-api
|
||||||
|
env_file:
|
||||||
|
- .env.smappee
|
||||||
|
labels:
|
||||||
|
- traefik.http.routers.smappee.rule=Host(`smappee.pladijs`)
|
||||||
|
- traefik.http.services.smappee.loadbalancer.server.port=8080
|
||||||
|
restart: unless-stopped
|
||||||
|
energy-management:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./energy-management
|
||||||
dockerfile: ./dockerfiles/power_control.Dockerfile
|
labels:
|
||||||
|
- traefik.http.routers.energymanagement.rule=Host(`energymanagement.pladijs`)
|
||||||
|
- traefik.http.services.energymanagement.loadbalancer.server.port=3000
|
||||||
|
environment:
|
||||||
|
DB_PATH: /app/data/energy.db
|
||||||
|
# InfluxDB 2.x
|
||||||
|
INFLUX_URL: http://influxdb.pladijs
|
||||||
|
INFLUX_TOKEN: f9YTt1XmjUwtYuM1JXKyHaXU3D1NH3aRxf3jcP2cEyD0IfPmM-_Im35VpIAPct7oWKjk261rX5uzrQ4ue4tJFw==
|
||||||
|
INFLUX_ORG: homeassistant
|
||||||
|
INFLUX_BUCKET: homeassistant
|
||||||
|
# Measurement/field that holds live power draw for the whole house
|
||||||
|
INFLUX_FIELD: value
|
||||||
|
# Unit of INFLUX_FIELD as stored in Influx: W or kW
|
||||||
|
INFLUX_FIELD_UNIT: W
|
||||||
|
INFLUX_DOMAIN: sensor
|
||||||
|
INFLUX_ENTITY_ID: p1_meter_power
|
||||||
|
|
||||||
|
# Home Assistant
|
||||||
|
HA_URL: http://homeassistant.pladijs
|
||||||
|
HA_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxZjlhYzBjYjlkZDE0YWEzYWFhMzVjMzU2Y2VkM2U5MCIsImlhdCI6MTc4NzA4MjEyNywiZXhwIjoyMTAyNDQyMTI3fQ.W6MF78ZPiXF-__GmlFK2D-3vwYGEI3eEgouxw007D64
|
||||||
|
|
||||||
|
# Capacity tariff target: keep every 15-minute block's average under this many kW
|
||||||
|
TARGET_KW: 3.5
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
PORT: 3000
|
||||||
|
CONTROL_LOOP_INTERVAL_MS: 10000
|
||||||
|
SOLAR_INFLUX_MEASUREMENT: power
|
||||||
|
SOLAR_INFLUX_DOMAIN: sensor
|
||||||
|
SOLAR_INFLUX_ENTITY_ID: sb4_0_1av_41_812_pv_power
|
||||||
|
SOLAR_INFLUX_FIELD: value
|
||||||
|
SOLAR_INFLUX_FIELD_UNIT: W
|
||||||
|
SOLAR_PRODUCTION_THRESHOLD_W: 300
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- energy-management-data:/app/data
|
||||||
|
- /run/dbus:/run/dbus:ro
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prometheus-data:
|
||||||
|
influxdb-data:
|
||||||
|
grafana-data:
|
||||||
|
homeassistant-data:
|
||||||
|
mosquitto-data:
|
||||||
|
traefik-data:
|
||||||
|
syncthing-data:
|
||||||
|
energy-management-data:
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
FROM ghcr.io/home-assistant/home-assistant:stable
|
FROM ghcr.io/home-assistant/home-assistant:stable
|
||||||
|
|
||||||
COPY ./configurations/home-assistant/configuration-production.yaml /config/configuration.yaml
|
COPY ./configurations/home-assistant/configuration.yaml /config/configuration.yaml
|
||||||
|
COPY ./configurations/home-assistant/scenes.yaml /config/scenes.yaml
|
||||||
|
COPY ./configurations/home-assistant/scripts.yaml /config/scripts.yaml
|
||||||
|
COPY ./configurations/home-assistant/automations.yaml /config/automations.yaml
|
||||||
|
|||||||
34
energy-management/.env.example
Normal file
34
energy-management/.env.example
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# InfluxDB 2.x
|
||||||
|
INFLUX_URL=http://influxdb.local:8086
|
||||||
|
INFLUX_TOKEN=replace-with-a-read-scoped-token
|
||||||
|
INFLUX_ORG=your-org
|
||||||
|
INFLUX_BUCKET=your-bucket
|
||||||
|
# Measurement/field that holds live power draw for the whole house
|
||||||
|
INFLUX_MEASUREMENT=power
|
||||||
|
INFLUX_FIELD=value
|
||||||
|
# Unit of INFLUX_FIELD as stored in Influx: W or kW
|
||||||
|
INFLUX_FIELD_UNIT=W
|
||||||
|
|
||||||
|
# Home Assistant
|
||||||
|
HA_URL=http://homeassistant.local:8123
|
||||||
|
HA_TOKEN=replace-with-a-long-lived-access-token
|
||||||
|
|
||||||
|
# Solar inverter production, read from InfluxDB (same bucket as the house
|
||||||
|
# power series above). Its latest sample is compared against
|
||||||
|
# SOLAR_PRODUCTION_THRESHOLD_W to gate "only when sun" devices. Leave
|
||||||
|
# SOLAR_INFLUX_ENTITY_ID unset to fall back to HA's sun.sun (astronomical
|
||||||
|
# above/below horizon).
|
||||||
|
SOLAR_INFLUX_MEASUREMENT=power
|
||||||
|
SOLAR_INFLUX_DOMAIN=sensor
|
||||||
|
SOLAR_INFLUX_ENTITY_ID=inverter_pv_power
|
||||||
|
SOLAR_INFLUX_FIELD=value
|
||||||
|
SOLAR_INFLUX_FIELD_UNIT=W
|
||||||
|
SOLAR_PRODUCTION_THRESHOLD_W=50
|
||||||
|
|
||||||
|
# Capacity tariff target: keep every 15-minute block's average under this many kW
|
||||||
|
TARGET_KW=5
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
PORT=3000
|
||||||
|
DB_PATH=./data/energy.db
|
||||||
|
CONTROL_LOOP_INTERVAL_MS=10000
|
||||||
9
energy-management/.gitignore
vendored
Normal file
9
energy-management/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
27
energy-management/Dockerfile
Normal file
27
energy-management/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM node:22-slim AS build
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY package.json ./
|
||||||
|
COPY backend/package.json backend/package.json
|
||||||
|
COPY frontend/package.json frontend/package.json
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY backend backend
|
||||||
|
COPY frontend frontend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-slim AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=build /repo/package.json ./package.json
|
||||||
|
COPY --from=build /repo/backend/package.json ./backend/package.json
|
||||||
|
COPY --from=build /repo/node_modules ./node_modules
|
||||||
|
COPY --from=build /repo/backend/dist ./backend/dist
|
||||||
|
COPY --from=build /repo/frontend/dist ./backend/dist/public
|
||||||
|
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "backend/dist/index.js"]
|
||||||
57
energy-management/README.md
Normal file
57
energy-management/README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Peak Power Manager
|
||||||
|
|
||||||
|
Keeps your electricity capacity tariff in check by watching live power draw
|
||||||
|
(from InfluxDB) and shedding/restoring large consumers (via Home Assistant)
|
||||||
|
whenever the current 15-minute billing block is projected to exceed a target.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Belgian-style capacity tariffs bill on the highest 15-minute average power of
|
||||||
|
the month. Every `CONTROL_LOOP_INTERVAL_MS` (default 10s), the app:
|
||||||
|
|
||||||
|
1. Reads recent power samples for the current quarter-hour block from InfluxDB.
|
||||||
|
2. Projects what that block's final average will be if current draw continues.
|
||||||
|
3. If the projection exceeds `TARGET_KW`, it sheds the lowest-priority
|
||||||
|
configured device (turns a switch off, or lowers a number set-point to its
|
||||||
|
floor) via Home Assistant.
|
||||||
|
4. Once the projection is comfortably back under target, it restores the
|
||||||
|
highest-priority shed device.
|
||||||
|
|
||||||
|
A minimum dwell time per device (configurable) prevents rapid on/off cycling.
|
||||||
|
Devices with "manual override" enabled are left alone by the control loop.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and fill in your InfluxDB and Home Assistant
|
||||||
|
connection details, the measurement/field that holds live power, and your
|
||||||
|
target kW.
|
||||||
|
2. `docker compose up --build`
|
||||||
|
3. Open `http://localhost:3000`, go to the **Devices** tab, and add your EV
|
||||||
|
charger, heat pump, and any other large consumers with their Home
|
||||||
|
Assistant entity IDs, control type (switch or number), and priority
|
||||||
|
(lower priority number = shed first).
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run dev:backend # Fastify API on :3000
|
||||||
|
npm run dev:frontend # Vite dev server on :5173, proxies /api to :3000
|
||||||
|
npm test # backend unit tests (quarter-hour projection, control decisions)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `backend/src/influx` — queries live power samples from InfluxDB (Flux).
|
||||||
|
- `backend/src/control/quarterHour.ts` — pure logic computing the running and
|
||||||
|
projected average power for the current 15-minute block.
|
||||||
|
- `backend/src/control/loop.ts` — pure decision logic: which device to shed
|
||||||
|
or restore given the projection and device states.
|
||||||
|
- `backend/src/control/runner.ts` — orchestrates the above against real
|
||||||
|
InfluxDB/Home Assistant/SQLite on each tick.
|
||||||
|
- `backend/src/homeassistant` — Home Assistant REST client and device
|
||||||
|
shed/restore abstraction.
|
||||||
|
- `backend/src/db` — SQLite config store (devices, target, action log, block
|
||||||
|
history).
|
||||||
|
- `frontend/` — React dashboard: live status, device list, 30-day peak
|
||||||
|
history chart, recent actions log, and a Devices config page.
|
||||||
BIN
energy-management/backend/data/energy.db-shm
Normal file
BIN
energy-management/backend/data/energy.db-shm
Normal file
Binary file not shown.
BIN
energy-management/backend/data/energy.db-wal
Normal file
BIN
energy-management/backend/data/energy.db-wal
Normal file
Binary file not shown.
29
energy-management/backend/package.json
Normal file
29
energy-management/backend/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@fastify/static": "^10.1.3",
|
||||||
|
"@influxdata/influxdb-client": "^1.35.0",
|
||||||
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"fastify": "^5.2.1",
|
||||||
|
"pino": "^9.6.0",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^3.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
133
energy-management/backend/src/__tests__/loop.test.ts
Normal file
133
energy-management/backend/src/__tests__/loop.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { decideControlAction } from "../control/loop.js";
|
||||||
|
import type { DeviceStatus, QuarterHourStatus } from "../types.js";
|
||||||
|
|
||||||
|
function makeStatus(overrides: Partial<QuarterHourStatus> = {}): QuarterHourStatus {
|
||||||
|
return {
|
||||||
|
blockStartMs: 0,
|
||||||
|
blockEndMs: 900_000,
|
||||||
|
elapsedFractionOfBlock: 0.5,
|
||||||
|
runningAverageW: 4000,
|
||||||
|
projectedAverageW: 4000,
|
||||||
|
targetW: 5000,
|
||||||
|
overTarget: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDevice(overrides: Partial<DeviceStatus> = {}): DeviceStatus {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
entityId: "switch.test",
|
||||||
|
name: "Test Device",
|
||||||
|
kind: "switch",
|
||||||
|
priority: 1,
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 1,
|
||||||
|
dwellSeconds: 300,
|
||||||
|
manualOverride: false,
|
||||||
|
onlyWhenSun: false,
|
||||||
|
fromTime: null,
|
||||||
|
toTime: null,
|
||||||
|
enableUrl: null,
|
||||||
|
disableUrl: null,
|
||||||
|
currentValue: 1,
|
||||||
|
shed: false,
|
||||||
|
lastChangedAt: null,
|
||||||
|
available: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("decideControlAction", () => {
|
||||||
|
it("sheds the lowest-priority unshed device when over target", () => {
|
||||||
|
const status = makeStatus({ overTarget: true, projectedAverageW: 6000 });
|
||||||
|
const devices = [
|
||||||
|
makeDevice({ entityId: "switch.a", priority: 3 }),
|
||||||
|
makeDevice({ entityId: "switch.b", priority: 1 }),
|
||||||
|
makeDevice({ entityId: "switch.c", priority: 2 }),
|
||||||
|
];
|
||||||
|
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
|
||||||
|
expect(action).toMatchObject({ entityId: "switch.b", action: "shed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not shed devices under manual override or dwell lockout", () => {
|
||||||
|
const status = makeStatus({ overTarget: true, projectedAverageW: 6000 });
|
||||||
|
const devices = [
|
||||||
|
makeDevice({ entityId: "switch.a", priority: 1, manualOverride: true }),
|
||||||
|
makeDevice({
|
||||||
|
entityId: "switch.b",
|
||||||
|
priority: 2,
|
||||||
|
lastChangedAt: new Date(999_800).toISOString(),
|
||||||
|
dwellSeconds: 300,
|
||||||
|
}),
|
||||||
|
makeDevice({ entityId: "switch.c", priority: 3 }),
|
||||||
|
];
|
||||||
|
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
|
||||||
|
expect(action).toMatchObject({ entityId: "switch.c", action: "shed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when over target but no controllable device is available", () => {
|
||||||
|
const status = makeStatus({ overTarget: true, projectedAverageW: 6000 });
|
||||||
|
const devices = [makeDevice({ manualOverride: true }), makeDevice({ shed: true })];
|
||||||
|
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
|
||||||
|
expect(action).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the highest-priority shed device once margin is comfortable", () => {
|
||||||
|
const status = makeStatus({ overTarget: false, projectedAverageW: 3000, targetW: 5000 });
|
||||||
|
const devices = [
|
||||||
|
makeDevice({ entityId: "switch.a", priority: 3, shed: true }),
|
||||||
|
makeDevice({ entityId: "switch.b", priority: 1, shed: true }),
|
||||||
|
];
|
||||||
|
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
|
||||||
|
expect(action).toMatchObject({ entityId: "switch.a", action: "restore" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore when margin is below the hysteresis threshold", () => {
|
||||||
|
const status = makeStatus({ overTarget: false, projectedAverageW: 4800, targetW: 5000 });
|
||||||
|
const devices = [makeDevice({ shed: true })];
|
||||||
|
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
|
||||||
|
expect(action).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing is shed and under target", () => {
|
||||||
|
const status = makeStatus({ overTarget: false, projectedAverageW: 1000, targetW: 5000 });
|
||||||
|
const devices = [makeDevice({ shed: false })];
|
||||||
|
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: true });
|
||||||
|
expect(action).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sheds a sun-only device when the sun is down, even under target", () => {
|
||||||
|
const status = makeStatus({ overTarget: false, projectedAverageW: 1000, targetW: 5000 });
|
||||||
|
const devices = [makeDevice({ entityId: "switch.solar", onlyWhenSun: true, shed: false })];
|
||||||
|
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: false });
|
||||||
|
expect(action).toMatchObject({ entityId: "switch.solar", action: "shed", reason: "outside allowed sun hours" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore a sun-only device while the sun is down", () => {
|
||||||
|
const status = makeStatus({ overTarget: false, projectedAverageW: 3000, targetW: 5000 });
|
||||||
|
const devices = [makeDevice({ entityId: "switch.solar", onlyWhenSun: true, shed: true })];
|
||||||
|
const action = decideControlAction({ status, devices, nowMs: 1_000_000, restoreMarginW: 500, sunUp: false });
|
||||||
|
expect(action).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sheds a device once it falls outside its allowed time window", () => {
|
||||||
|
const status = makeStatus({ overTarget: false, projectedAverageW: 1000, targetW: 5000 });
|
||||||
|
// nowMs = 23:00 local time-of-day in a day starting at epoch-aligned midnight is awkward to
|
||||||
|
// construct portably, so drive minutesSinceMidnight indirectly via a known Date.
|
||||||
|
const outsideWindow = new Date();
|
||||||
|
outsideWindow.setHours(23, 0, 0, 0);
|
||||||
|
const devices = [
|
||||||
|
makeDevice({ entityId: "switch.scheduled", fromTime: "08:00", toTime: "20:00", shed: false }),
|
||||||
|
];
|
||||||
|
const action = decideControlAction({
|
||||||
|
status,
|
||||||
|
devices,
|
||||||
|
nowMs: outsideWindow.getTime(),
|
||||||
|
restoreMarginW: 500,
|
||||||
|
sunUp: true,
|
||||||
|
});
|
||||||
|
expect(action).toMatchObject({ entityId: "switch.scheduled", action: "shed", reason: "outside allowed time window" });
|
||||||
|
});
|
||||||
|
});
|
||||||
72
energy-management/backend/src/__tests__/quarterHour.test.ts
Normal file
72
energy-management/backend/src/__tests__/quarterHour.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { blockStartFor, computeQuarterHourStatus } from "../control/quarterHour.js";
|
||||||
|
|
||||||
|
const BLOCK_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
describe("blockStartFor", () => {
|
||||||
|
it("aligns to the current 15-minute boundary", () => {
|
||||||
|
const t = new Date("2026-08-18T10:07:30Z").getTime();
|
||||||
|
expect(blockStartFor(t)).toBe(new Date("2026-08-18T10:00:00Z").getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent on an exact boundary", () => {
|
||||||
|
const t = new Date("2026-08-18T10:15:00Z").getTime();
|
||||||
|
expect(blockStartFor(t)).toBe(t);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeQuarterHourStatus", () => {
|
||||||
|
it("returns zero averages when no samples exist yet", () => {
|
||||||
|
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
|
||||||
|
const now = blockStart + 60_000;
|
||||||
|
const status = computeQuarterHourStatus([], now, 5000);
|
||||||
|
expect(status.runningAverageW).toBe(0);
|
||||||
|
expect(status.projectedAverageW).toBe(0);
|
||||||
|
expect(status.overTarget).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes a flat running average for a constant power draw", () => {
|
||||||
|
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
|
||||||
|
const now = blockStart + 5 * 60_000;
|
||||||
|
const samples = [{ timestampMs: blockStart, watts: 3000 }];
|
||||||
|
const status = computeQuarterHourStatus(samples, now, 5000);
|
||||||
|
expect(status.runningAverageW).toBeCloseTo(3000);
|
||||||
|
// Projection assumes the last known power (3000W) holds for the rest of the block.
|
||||||
|
expect(status.projectedAverageW).toBeCloseTo(3000);
|
||||||
|
expect(status.overTarget).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects over target when a late spike would push the block average up", () => {
|
||||||
|
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
|
||||||
|
const tenMinIn = blockStart + 10 * 60_000;
|
||||||
|
const now = blockStart + 12 * 60_000;
|
||||||
|
const samples = [
|
||||||
|
{ timestampMs: blockStart, watts: 1000 },
|
||||||
|
{ timestampMs: tenMinIn, watts: 9000 },
|
||||||
|
];
|
||||||
|
const status = computeQuarterHourStatus(samples, now, 3000);
|
||||||
|
// energy so far: 1000W*10min + 9000W*2min; remaining 3min projected at 9000W
|
||||||
|
const expectedEnergy = 1000 * 10 * 60_000 + 9000 * 2 * 60_000;
|
||||||
|
const expectedRunningAvg = expectedEnergy / (12 * 60_000);
|
||||||
|
expect(status.runningAverageW).toBeCloseTo(expectedRunningAvg);
|
||||||
|
const expectedProjectedEnergy = expectedEnergy + 9000 * 3 * 60_000;
|
||||||
|
const expectedProjectedAvg = expectedProjectedEnergy / BLOCK_MS;
|
||||||
|
expect(status.projectedAverageW).toBeCloseTo(expectedProjectedAvg);
|
||||||
|
expect(status.overTarget).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores samples from before the current block", () => {
|
||||||
|
const prevBlockSample = { timestampMs: new Date("2026-08-18T09:59:00Z").getTime(), watts: 20000 };
|
||||||
|
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
|
||||||
|
const now = blockStart + 60_000;
|
||||||
|
const status = computeQuarterHourStatus([prevBlockSample], now, 5000);
|
||||||
|
expect(status.runningAverageW).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("elapsedFractionOfBlock reflects how far into the block now is", () => {
|
||||||
|
const blockStart = new Date("2026-08-18T10:00:00Z").getTime();
|
||||||
|
const now = blockStart + 3 * 60_000;
|
||||||
|
const status = computeQuarterHourStatus([], now, 5000);
|
||||||
|
expect(status.elapsedFractionOfBlock).toBeCloseTo(3 / 15);
|
||||||
|
});
|
||||||
|
});
|
||||||
80
energy-management/backend/src/api/routes.ts
Normal file
80
energy-management/backend/src/api/routes.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { Store } from "../db/store.js";
|
||||||
|
import type { ControlLoopRunner } from "../control/runner.js";
|
||||||
|
|
||||||
|
const timeOfDaySchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:MM")
|
||||||
|
.nullable();
|
||||||
|
|
||||||
|
const endpointUrlSchema = z.string().url().nullable();
|
||||||
|
|
||||||
|
const deviceInputSchema = z.object({
|
||||||
|
entityId: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
kind: z.enum(["switch", "number"]),
|
||||||
|
priority: z.number().int(),
|
||||||
|
minValue: z.number().default(0),
|
||||||
|
maxValue: z.number().default(1),
|
||||||
|
dwellSeconds: z.number().int().positive().default(300),
|
||||||
|
manualOverride: z.boolean().default(false),
|
||||||
|
onlyWhenSun: z.boolean().default(false),
|
||||||
|
fromTime: timeOfDaySchema.default(null),
|
||||||
|
toTime: timeOfDaySchema.default(null),
|
||||||
|
enableUrl: endpointUrlSchema.default(null),
|
||||||
|
disableUrl: endpointUrlSchema.default(null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceUpdateSchema = deviceInputSchema.partial();
|
||||||
|
|
||||||
|
export function registerRoutes(app: FastifyInstance, store: Store, runner: ControlLoopRunner): void {
|
||||||
|
app.get("/api/status", async () => {
|
||||||
|
return runner.getStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/history", async (req) => {
|
||||||
|
const query = z.object({ sinceMs: z.coerce.number().optional() }).parse(req.query);
|
||||||
|
const sinceMs = query.sinceMs ?? Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
return store.listBlockHistory(sinceMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/actions", async (req) => {
|
||||||
|
const query = z.object({ limit: z.coerce.number().optional() }).parse(req.query);
|
||||||
|
return store.listRecentActions(query.limit ?? 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/devices", async () => {
|
||||||
|
return store.listDevices();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/devices", async (req, reply) => {
|
||||||
|
const body = deviceInputSchema.parse(req.body);
|
||||||
|
const device = store.createDevice(body);
|
||||||
|
reply.code(201);
|
||||||
|
return device;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/devices/:id", async (req) => {
|
||||||
|
const params = z.object({ id: z.coerce.number() }).parse(req.params);
|
||||||
|
const body = deviceUpdateSchema.parse(req.body);
|
||||||
|
store.updateDevice(params.id, body);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/devices/:id", async (req) => {
|
||||||
|
const params = z.object({ id: z.coerce.number() }).parse(req.params);
|
||||||
|
store.deleteDevice(params.id);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/settings/target", async () => {
|
||||||
|
return { targetW: store.getTargetW() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/settings/target", async (req) => {
|
||||||
|
const body = z.object({ targetKw: z.number().positive() }).parse(req.body);
|
||||||
|
store.setTargetKw(body.targetKw);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
100
energy-management/backend/src/control/loop.ts
Normal file
100
energy-management/backend/src/control/loop.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { ControlAction, DeviceStatus, QuarterHourStatus } from "../types.js";
|
||||||
|
|
||||||
|
export interface LoopDecisionInput {
|
||||||
|
status: QuarterHourStatus;
|
||||||
|
devices: DeviceStatus[];
|
||||||
|
nowMs: number;
|
||||||
|
/** Only restore once projected power is at least this far below target, to avoid flapping. */
|
||||||
|
restoreMarginW: number;
|
||||||
|
/** Whether the solar inverter is currently producing power (see HomeAssistantClient.isSolarProducing). */
|
||||||
|
sunUp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDwellElapsed(device: DeviceStatus, nowMs: number): boolean {
|
||||||
|
if (device.lastChangedAt === null) return true;
|
||||||
|
const lastChangeMs = new Date(device.lastChangedAt).getTime();
|
||||||
|
return nowMs - lastChangeMs >= device.dwellSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isControllable(device: DeviceStatus, nowMs: number): boolean {
|
||||||
|
return !device.manualOverride && device.available && isDwellElapsed(device, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesSinceMidnight(nowMs: number): number {
|
||||||
|
const d = new Date(nowMs);
|
||||||
|
return d.getHours() * 60 + d.getMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinTimeWindow(fromTime: string | null, toTime: string | null, nowMs: number): boolean {
|
||||||
|
if (!fromTime || !toTime) return true;
|
||||||
|
const [fromH, fromM] = fromTime.split(":").map(Number);
|
||||||
|
const [toH, toM] = toTime.split(":").map(Number);
|
||||||
|
const fromMinutes = fromH * 60 + fromM;
|
||||||
|
const toMinutes = toH * 60 + toM;
|
||||||
|
const nowMinutes = minutesSinceMidnight(nowMs);
|
||||||
|
if (fromMinutes === toMinutes) return true;
|
||||||
|
if (fromMinutes < toMinutes) return nowMinutes >= fromMinutes && nowMinutes < toMinutes;
|
||||||
|
return nowMinutes >= fromMinutes || nowMinutes < toMinutes; // window wraps past midnight
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether device is currently allowed to be enabled, per its sun/time-window constraints. */
|
||||||
|
function isWithinSchedule(device: DeviceStatus, nowMs: number, sunUp: boolean): boolean {
|
||||||
|
if (device.onlyWhenSun && !sunUp) return false;
|
||||||
|
return isWithinTimeWindow(device.fromTime, device.toTime, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision function: given the current quarter-hour projection and device
|
||||||
|
* states, decides at most one shed or restore action to take this tick.
|
||||||
|
* Acting on a single device per tick (rather than all eligible at once) avoids
|
||||||
|
* overshooting the target and keeps the effect of each action observable.
|
||||||
|
*/
|
||||||
|
export function decideControlAction(input: LoopDecisionInput): ControlAction | null {
|
||||||
|
const { status, devices, nowMs, restoreMarginW, sunUp } = input;
|
||||||
|
|
||||||
|
// Devices currently enabled outside their allowed sun/time window get shed
|
||||||
|
// first, independent of the power budget.
|
||||||
|
const scheduleViolators = devices
|
||||||
|
.filter((d) => !d.shed && isControllable(d, nowMs) && !isWithinSchedule(d, nowMs, sunUp))
|
||||||
|
.sort((a, b) => a.priority - b.priority);
|
||||||
|
if (scheduleViolators[0]) {
|
||||||
|
const target = scheduleViolators[0];
|
||||||
|
return {
|
||||||
|
timestampMs: nowMs,
|
||||||
|
entityId: target.entityId,
|
||||||
|
action: "shed",
|
||||||
|
reason: target.onlyWhenSun && !sunUp ? "outside allowed sun hours" : "outside allowed time window",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.overTarget) {
|
||||||
|
const candidates = devices
|
||||||
|
.filter((d) => !d.shed && isControllable(d, nowMs))
|
||||||
|
.sort((a, b) => a.priority - b.priority); // least important (lowest priority) first
|
||||||
|
const target = candidates[0];
|
||||||
|
if (!target) return null;
|
||||||
|
return {
|
||||||
|
timestampMs: nowMs,
|
||||||
|
entityId: target.entityId,
|
||||||
|
action: "shed",
|
||||||
|
reason: `projected block average ${Math.round(status.projectedAverageW)}W exceeds target ${Math.round(status.targetW)}W`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const marginW = status.targetW - status.projectedAverageW;
|
||||||
|
if (marginW >= restoreMarginW) {
|
||||||
|
const candidates = devices
|
||||||
|
.filter((d) => d.shed && isControllable(d, nowMs) && isWithinSchedule(d, nowMs, sunUp))
|
||||||
|
.sort((a, b) => b.priority - a.priority); // most important (highest priority) first
|
||||||
|
const target = candidates[0];
|
||||||
|
if (!target) return null;
|
||||||
|
return {
|
||||||
|
timestampMs: nowMs,
|
||||||
|
entityId: target.entityId,
|
||||||
|
action: "restore",
|
||||||
|
reason: `projected block average ${Math.round(status.projectedAverageW)}W is ${Math.round(marginW)}W under target ${Math.round(status.targetW)}W`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
54
energy-management/backend/src/control/quarterHour.ts
Normal file
54
energy-management/backend/src/control/quarterHour.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { PowerSample, QuarterHourStatus } from "../types.js";
|
||||||
|
|
||||||
|
export const BLOCK_DURATION_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export function blockStartFor(timestampMs: number): number {
|
||||||
|
return Math.floor(timestampMs / BLOCK_DURATION_MS) * BLOCK_DURATION_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the running and projected average power for the current 15-minute
|
||||||
|
* billing block, given a step-function reading of power samples (each sample
|
||||||
|
* holds until the next one arrives). Samples outside the current block are
|
||||||
|
* ignored; if the block has no samples yet, power is assumed to be 0 so far.
|
||||||
|
*/
|
||||||
|
export function computeQuarterHourStatus(
|
||||||
|
samples: PowerSample[],
|
||||||
|
nowMs: number,
|
||||||
|
targetW: number,
|
||||||
|
): QuarterHourStatus {
|
||||||
|
const blockStartMs = blockStartFor(nowMs);
|
||||||
|
const blockEndMs = blockStartMs + BLOCK_DURATION_MS;
|
||||||
|
|
||||||
|
const relevant = samples
|
||||||
|
.filter((s) => s.timestampMs >= blockStartMs && s.timestampMs <= nowMs)
|
||||||
|
.sort((a, b) => a.timestampMs - b.timestampMs);
|
||||||
|
|
||||||
|
let energyWms = 0;
|
||||||
|
let cursorMs = blockStartMs;
|
||||||
|
let cursorWatts = relevant.length > 0 ? relevant[0].watts : 0;
|
||||||
|
|
||||||
|
for (const sample of relevant) {
|
||||||
|
energyWms += cursorWatts * (sample.timestampMs - cursorMs);
|
||||||
|
cursorMs = sample.timestampMs;
|
||||||
|
cursorWatts = sample.watts;
|
||||||
|
}
|
||||||
|
energyWms += cursorWatts * (nowMs - cursorMs);
|
||||||
|
|
||||||
|
const elapsedMs = nowMs - blockStartMs;
|
||||||
|
const runningAverageW = elapsedMs > 0 ? energyWms / elapsedMs : 0;
|
||||||
|
|
||||||
|
const remainingMs = blockEndMs - nowMs;
|
||||||
|
const projectedEnergyWms = energyWms + cursorWatts * remainingMs;
|
||||||
|
const projectedAverageW = projectedEnergyWms / BLOCK_DURATION_MS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
blockStartMs,
|
||||||
|
blockEndMs,
|
||||||
|
elapsedFractionOfBlock: elapsedMs / BLOCK_DURATION_MS,
|
||||||
|
runningAverageW,
|
||||||
|
projectedAverageW,
|
||||||
|
targetW,
|
||||||
|
overTarget: projectedAverageW > targetW,
|
||||||
|
};
|
||||||
|
}
|
||||||
116
energy-management/backend/src/control/runner.ts
Normal file
116
energy-management/backend/src/control/runner.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import type { QueryApi } from "@influxdata/influxdb-client";
|
||||||
|
import type { Env } from "../env.js";
|
||||||
|
import {
|
||||||
|
fetchLatestPowerSample,
|
||||||
|
fetchLatestSolarProductionSample,
|
||||||
|
fetchPowerSamplesSince,
|
||||||
|
} from "../influx/power.js";
|
||||||
|
import { HomeAssistantClient } from "../homeassistant/client.js";
|
||||||
|
import { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.js";
|
||||||
|
import type { Store } from "../db/store.js";
|
||||||
|
import type { CurrentReadings, DeviceStatus, QuarterHourStatus } from "../types.js";
|
||||||
|
import { blockStartFor, computeQuarterHourStatus } from "./quarterHour.js";
|
||||||
|
import { decideControlAction } from "./loop.js";
|
||||||
|
|
||||||
|
const RESTORE_MARGIN_W = 300;
|
||||||
|
|
||||||
|
export class ControlLoopRunner {
|
||||||
|
constructor(
|
||||||
|
private readonly env: Env,
|
||||||
|
private readonly store: Store,
|
||||||
|
private readonly queryApi: QueryApi,
|
||||||
|
private readonly ha: HomeAssistantClient,
|
||||||
|
private readonly log: { info: (o: unknown, msg?: string) => void; error: (o: unknown, msg?: string) => void },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }> {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const blockStart = blockStartFor(nowMs);
|
||||||
|
const targetW = this.store.getTargetW();
|
||||||
|
|
||||||
|
const [samples, latestPower, latestSolar, devices] = await Promise.all([
|
||||||
|
fetchPowerSamplesSince(this.queryApi, this.env, blockStart),
|
||||||
|
fetchLatestPowerSample(this.queryApi, this.env),
|
||||||
|
fetchLatestSolarProductionSample(this.queryApi, this.env),
|
||||||
|
this.loadDeviceStatuses(),
|
||||||
|
]);
|
||||||
|
const quarterHour = computeQuarterHourStatus(samples, nowMs, targetW);
|
||||||
|
const current: CurrentReadings = {
|
||||||
|
powerW: latestPower?.watts ?? null,
|
||||||
|
solarW: latestSolar?.watts ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { quarterHour, devices, current };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDeviceStatuses(): Promise<DeviceStatus[]> {
|
||||||
|
const configs = this.store.listDevices();
|
||||||
|
return Promise.all(
|
||||||
|
configs.map(async (config) => {
|
||||||
|
const { shed, lastChangedAt } = this.store.getDeviceShedState(config.id);
|
||||||
|
return getDeviceStatus(this.ha, config, shed, lastChangedAt);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the solar inverter is currently producing power, used to gate
|
||||||
|
* `onlyWhenSun` devices. Reads the configured InfluxDB solar production
|
||||||
|
* series and compares its latest sample against SOLAR_PRODUCTION_THRESHOLD_W.
|
||||||
|
* Falls back to HA's sun.sun (astronomical above/below horizon) when no
|
||||||
|
* solar production series is configured, or if either read fails.
|
||||||
|
*/
|
||||||
|
private async isSolarProducing(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const sample = await fetchLatestSolarProductionSample(this.queryApi, this.env);
|
||||||
|
if (sample) return sample.watts >= this.env.SOLAR_PRODUCTION_THRESHOLD_W;
|
||||||
|
} catch (err) {
|
||||||
|
this.log.error({ err }, "failed to read solar production series; falling back to sun.sun");
|
||||||
|
}
|
||||||
|
return this.ha.isSunUp().catch((err) => {
|
||||||
|
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async tick(): Promise<void> {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const { quarterHour, devices } = await this.getStatus();
|
||||||
|
|
||||||
|
// Upserting every tick means the last write before a block rolls over holds
|
||||||
|
// as that block's final average, with no separate "finalize" step needed.
|
||||||
|
this.store.upsertBlockHistory(quarterHour.blockStartMs, quarterHour.runningAverageW);
|
||||||
|
|
||||||
|
const sunUp = await this.isSolarProducing();
|
||||||
|
const decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp });
|
||||||
|
if (!decision) return;
|
||||||
|
|
||||||
|
const device = devices.find((d) => d.entityId === decision.entityId);
|
||||||
|
if (!device) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (decision.action === "shed") {
|
||||||
|
await shedDevice(this.ha, device);
|
||||||
|
} else {
|
||||||
|
await restoreDevice(this.ha, device);
|
||||||
|
}
|
||||||
|
const changedAtIso = new Date(decision.timestampMs).toISOString();
|
||||||
|
this.store.setDeviceShed(device.id, decision.action === "shed", changedAtIso);
|
||||||
|
this.store.logAction(decision);
|
||||||
|
this.log.info({ decision }, "control action applied");
|
||||||
|
} catch (err) {
|
||||||
|
this.log.error({ err, decision }, "failed to apply control action");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createControlLoopRunner(
|
||||||
|
env: Env,
|
||||||
|
store: Store,
|
||||||
|
queryApi: QueryApi,
|
||||||
|
log: { info: (o: unknown, msg?: string) => void; error: (o: unknown, msg?: string) => void },
|
||||||
|
): ControlLoopRunner {
|
||||||
|
const ha = new HomeAssistantClient(env);
|
||||||
|
return new ControlLoopRunner(env, store, queryApi, ha, log);
|
||||||
|
}
|
||||||
72
energy-management/backend/src/db/index.ts
Normal file
72
energy-management/backend/src/db/index.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import type { Env } from "../env.js";
|
||||||
|
|
||||||
|
export function openDb(env: Env): Database.Database {
|
||||||
|
mkdirSync(dirname(env.DB_PATH), { recursive: true });
|
||||||
|
const db = new Database(env.DB_PATH);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
migrate(db, env);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(db: Database.Database, env: Env): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('switch', 'number')),
|
||||||
|
priority INTEGER NOT NULL,
|
||||||
|
min_value REAL NOT NULL DEFAULT 0,
|
||||||
|
max_value REAL NOT NULL DEFAULT 1,
|
||||||
|
dwell_seconds INTEGER NOT NULL DEFAULT 300,
|
||||||
|
manual_override INTEGER NOT NULL DEFAULT 0,
|
||||||
|
shed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_changed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS control_actions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp_ms INTEGER NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('shed', 'restore')),
|
||||||
|
reason TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS block_history (
|
||||||
|
block_start_ms INTEGER PRIMARY KEY,
|
||||||
|
average_w REAL NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const existingTarget = db.prepare("SELECT value FROM settings WHERE key = 'target_kw'").get();
|
||||||
|
if (!existingTarget) {
|
||||||
|
db.prepare("INSERT INTO settings (key, value) VALUES ('target_kw', ?)").run(String(env.TARGET_KW));
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceColumns = new Set(
|
||||||
|
(db.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).map((c) => c.name),
|
||||||
|
);
|
||||||
|
if (!deviceColumns.has("only_when_sun")) {
|
||||||
|
db.exec("ALTER TABLE devices ADD COLUMN only_when_sun INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
if (!deviceColumns.has("from_time")) {
|
||||||
|
db.exec("ALTER TABLE devices ADD COLUMN from_time TEXT");
|
||||||
|
}
|
||||||
|
if (!deviceColumns.has("to_time")) {
|
||||||
|
db.exec("ALTER TABLE devices ADD COLUMN to_time TEXT");
|
||||||
|
}
|
||||||
|
if (!deviceColumns.has("enable_url")) {
|
||||||
|
db.exec("ALTER TABLE devices ADD COLUMN enable_url TEXT");
|
||||||
|
}
|
||||||
|
if (!deviceColumns.has("disable_url")) {
|
||||||
|
db.exec("ALTER TABLE devices ADD COLUMN disable_url TEXT");
|
||||||
|
}
|
||||||
|
}
|
||||||
140
energy-management/backend/src/db/store.ts
Normal file
140
energy-management/backend/src/db/store.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type Database from "better-sqlite3";
|
||||||
|
import type { ControlAction, DeviceConfig } from "../types.js";
|
||||||
|
|
||||||
|
interface DeviceRow {
|
||||||
|
id: number;
|
||||||
|
entity_id: string;
|
||||||
|
name: string;
|
||||||
|
kind: "switch" | "number";
|
||||||
|
priority: number;
|
||||||
|
min_value: number;
|
||||||
|
max_value: number;
|
||||||
|
dwell_seconds: number;
|
||||||
|
manual_override: number;
|
||||||
|
only_when_sun: number;
|
||||||
|
from_time: string | null;
|
||||||
|
to_time: string | null;
|
||||||
|
enable_url: string | null;
|
||||||
|
disable_url: string | null;
|
||||||
|
shed: number;
|
||||||
|
last_changed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToDeviceConfig(row: DeviceRow): DeviceConfig {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
entityId: row.entity_id,
|
||||||
|
name: row.name,
|
||||||
|
kind: row.kind,
|
||||||
|
priority: row.priority,
|
||||||
|
minValue: row.min_value,
|
||||||
|
maxValue: row.max_value,
|
||||||
|
dwellSeconds: row.dwell_seconds,
|
||||||
|
manualOverride: row.manual_override === 1,
|
||||||
|
onlyWhenSun: row.only_when_sun === 1,
|
||||||
|
fromTime: row.from_time,
|
||||||
|
toTime: row.to_time,
|
||||||
|
enableUrl: row.enable_url,
|
||||||
|
disableUrl: row.disable_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Store {
|
||||||
|
constructor(private readonly db: Database.Database) {}
|
||||||
|
|
||||||
|
listDevices(): DeviceConfig[] {
|
||||||
|
const rows = this.db.prepare("SELECT * FROM devices ORDER BY priority ASC").all() as DeviceRow[];
|
||||||
|
return rows.map(rowToDeviceConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeviceShedState(id: number): { shed: boolean; lastChangedAt: string | null } {
|
||||||
|
const row = this.db.prepare("SELECT shed, last_changed_at FROM devices WHERE id = ?").get(id) as
|
||||||
|
| { shed: number; last_changed_at: string | null }
|
||||||
|
| undefined;
|
||||||
|
return { shed: row?.shed === 1, lastChangedAt: row?.last_changed_at ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
createDevice(device: Omit<DeviceConfig, "id">): DeviceConfig {
|
||||||
|
const result = this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO devices (entity_id, name, kind, priority, min_value, max_value, dwell_seconds, manual_override, only_when_sun, from_time, to_time, enable_url, disable_url)
|
||||||
|
VALUES (@entityId, @name, @kind, @priority, @minValue, @maxValue, @dwellSeconds, @manualOverride, @onlyWhenSun, @fromTime, @toTime, @enableUrl, @disableUrl)`,
|
||||||
|
)
|
||||||
|
.run({ ...device, manualOverride: device.manualOverride ? 1 : 0, onlyWhenSun: device.onlyWhenSun ? 1 : 0 });
|
||||||
|
return { ...device, id: Number(result.lastInsertRowid) };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDevice(id: number, patch: Partial<Omit<DeviceConfig, "id">>): void {
|
||||||
|
const fields = Object.entries(patch);
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
const columnMap: Record<string, string> = {
|
||||||
|
entityId: "entity_id",
|
||||||
|
name: "name",
|
||||||
|
kind: "kind",
|
||||||
|
priority: "priority",
|
||||||
|
minValue: "min_value",
|
||||||
|
maxValue: "max_value",
|
||||||
|
dwellSeconds: "dwell_seconds",
|
||||||
|
manualOverride: "manual_override",
|
||||||
|
onlyWhenSun: "only_when_sun",
|
||||||
|
fromTime: "from_time",
|
||||||
|
toTime: "to_time",
|
||||||
|
enableUrl: "enable_url",
|
||||||
|
disableUrl: "disable_url",
|
||||||
|
};
|
||||||
|
const setClause = fields.map(([key]) => `${columnMap[key]} = @${key}`).join(", ");
|
||||||
|
const values: Record<string, unknown> = { id };
|
||||||
|
for (const [key, value] of fields) {
|
||||||
|
values[key] = key === "manualOverride" || key === "onlyWhenSun" ? (value ? 1 : 0) : value;
|
||||||
|
}
|
||||||
|
this.db.prepare(`UPDATE devices SET ${setClause} WHERE id = @id`).run(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDevice(id: number): void {
|
||||||
|
this.db.prepare("DELETE FROM devices WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeviceShed(id: number, shed: boolean, changedAtIso: string): void {
|
||||||
|
this.db
|
||||||
|
.prepare("UPDATE devices SET shed = ?, last_changed_at = ? WHERE id = ?")
|
||||||
|
.run(shed ? 1 : 0, changedAtIso, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTargetW(): number {
|
||||||
|
const row = this.db.prepare("SELECT value FROM settings WHERE key = 'target_kw'").get() as { value: string };
|
||||||
|
return Number(row.value) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTargetKw(targetKw: number): void {
|
||||||
|
this.db.prepare("UPDATE settings SET value = ? WHERE key = 'target_kw'").run(String(targetKw));
|
||||||
|
}
|
||||||
|
|
||||||
|
logAction(action: ControlAction): void {
|
||||||
|
this.db
|
||||||
|
.prepare("INSERT INTO control_actions (timestamp_ms, entity_id, action, reason) VALUES (?, ?, ?, ?)")
|
||||||
|
.run(action.timestampMs, action.entityId, action.action, action.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
listRecentActions(limit = 50): ControlAction[] {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare("SELECT timestamp_ms, entity_id, action, reason FROM control_actions ORDER BY timestamp_ms DESC LIMIT ?")
|
||||||
|
.all(limit) as { timestamp_ms: number; entity_id: string; action: "shed" | "restore"; reason: string }[];
|
||||||
|
return rows.map((r) => ({ timestampMs: r.timestamp_ms, entityId: r.entity_id, action: r.action, reason: r.reason }));
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertBlockHistory(blockStartMs: number, averageW: number): void {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO block_history (block_start_ms, average_w) VALUES (?, ?)
|
||||||
|
ON CONFLICT(block_start_ms) DO UPDATE SET average_w = excluded.average_w`,
|
||||||
|
)
|
||||||
|
.run(blockStartMs, averageW);
|
||||||
|
}
|
||||||
|
|
||||||
|
listBlockHistory(sinceMs: number): { blockStartMs: number; averageW: number }[] {
|
||||||
|
const rows = this.db
|
||||||
|
.prepare("SELECT block_start_ms, average_w FROM block_history WHERE block_start_ms >= ? ORDER BY block_start_ms ASC")
|
||||||
|
.all(sinceMs) as { block_start_ms: number; average_w: number }[];
|
||||||
|
return rows.map((r) => ({ blockStartMs: r.block_start_ms, averageW: r.average_w }));
|
||||||
|
}
|
||||||
|
}
|
||||||
46
energy-management/backend/src/env.ts
Normal file
46
energy-management/backend/src/env.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { config as loadDotenv } from "dotenv";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// In production (docker-compose) env vars are already injected via env_file;
|
||||||
|
// this only fills gaps for local `npm run dev`, and is a no-op if the file is absent.
|
||||||
|
loadDotenv({ path: join(dirname(fileURLToPath(import.meta.url)), "../../.env") });
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
INFLUX_URL: z.string().url(),
|
||||||
|
INFLUX_TOKEN: z.string().min(1),
|
||||||
|
INFLUX_ORG: z.string().min(1),
|
||||||
|
INFLUX_BUCKET: z.string().min(1),
|
||||||
|
INFLUX_MEASUREMENT: z.string().default("power"),
|
||||||
|
INFLUX_DOMAIN: z.string(),
|
||||||
|
INFLUX_ENTITY_ID: z.string(),
|
||||||
|
INFLUX_FIELD: z.string().default("value"),
|
||||||
|
INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
|
||||||
|
HA_URL: z.string().url(),
|
||||||
|
HA_TOKEN: z.string().min(1),
|
||||||
|
// Solar inverter production, read from InfluxDB (same shape as the house
|
||||||
|
// power series above). Leave SOLAR_INFLUX_ENTITY_ID unset to fall back to
|
||||||
|
// sun.sun (astronomical day/night).
|
||||||
|
SOLAR_INFLUX_MEASUREMENT: z.string().default("power"),
|
||||||
|
SOLAR_INFLUX_DOMAIN: z.string().optional(),
|
||||||
|
SOLAR_INFLUX_ENTITY_ID: z.string().optional(),
|
||||||
|
SOLAR_INFLUX_FIELD: z.string().default("value"),
|
||||||
|
SOLAR_INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
|
||||||
|
SOLAR_PRODUCTION_THRESHOLD_W: z.coerce.number().default(50),
|
||||||
|
TARGET_KW: z.coerce.number().positive(),
|
||||||
|
DB_PATH: z.string().default("./data/energy.db"),
|
||||||
|
CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
export function loadEnv(): Env {
|
||||||
|
const parsed = envSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error("Invalid environment configuration:", parsed.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
56
energy-management/backend/src/homeassistant/client.ts
Normal file
56
energy-management/backend/src/homeassistant/client.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Env } from "../env.js";
|
||||||
|
|
||||||
|
export interface HaState {
|
||||||
|
entity_id: string;
|
||||||
|
state: string;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HomeAssistantClient {
|
||||||
|
constructor(private readonly env: Env) {}
|
||||||
|
|
||||||
|
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${this.env.HA_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.env.HA_TOKEN}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Home Assistant request failed: ${init?.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getState(entityId: string): Promise<HaState> {
|
||||||
|
return this.request<HaState>(`/api/states/${entityId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStates(): Promise<HaState[]> {
|
||||||
|
return this.request<HaState[]>(`/api/states`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async callService(domain: string, service: string, data: Record<string, unknown>): Promise<void> {
|
||||||
|
await this.request(`/api/services/${domain}/${service}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSwitch(entityId: string, on: boolean): Promise<void> {
|
||||||
|
const domain = entityId.split(".")[0];
|
||||||
|
await this.callService(domain, on ? "turn_on" : "turn_off", { entity_id: entityId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async setNumber(entityId: string, value: number): Promise<void> {
|
||||||
|
await this.callService("number", "set_value", { entity_id: entityId, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether HA's sun.sun entity currently reports the sun above the horizon. */
|
||||||
|
async isSunUp(): Promise<boolean> {
|
||||||
|
const state = await this.getState("sun.sun");
|
||||||
|
return state.state === "above_horizon";
|
||||||
|
}
|
||||||
|
}
|
||||||
87
energy-management/backend/src/homeassistant/devices.ts
Normal file
87
energy-management/backend/src/homeassistant/devices.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { DeviceConfig, DeviceStatus } from "../types.js";
|
||||||
|
import type { HomeAssistantClient } from "./client.js";
|
||||||
|
|
||||||
|
function normalizeHaValue(kind: DeviceConfig["kind"], state: string): number | null {
|
||||||
|
if (kind === "switch") {
|
||||||
|
if (state === "on") return 1;
|
||||||
|
if (state === "off") return 0;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const n = Number(state);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when both directions are driven by custom endpoints rather than Home Assistant. */
|
||||||
|
function isExternallyControlled(device: DeviceConfig): boolean {
|
||||||
|
return device.enableUrl !== null && device.disableUrl !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callEndpoint(url: string): Promise<void> {
|
||||||
|
const res = await fetch(url, { method: "POST" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`custom endpoint request failed: POST ${url} -> ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceStatus(
|
||||||
|
ha: HomeAssistantClient,
|
||||||
|
device: DeviceConfig,
|
||||||
|
shed: boolean,
|
||||||
|
lastChangedAt: string | null,
|
||||||
|
): Promise<DeviceStatus> {
|
||||||
|
// Externally controlled devices have no Home Assistant entity to poll; state is
|
||||||
|
// whatever we last commanded via the custom endpoints.
|
||||||
|
if (isExternallyControlled(device)) {
|
||||||
|
return {
|
||||||
|
...device,
|
||||||
|
currentValue: shed ? (device.kind === "switch" ? 0 : device.minValue) : device.kind === "switch" ? 1 : device.maxValue,
|
||||||
|
shed,
|
||||||
|
lastChangedAt,
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const haState = await ha.getState(device.entityId);
|
||||||
|
return {
|
||||||
|
...device,
|
||||||
|
currentValue: normalizeHaValue(device.kind, haState.state),
|
||||||
|
shed,
|
||||||
|
lastChangedAt,
|
||||||
|
available: haState.state !== "unavailable" && haState.state !== "unknown",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
...device,
|
||||||
|
currentValue: null,
|
||||||
|
shed,
|
||||||
|
lastChangedAt,
|
||||||
|
available: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sheds (reduces load of) a device: turns a switch off, or lowers a number to its floor. */
|
||||||
|
export async function shedDevice(ha: HomeAssistantClient, device: DeviceConfig): Promise<void> {
|
||||||
|
if (device.disableUrl) {
|
||||||
|
await callEndpoint(device.disableUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (device.kind === "switch") {
|
||||||
|
await ha.setSwitch(device.entityId, false);
|
||||||
|
} else {
|
||||||
|
await ha.setNumber(device.entityId, device.minValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restores a device to its normal operating value. */
|
||||||
|
export async function restoreDevice(ha: HomeAssistantClient, device: DeviceConfig): Promise<void> {
|
||||||
|
if (device.enableUrl) {
|
||||||
|
await callEndpoint(device.enableUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (device.kind === "switch") {
|
||||||
|
await ha.setSwitch(device.entityId, true);
|
||||||
|
} else {
|
||||||
|
await ha.setNumber(device.entityId, device.maxValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
energy-management/backend/src/index.ts
Normal file
53
energy-management/backend/src/index.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import staticPlugin from "@fastify/static";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { loadEnv } from "./env.js";
|
||||||
|
import { openDb } from "./db/index.js";
|
||||||
|
import { Store } from "./db/store.js";
|
||||||
|
import { createInfluxQueryApi } from "./influx/client.js";
|
||||||
|
import { createControlLoopRunner } from "./control/runner.js";
|
||||||
|
import { registerRoutes } from "./api/routes.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const env = loadEnv();
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
transport: process.env.NODE_ENV === "production" ? undefined : { target: "pino-pretty" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cors, { origin: true });
|
||||||
|
|
||||||
|
const db = openDb(env);
|
||||||
|
const store = new Store(db);
|
||||||
|
const queryApi = createInfluxQueryApi(env);
|
||||||
|
const runner = createControlLoopRunner(env, store, queryApi, app.log);
|
||||||
|
|
||||||
|
registerRoutes(app, store, runner);
|
||||||
|
|
||||||
|
const publicDir = join(dirname(fileURLToPath(import.meta.url)), "public");
|
||||||
|
if (existsSync(publicDir)) {
|
||||||
|
await app.register(staticPlugin, { root: publicDir });
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
if (req.raw.url?.startsWith("/api")) {
|
||||||
|
reply.code(404).send({ error: "not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
runner.tick().catch((err) => app.log.error({ err }, "control loop tick failed"));
|
||||||
|
}, env.CONTROL_LOOP_INTERVAL_MS);
|
||||||
|
|
||||||
|
await app.listen({ port: env.PORT, host: "0.0.0.0" });
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
7
energy-management/backend/src/influx/client.ts
Normal file
7
energy-management/backend/src/influx/client.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { InfluxDB, type QueryApi } from "@influxdata/influxdb-client";
|
||||||
|
import type { Env } from "../env.js";
|
||||||
|
|
||||||
|
export function createInfluxQueryApi(env: Env): QueryApi {
|
||||||
|
const influx = new InfluxDB({ url: env.INFLUX_URL, token: env.INFLUX_TOKEN });
|
||||||
|
return influx.getQueryApi(env.INFLUX_ORG);
|
||||||
|
}
|
||||||
110
energy-management/backend/src/influx/power.ts
Normal file
110
energy-management/backend/src/influx/power.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { QueryApi } from "@influxdata/influxdb-client";
|
||||||
|
import type { Env } from "../env.js";
|
||||||
|
import type { PowerSample } from "../types.js";
|
||||||
|
|
||||||
|
interface SeriesConfig {
|
||||||
|
bucket: string;
|
||||||
|
measurement: string;
|
||||||
|
field: string;
|
||||||
|
fieldUnit: "W" | "kW";
|
||||||
|
domain?: string;
|
||||||
|
entityId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches raw samples for one series between `sinceMs` and now, converting
|
||||||
|
* the configured field to watts based on `fieldUnit`.
|
||||||
|
*/
|
||||||
|
async function fetchSeriesSince(
|
||||||
|
queryApi: QueryApi,
|
||||||
|
config: SeriesConfig,
|
||||||
|
sinceMs: number,
|
||||||
|
): Promise<PowerSample[]> {
|
||||||
|
const sinceIso = new Date(sinceMs).toISOString();
|
||||||
|
const domainFilter = config.domain
|
||||||
|
? `|> filter(fn: (r) => r["domain"] == "${config.domain}")\n `
|
||||||
|
: "";
|
||||||
|
const entityFilter = config.entityId
|
||||||
|
? `|> filter(fn: (r) => r["entity_id"] == "${config.entityId}")\n `
|
||||||
|
: "";
|
||||||
|
const flux = `
|
||||||
|
from(bucket: "${config.bucket}")
|
||||||
|
|> range(start: ${sinceIso})
|
||||||
|
|> filter(fn: (r) => r["_measurement"] == "${config.fieldUnit}")
|
||||||
|
|> filter(fn: (r) => r["_field"] == "${config.field}")
|
||||||
|
${domainFilter}${entityFilter}|> sort(columns: ["_time"])
|
||||||
|
`;
|
||||||
|
|
||||||
|
const unitMultiplier = config.fieldUnit === "kW" ? 1000 : 1;
|
||||||
|
const samples: PowerSample[] = [];
|
||||||
|
|
||||||
|
for await (const { values, tableMeta } of queryApi.iterateRows(flux)) {
|
||||||
|
const row = tableMeta.toObject(values) as { _time: string; _value: number };
|
||||||
|
samples.push({
|
||||||
|
timestampMs: new Date(row._time).getTime(),
|
||||||
|
watts: row._value * unitMultiplier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
function housePowerConfig(env: Env): SeriesConfig {
|
||||||
|
return {
|
||||||
|
bucket: env.INFLUX_BUCKET,
|
||||||
|
measurement: env.INFLUX_MEASUREMENT,
|
||||||
|
field: env.INFLUX_FIELD,
|
||||||
|
fieldUnit: env.INFLUX_FIELD_UNIT,
|
||||||
|
domain: env.INFLUX_DOMAIN,
|
||||||
|
entityId: env.INFLUX_ENTITY_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches raw power samples between `sinceMs` and now.
|
||||||
|
* Converts the configured field to watts based on INFLUX_FIELD_UNIT.
|
||||||
|
*/
|
||||||
|
export async function fetchPowerSamplesSince(
|
||||||
|
queryApi: QueryApi,
|
||||||
|
env: Env,
|
||||||
|
sinceMs: number,
|
||||||
|
): Promise<PowerSample[]> {
|
||||||
|
return fetchSeriesSince(queryApi, housePowerConfig(env), sinceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches the single most recent power sample, if any exists within the lookback window. */
|
||||||
|
export async function fetchLatestPowerSample(
|
||||||
|
queryApi: QueryApi,
|
||||||
|
env: Env,
|
||||||
|
lookbackMs = 60_000,
|
||||||
|
): Promise<PowerSample | null> {
|
||||||
|
const samples = await fetchPowerSamplesSince(
|
||||||
|
queryApi,
|
||||||
|
env,
|
||||||
|
Date.now() - lookbackMs,
|
||||||
|
);
|
||||||
|
return samples.length > 0 ? samples[samples.length - 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the most recent solar production sample from InfluxDB, if any
|
||||||
|
* exists within the lookback window. Returns null when no solar production
|
||||||
|
* series is configured (SOLAR_INFLUX_ENTITY_ID unset).
|
||||||
|
*/
|
||||||
|
export async function fetchLatestSolarProductionSample(
|
||||||
|
queryApi: QueryApi,
|
||||||
|
env: Env,
|
||||||
|
lookbackMs = 60_000,
|
||||||
|
): Promise<PowerSample | null> {
|
||||||
|
if (!env.SOLAR_INFLUX_ENTITY_ID) return null;
|
||||||
|
const config: SeriesConfig = {
|
||||||
|
bucket: env.INFLUX_BUCKET,
|
||||||
|
measurement: env.SOLAR_INFLUX_MEASUREMENT,
|
||||||
|
field: env.SOLAR_INFLUX_FIELD,
|
||||||
|
fieldUnit: env.SOLAR_INFLUX_FIELD_UNIT,
|
||||||
|
domain: env.SOLAR_INFLUX_DOMAIN,
|
||||||
|
entityId: env.SOLAR_INFLUX_ENTITY_ID,
|
||||||
|
};
|
||||||
|
const samples = await fetchSeriesSince(queryApi, config, Date.now() - lookbackMs);
|
||||||
|
return samples.length > 0 ? samples[samples.length - 1] : null;
|
||||||
|
}
|
||||||
52
energy-management/backend/src/types.ts
Normal file
52
energy-management/backend/src/types.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export type ControlKind = "switch" | "number";
|
||||||
|
|
||||||
|
export interface DeviceConfig {
|
||||||
|
id: number;
|
||||||
|
entityId: string;
|
||||||
|
name: string;
|
||||||
|
kind: ControlKind;
|
||||||
|
priority: number; // lower = shed first
|
||||||
|
minValue: number; // for "number" kind: floor to shed to; for "switch": unused (0)
|
||||||
|
maxValue: number; // for "number" kind: normal operating value; for "switch": unused (1)
|
||||||
|
dwellSeconds: number; // minimum time between state changes for this device
|
||||||
|
manualOverride: boolean; // if true, control loop leaves this device alone
|
||||||
|
onlyWhenSun: boolean; // if true, device may only be enabled while the solar inverter is producing power
|
||||||
|
fromTime: string | null; // "HH:MM", start of allowed enable window (local time), null = no restriction
|
||||||
|
toTime: string | null; // "HH:MM", end of allowed enable window (local time), null = no restriction
|
||||||
|
enableUrl: string | null; // if set, called (HTTP POST) to enable/restore the device instead of Home Assistant
|
||||||
|
disableUrl: string | null; // if set, called (HTTP POST) to disable/shed the device instead of Home Assistant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceStatus extends DeviceConfig {
|
||||||
|
currentValue: number | null; // current HA state, normalized (0/1 for switch, number value)
|
||||||
|
shed: boolean;
|
||||||
|
lastChangedAt: string | null; // ISO timestamp of last control-loop action
|
||||||
|
available: boolean; // whether HA reports the entity as reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PowerSample {
|
||||||
|
timestampMs: number;
|
||||||
|
watts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuarterHourStatus {
|
||||||
|
blockStartMs: number;
|
||||||
|
blockEndMs: number;
|
||||||
|
elapsedFractionOfBlock: number; // 0..1
|
||||||
|
runningAverageW: number;
|
||||||
|
projectedAverageW: number;
|
||||||
|
targetW: number;
|
||||||
|
overTarget: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentReadings {
|
||||||
|
powerW: number | null; // most recent house power sample, null if none within lookback
|
||||||
|
solarW: number | null; // most recent solar production sample, null if unconfigured or none within lookback
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlAction {
|
||||||
|
timestampMs: number;
|
||||||
|
entityId: string;
|
||||||
|
action: "shed" | "restore";
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
16
energy-management/backend/tsconfig.json
Normal file
16
energy-management/backend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
energy-management/docker-compose.yml
Normal file
15
energy-management/docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
energy-management:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DB_PATH: /app/data/energy.db
|
||||||
|
volumes:
|
||||||
|
- energy-management-data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
energy-management-data:
|
||||||
12
energy-management/frontend/index.html
Normal file
12
energy-management/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Peak Power Manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
energy-management/frontend/package.json
Normal file
21
energy-management/frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.7",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
energy-management/frontend/src/App.tsx
Normal file
23
energy-management/frontend/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Dashboard } from "./Dashboard";
|
||||||
|
import { Devices } from "./Devices";
|
||||||
|
|
||||||
|
type Tab = "dashboard" | "devices";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [tab, setTab] = useState<Tab>("dashboard");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<nav className="tabs">
|
||||||
|
<button className={tab === "dashboard" ? "active" : ""} onClick={() => setTab("dashboard")}>
|
||||||
|
Dashboard
|
||||||
|
</button>
|
||||||
|
<button className={tab === "devices" ? "active" : ""} onClick={() => setTab("devices")}>
|
||||||
|
Devices
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
{tab === "dashboard" ? <Dashboard /> : <Devices />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
energy-management/frontend/src/Dashboard.tsx
Normal file
164
energy-management/frontend/src/Dashboard.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type BlockHistoryPoint,
|
||||||
|
type ControlAction,
|
||||||
|
type CurrentReadings,
|
||||||
|
type DeviceStatus,
|
||||||
|
type QuarterHourStatus,
|
||||||
|
} from "./api";
|
||||||
|
import { PeakHistoryChart } from "./PeakHistoryChart";
|
||||||
|
|
||||||
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function formatW(watts: number | null): string {
|
||||||
|
if (watts == null) return "—";
|
||||||
|
return Math.abs(watts) >= 1000 ? `${(watts / 1000).toFixed(2)} kW` : `${Math.round(watts)} W`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [quarterHour, setQuarterHour] = useState<QuarterHourStatus | null>(null);
|
||||||
|
const [current, setCurrent] = useState<CurrentReadings | null>(null);
|
||||||
|
const [devices, setDevices] = useState<DeviceStatus[]>([]);
|
||||||
|
const [history, setHistory] = useState<BlockHistoryPoint[]>([]);
|
||||||
|
const [actions, setActions] = useState<ControlAction[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const [status, hist, acts] = await Promise.all([
|
||||||
|
api.getStatus(),
|
||||||
|
api.getHistory(Date.now() - THIRTY_DAYS_MS),
|
||||||
|
api.getActions(10),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setQuarterHour(status.quarterHour);
|
||||||
|
setCurrent(status.current);
|
||||||
|
setDevices(status.devices);
|
||||||
|
setHistory(hist);
|
||||||
|
setActions(acts);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load status");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
const interval = setInterval(refresh, 10_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Error</h2>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quarterHour) {
|
||||||
|
return <div className="card">Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overTarget = quarterHour.overTarget;
|
||||||
|
const shedCount = devices.filter((d) => d.shed).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Live readings</h2>
|
||||||
|
<div className="stat-grid">
|
||||||
|
<div className="stat-tile">
|
||||||
|
<div className="stat-label">Power usage</div>
|
||||||
|
<div className="stat-value">{formatW(current?.powerW ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-tile">
|
||||||
|
<div className="stat-label">Solar generation</div>
|
||||||
|
<div className="stat-value">{formatW(current?.solarW ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Current quarter-hour block</h2>
|
||||||
|
<div className="hero-figure">{(quarterHour.projectedAverageW / 1000).toFixed(2)} kW</div>
|
||||||
|
<div className="hero-sub">
|
||||||
|
projected average · target {(quarterHour.targetW / 1000).toFixed(2)} kW · running average{" "}
|
||||||
|
{(quarterHour.runningAverageW / 1000).toFixed(2)} kW ·{" "}
|
||||||
|
{Math.round(quarterHour.elapsedFractionOfBlock * 100)}% through block
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<span className={`status-pill ${overTarget ? "critical" : "good"}`}>
|
||||||
|
{overTarget ? `Over target · ${shedCount} device(s) shed` : "Under target"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Devices</h2>
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No devices configured yet — add them in the Devices tab.</p>
|
||||||
|
) : (
|
||||||
|
devices
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.priority - b.priority)
|
||||||
|
.map((d) => (
|
||||||
|
<div className="device-row" key={d.id}>
|
||||||
|
<div>
|
||||||
|
<div className="device-name">{d.name}</div>
|
||||||
|
<div className="device-meta">
|
||||||
|
{d.entityId} · priority {d.priority}
|
||||||
|
{!d.available ? " · unavailable" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
{d.manualOverride && <span className="badge override">Manual</span>}
|
||||||
|
<span className={`badge ${d.shed ? "shed" : "active"}`}>{d.shed ? "Shed" : "Active"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Daily peak (last 30 days)</h2>
|
||||||
|
<PeakHistoryChart points={history} targetW={quarterHour.targetW} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Recent control actions</h2>
|
||||||
|
{actions.length === 0 ? (
|
||||||
|
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No actions yet.</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{actions.map((a, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{new Date(a.timestampMs).toLocaleTimeString()}</td>
|
||||||
|
<td>{a.entityId}</td>
|
||||||
|
<td>{a.action}</td>
|
||||||
|
<td style={{ color: "var(--text-secondary)" }}>{a.reason}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
energy-management/frontend/src/Devices.tsx
Normal file
324
energy-management/frontend/src/Devices.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { api, type ControlKind, type DeviceConfig } from "./api";
|
||||||
|
|
||||||
|
type DeviceFormValues = Omit<DeviceConfig, "id">;
|
||||||
|
|
||||||
|
const emptyForm: DeviceFormValues = {
|
||||||
|
entityId: "",
|
||||||
|
name: "",
|
||||||
|
kind: "switch" as ControlKind,
|
||||||
|
priority: 1,
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 1,
|
||||||
|
dwellSeconds: 300,
|
||||||
|
manualOverride: false,
|
||||||
|
onlyWhenSun: false,
|
||||||
|
fromTime: null,
|
||||||
|
toTime: null,
|
||||||
|
enableUrl: null,
|
||||||
|
disableUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function DeviceFields({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
values: DeviceFormValues;
|
||||||
|
onChange: (next: DeviceFormValues) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={values.name}
|
||||||
|
onChange={(e) => onChange({ ...values, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Home Assistant entity ID
|
||||||
|
<input
|
||||||
|
value={values.entityId}
|
||||||
|
onChange={(e) => onChange({ ...values, entityId: e.target.value })}
|
||||||
|
placeholder="switch.ev_charger"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Control type
|
||||||
|
<select
|
||||||
|
value={values.kind}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, kind: e.target.value as ControlKind })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="switch">Switch (on/off)</option>
|
||||||
|
<option value="number">Number (set-point)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Priority (lower = shed first)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={values.priority}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, priority: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{values.kind === "number" && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Min value (shed target)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={values.minValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, minValue: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Max value (normal)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={values.maxValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, maxValue: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
Min dwell time (seconds)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={values.dwellSeconds}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, dwellSeconds: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Only enable if sun
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={values.onlyWhenSun}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, onlyWhenSun: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
From (allowed window start)
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={values.fromTime ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, fromTime: e.target.value || null })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To (allowed window end)
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={values.toTime ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, toTime: e.target.value || null })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Custom enable endpoint (optional)
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={values.enableUrl ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, enableUrl: e.target.value || null })
|
||||||
|
}
|
||||||
|
placeholder="https://example.local/device/on"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Custom disable endpoint (optional)
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={values.disableUrl ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...values, disableUrl: e.target.value || null })
|
||||||
|
}
|
||||||
|
placeholder="https://example.local/device/off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Devices() {
|
||||||
|
const [devices, setDevices] = useState<DeviceConfig[]>([]);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<DeviceFormValues>(emptyForm);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setDevices(await api.getDevices());
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleCreate(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api.createDevice(form);
|
||||||
|
setForm(emptyForm);
|
||||||
|
await refresh();
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create device");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleOverride(d: DeviceConfig) {
|
||||||
|
await api.updateDevice(d.id, { manualOverride: !d.manualOverride });
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(d: DeviceConfig) {
|
||||||
|
if (!confirm(`Remove ${d.name}?`)) return;
|
||||||
|
await api.deleteDevice(d.id);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(d: DeviceConfig) {
|
||||||
|
const { id: _id, ...rest } = d;
|
||||||
|
setEditForm(rest);
|
||||||
|
setEditingId(d.id);
|
||||||
|
setEditError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (editingId == null) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.updateDevice(editingId, editForm);
|
||||||
|
await refresh();
|
||||||
|
setEditingId(null);
|
||||||
|
setEditError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setEditError(err instanceof Error ? err.message : "Failed to save device");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Add device</h2>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<DeviceFields values={form} onChange={setForm} />
|
||||||
|
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||||
|
Set both custom endpoints to control this device over plain HTTP
|
||||||
|
(POST) instead of Home Assistant. The entity ID above is still used
|
||||||
|
as the device's unique key.
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: "var(--status-critical)", fontSize: 13 }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button className="primary" type="submit">
|
||||||
|
Add device
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h2>Configured devices</h2>
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||||
|
No devices configured yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
devices
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.priority - b.priority)
|
||||||
|
.map((d) =>
|
||||||
|
editingId === d.id ? (
|
||||||
|
<form
|
||||||
|
className="device-row device-row-editing"
|
||||||
|
key={d.id}
|
||||||
|
onSubmit={saveEdit}
|
||||||
|
>
|
||||||
|
<DeviceFields values={editForm} onChange={setEditForm} />
|
||||||
|
{editError && (
|
||||||
|
<p style={{ color: "var(--status-critical)", fontSize: 13 }}>
|
||||||
|
{editError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button className="primary" type="submit" disabled={saving}>
|
||||||
|
{saving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="device-row" key={d.id}>
|
||||||
|
<div>
|
||||||
|
<div className="device-name">{d.name}</div>
|
||||||
|
<div className="device-meta">
|
||||||
|
{d.entityId} · {d.kind} · priority {d.priority} · dwell{" "}
|
||||||
|
{d.dwellSeconds}s{d.onlyWhenSun && " · sun only"}
|
||||||
|
{d.fromTime && d.toTime && ` · ${d.fromTime}–${d.toTime}`}
|
||||||
|
{d.enableUrl && d.disableUrl && " · custom endpoints"}
|
||||||
|
<div>{d.enableUrl}</div>
|
||||||
|
<div>{d.disableUrl}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
|
{d.manualOverride && (
|
||||||
|
<span className="badge override">Manual</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => toggleOverride(d)}
|
||||||
|
>
|
||||||
|
{d.manualOverride ? "Resume auto" : "Manual override"}
|
||||||
|
</button>
|
||||||
|
<button className="secondary" onClick={() => startEdit(d)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button className="secondary" onClick={() => remove(d)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
energy-management/frontend/src/PeakHistoryChart.tsx
Normal file
114
energy-management/frontend/src/PeakHistoryChart.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { BlockHistoryPoint } from "./api";
|
||||||
|
|
||||||
|
interface DailyPeak {
|
||||||
|
dayStartMs: number;
|
||||||
|
peakW: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDailyPeaks(points: BlockHistoryPoint[]): DailyPeak[] {
|
||||||
|
const byDay = new Map<number, number>();
|
||||||
|
for (const p of points) {
|
||||||
|
const dayStart = new Date(p.blockStartMs);
|
||||||
|
dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const key = dayStart.getTime();
|
||||||
|
byDay.set(key, Math.max(byDay.get(key) ?? 0, p.averageW));
|
||||||
|
}
|
||||||
|
return [...byDay.entries()]
|
||||||
|
.map(([dayStartMs, peakW]) => ({ dayStartMs, peakW }))
|
||||||
|
.sort((a, b) => a.dayStartMs - b.dayStartMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIDTH = 880;
|
||||||
|
const HEIGHT = 220;
|
||||||
|
const PAD = { top: 16, right: 16, bottom: 28, left: 48 };
|
||||||
|
|
||||||
|
export function PeakHistoryChart({ points, targetW }: { points: BlockHistoryPoint[]; targetW: number }) {
|
||||||
|
const daily = useMemo(() => toDailyPeaks(points), [points]);
|
||||||
|
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (daily.length === 0) {
|
||||||
|
return <p style={{ color: "var(--text-muted)", fontSize: 13 }}>No history yet — check back after the first day of data.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxW = Math.max(targetW, ...daily.map((d) => d.peakW)) * 1.1;
|
||||||
|
const innerW = WIDTH - PAD.left - PAD.right;
|
||||||
|
const innerH = HEIGHT - PAD.top - PAD.bottom;
|
||||||
|
|
||||||
|
const x = (i: number) => PAD.left + (daily.length === 1 ? innerW / 2 : (i / (daily.length - 1)) * innerW);
|
||||||
|
const y = (w: number) => PAD.top + innerH - (w / maxW) * innerH;
|
||||||
|
|
||||||
|
const linePath = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(d.peakW)}`).join(" ");
|
||||||
|
const targetY = y(targetW);
|
||||||
|
const hovered = hoverIdx !== null ? daily[hoverIdx] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} style={{ width: "100%", height: "auto", display: "block" }}>
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
|
||||||
|
<line
|
||||||
|
key={f}
|
||||||
|
x1={PAD.left}
|
||||||
|
x2={WIDTH - PAD.right}
|
||||||
|
y1={PAD.top + innerH * (1 - f)}
|
||||||
|
y2={PAD.top + innerH * (1 - f)}
|
||||||
|
stroke="var(--gridline)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<line x1={PAD.left} x2={WIDTH - PAD.right} y1={targetY} y2={targetY} stroke="var(--status-critical)" strokeWidth={1.5} strokeDasharray="4 4" />
|
||||||
|
<text x={WIDTH - PAD.right} y={targetY - 6} textAnchor="end" fontSize={11} fill="var(--status-critical)">
|
||||||
|
target {Math.round(targetW / 1000)} kW
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<path d={linePath} fill="none" stroke="var(--series-1)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
|
||||||
|
{daily.map((d, i) => (
|
||||||
|
<circle
|
||||||
|
key={d.dayStartMs}
|
||||||
|
cx={x(i)}
|
||||||
|
cy={y(d.peakW)}
|
||||||
|
r={hoverIdx === i ? 6 : 4}
|
||||||
|
fill={d.peakW > targetW ? "var(--status-critical)" : "var(--series-1)"}
|
||||||
|
stroke="var(--surface-1)"
|
||||||
|
strokeWidth={2}
|
||||||
|
onMouseEnter={() => setHoverIdx(i)}
|
||||||
|
onMouseLeave={() => setHoverIdx(null)}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{daily.map((d, i) =>
|
||||||
|
i % Math.ceil(daily.length / 8 || 1) === 0 ? (
|
||||||
|
<text key={d.dayStartMs} x={x(i)} y={HEIGHT - 8} textAnchor="middle" fontSize={11} fill="var(--text-muted)">
|
||||||
|
{new Date(d.dayStartMs).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{hovered && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${(x(hoverIdx!) / WIDTH) * 100}%`,
|
||||||
|
top: 0,
|
||||||
|
transform: "translate(-50%, -100%)",
|
||||||
|
background: "var(--surface-1)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
pointerEvents: "none",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{new Date(hovered.dayStartMs).toLocaleDateString()}</div>
|
||||||
|
<div style={{ color: "var(--text-secondary)" }}>peak {(hovered.peakW / 1000).toFixed(2)} kW</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
energy-management/frontend/src/api.ts
Normal file
79
energy-management/frontend/src/api.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
export type ControlKind = "switch" | "number";
|
||||||
|
|
||||||
|
export interface DeviceConfig {
|
||||||
|
id: number;
|
||||||
|
entityId: string;
|
||||||
|
name: string;
|
||||||
|
kind: ControlKind;
|
||||||
|
priority: number;
|
||||||
|
minValue: number;
|
||||||
|
maxValue: number;
|
||||||
|
dwellSeconds: number;
|
||||||
|
manualOverride: boolean;
|
||||||
|
onlyWhenSun: boolean;
|
||||||
|
fromTime: string | null;
|
||||||
|
toTime: string | null;
|
||||||
|
enableUrl: string | null;
|
||||||
|
disableUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceStatus extends DeviceConfig {
|
||||||
|
currentValue: number | null;
|
||||||
|
shed: boolean;
|
||||||
|
lastChangedAt: string | null;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuarterHourStatus {
|
||||||
|
blockStartMs: number;
|
||||||
|
blockEndMs: number;
|
||||||
|
elapsedFractionOfBlock: number;
|
||||||
|
runningAverageW: number;
|
||||||
|
projectedAverageW: number;
|
||||||
|
targetW: number;
|
||||||
|
overTarget: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentReadings {
|
||||||
|
powerW: number | null;
|
||||||
|
solarW: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlAction {
|
||||||
|
timestampMs: number;
|
||||||
|
entityId: string;
|
||||||
|
action: "shed" | "restore";
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockHistoryPoint {
|
||||||
|
blockStartMs: number;
|
||||||
|
averageW: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: { ...(init?.body ? { "Content-Type": "application/json" } : {}), ...init?.headers },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${init?.method ?? "GET"} ${path} failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getStatus: () =>
|
||||||
|
request<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }>(
|
||||||
|
"/api/status",
|
||||||
|
),
|
||||||
|
getHistory: (sinceMs: number) => request<BlockHistoryPoint[]>(`/api/history?sinceMs=${sinceMs}`),
|
||||||
|
getActions: (limit = 50) => request<ControlAction[]>(`/api/actions?limit=${limit}`),
|
||||||
|
getDevices: () => request<DeviceConfig[]>("/api/devices"),
|
||||||
|
createDevice: (device: Omit<DeviceConfig, "id">) =>
|
||||||
|
request<DeviceConfig>("/api/devices", { method: "POST", body: JSON.stringify(device) }),
|
||||||
|
updateDevice: (id: number, patch: Partial<Omit<DeviceConfig, "id">>) =>
|
||||||
|
request<{ ok: true }>(`/api/devices/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
deleteDevice: (id: number) => request<{ ok: true }>(`/api/devices/${id}`, { method: "DELETE" }),
|
||||||
|
getTarget: () => request<{ targetW: number }>("/api/settings/target"),
|
||||||
|
setTargetKw: (targetKw: number) =>
|
||||||
|
request<{ ok: true }>("/api/settings/target", { method: "PUT", body: JSON.stringify({ targetKw }) }),
|
||||||
|
};
|
||||||
10
energy-management/frontend/src/main.tsx
Normal file
10
energy-management/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./theme.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
290
energy-management/frontend/src/theme.css
Normal file
290
energy-management/frontend/src/theme.css
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface-1: #fcfcfb;
|
||||||
|
--page-plane: #f9f9f7;
|
||||||
|
--text-primary: #0b0b0b;
|
||||||
|
--text-secondary: #52514e;
|
||||||
|
--text-muted: #898781;
|
||||||
|
--gridline: #e1e0d9;
|
||||||
|
--baseline: #c3c2b7;
|
||||||
|
--border: rgba(11, 11, 11, 0.1);
|
||||||
|
--series-1: #2a78d6;
|
||||||
|
--seq-300: #6da7ec;
|
||||||
|
--seq-500: #256abf;
|
||||||
|
--status-good: #0ca30c;
|
||||||
|
--status-warning: #fab219;
|
||||||
|
--status-critical: #d03b3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:where(:not([data-theme="light"])) {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface-1: #1a1a19;
|
||||||
|
--page-plane: #0d0d0d;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c3c2b7;
|
||||||
|
--text-muted: #898781;
|
||||||
|
--gridline: #2c2c2a;
|
||||||
|
--baseline: #383835;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--series-1: #3987e5;
|
||||||
|
--seq-300: #5598e7;
|
||||||
|
--seq-500: #1c5cab;
|
||||||
|
--status-good: #0ca30c;
|
||||||
|
--status-warning: #fab219;
|
||||||
|
--status-critical: #d03b3b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface-1: #1a1a19;
|
||||||
|
--page-plane: #0d0d0d;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c3c2b7;
|
||||||
|
--text-muted: #898781;
|
||||||
|
--gridline: #2c2c2a;
|
||||||
|
--baseline: #383835;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--series-1: #3987e5;
|
||||||
|
--seq-300: #5598e7;
|
||||||
|
--seq-500: #1c5cab;
|
||||||
|
--status-good: #0ca30c;
|
||||||
|
--status-warning: #fab219;
|
||||||
|
--status-critical: #d03b3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--page-plane);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 20px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tabs button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tabs button.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom-color: var(--series-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-figure {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-tile {
|
||||||
|
background: var(--surface-2, transparent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.good {
|
||||||
|
color: var(--status-good);
|
||||||
|
background: color-mix(in srgb, var(--status-good) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.critical {
|
||||||
|
color: var(--status-critical);
|
||||||
|
background: color-mix(in srgb, var(--status-critical) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--gridline);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-row-editing {
|
||||||
|
display: block;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.shed {
|
||||||
|
color: var(--status-critical);
|
||||||
|
background: color-mix(in srgb, var(--status-critical) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.active {
|
||||||
|
color: var(--status-good);
|
||||||
|
background: color-mix(in srgb, var(--status-good) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.override {
|
||||||
|
color: var(--status-warning);
|
||||||
|
background: color-mix(in srgb, var(--status-warning) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--series-1);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
font: inherit;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--gridline);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--gridline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
16
energy-management/frontend/tsconfig.json
Normal file
16
energy-management/frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
energy-management/frontend/tsconfig.tsbuildinfo
Normal file
1
energy-management/frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/App.tsx","./src/Dashboard.tsx","./src/Devices.tsx","./src/PeakHistoryChart.tsx","./src/api.ts","./src/main.tsx"],"version":"5.9.3"}
|
||||||
11
energy-management/frontend/vite.config.ts
Normal file
11
energy-management/frontend/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
3872
energy-management/package-lock.json
generated
Normal file
3872
energy-management/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
energy-management/package.json
Normal file
14
energy-management/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "energy-management",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"backend",
|
||||||
|
"frontend"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev:backend": "npm run dev -w backend",
|
||||||
|
"dev:frontend": "npm run dev -w frontend",
|
||||||
|
"build": "npm run build -w backend && npm run build -w frontend",
|
||||||
|
"test": "npm run test -w backend"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -5,75 +7,120 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// Add JSON serialization services
|
// Add JSON serialization services
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
{
|
{
|
||||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapPost(
|
app.MapPost(
|
||||||
"/high_power_alert",
|
"/high_power_alert",
|
||||||
async (AlertBody body) =>
|
async (AlertBody body) =>
|
||||||
{
|
{
|
||||||
Console.WriteLine("high power alert was received.");
|
Console.WriteLine("high power alert was received.");
|
||||||
using var client = new HttpClient();
|
using var client = new HttpClient();
|
||||||
var heatPumpState = await client.GetFromJsonAsync<HeatPumpStatus>("http://192.168.1.121/control");
|
var heatPumpState = await client.GetFromJsonAsync<HeatPumpStatus>(
|
||||||
var message = "";
|
"http://192.168.1.121/control"
|
||||||
Console.WriteLine(heatPumpState.heatpump.power);
|
);
|
||||||
switch (body.Status)
|
var message = "";
|
||||||
{
|
Console.WriteLine(heatPumpState.heatpump.power);
|
||||||
case "firing":
|
switch (body.Status)
|
||||||
Console.WriteLine("Peak too high. Shutting down heat pump.");
|
{
|
||||||
message = "@everyone peak is too high. Disabling heat pump. 🥶";
|
case "firing":
|
||||||
if (heatPumpState.heatpump.power == "on")
|
Console.WriteLine("Peak too high. Shutting down heat pump.");
|
||||||
{
|
message = "@everyone peak is too high. Disabling heat pump. 🥶";
|
||||||
await client.GetAsync(
|
if (heatPumpState.heatpump.power == "on")
|
||||||
"http://192.168.1.121/control?cmd=heatpump&set_power_mode=off"
|
{
|
||||||
);
|
await client.GetAsync(
|
||||||
}
|
"http://192.168.1.121/control?cmd=heatpump&set_power_mode=off"
|
||||||
else
|
);
|
||||||
{
|
}
|
||||||
message = "@everyone high power alert, but heat pump could not be disabled because state was not 'ON'";
|
else
|
||||||
}
|
{
|
||||||
await client.PostAsJsonAsync<DiscordMessage>(
|
message =
|
||||||
"https://discord.com/api/webhooks/1455072237207158981/C9qvSIGMZVc60VwpZizxqKigyzvA182RDSdt9k8qtWTq-fBgzlJHh53wAYIqYkGNkBM3",
|
"@everyone high power alert, but heat pump could not be disabled because state was not 'ON'";
|
||||||
new DiscordMessage(message, "willemBot")
|
}
|
||||||
);
|
await client.PostAsJsonAsync<DiscordMessage>(
|
||||||
break;
|
"https://discord.com/api/webhooks/1455072237207158981/C9qvSIGMZVc60VwpZizxqKigyzvA182RDSdt9k8qtWTq-fBgzlJHh53wAYIqYkGNkBM3",
|
||||||
case "resolved":
|
new DiscordMessage(message, "willemBot")
|
||||||
Console.WriteLine("Peak acceptable. Re enabling heat pump. 🌶️");
|
);
|
||||||
message = "Re enabled heat pump.";
|
break;
|
||||||
if (heatPumpState.heatpump.power == "off")
|
case "resolved":
|
||||||
{
|
Console.WriteLine("Peak acceptable. Re enabling heat pump. 🌶️");
|
||||||
await client.GetAsync(
|
message = "Re enabled heat pump.";
|
||||||
"http://192.168.1.121/control?cmd=heatpump&set_power_mode=on"
|
if (heatPumpState.heatpump.power == "off")
|
||||||
);
|
{
|
||||||
}
|
await client.GetAsync(
|
||||||
else {
|
"http://192.168.1.121/control?cmd=heatpump&set_power_mode=on"
|
||||||
message = "@everyone Heat pump could not be re enabled because state was not 'ON'";
|
);
|
||||||
}
|
}
|
||||||
await client.PostAsJsonAsync<DiscordMessage>(
|
else
|
||||||
"https://discord.com/api/webhooks/1455072237207158981/C9qvSIGMZVc60VwpZizxqKigyzvA182RDSdt9k8qtWTq-fBgzlJHh53wAYIqYkGNkBM3",
|
{
|
||||||
new DiscordMessage(message, "willemBot")
|
message =
|
||||||
);
|
"@everyone Heat pump could not be re enabled because state was not 'ON'";
|
||||||
break;
|
}
|
||||||
default:
|
await client.PostAsJsonAsync<DiscordMessage>(
|
||||||
Console.WriteLine("Unknown body type");
|
"https://discord.com/api/webhooks/1455072237207158981/C9qvSIGMZVc60VwpZizxqKigyzvA182RDSdt9k8qtWTq-fBgzlJHh53wAYIqYkGNkBM3",
|
||||||
break;
|
new DiscordMessage(message, "willemBot")
|
||||||
}
|
);
|
||||||
return new OkResult();
|
break;
|
||||||
}
|
default:
|
||||||
)
|
Console.WriteLine("Unknown body type");
|
||||||
.WithName("HighPowerAlert");
|
break;
|
||||||
|
}
|
||||||
|
return new OkResult();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.WithName("HighPowerAlert");
|
||||||
|
|
||||||
app.MapPost(
|
app.MapPost(
|
||||||
"/low_power_alert",
|
"/low_power_alert",
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
Console.WriteLine("low power alert was received.");
|
Console.WriteLine("low power alert was received.");
|
||||||
return new OkResult();
|
return new OkResult();
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.WithName("Low Power Alert");
|
.WithName("Low Power Alert");
|
||||||
|
|
||||||
|
// Example: querying an InfluxDB v2 instance via its Flux query API.
|
||||||
|
app.MapGet(
|
||||||
|
"/example/influx_query",
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
var token = "0kycl3gLT7kWBzfrNcvkmXVDUUjdWUwKnTmBZJfS_dHrE2EiJYv_HthDLc92Xsdaui3FxRsm7zndyyS33Mh9YA==";
|
||||||
|
var org = "homeassistant";
|
||||||
|
|
||||||
|
using var client = new HttpClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||||
|
"Token",
|
||||||
|
token
|
||||||
|
);
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(
|
||||||
|
new MediaTypeWithQualityHeaderValue("application/csv")
|
||||||
|
);
|
||||||
|
|
||||||
|
var flux = """
|
||||||
|
from(bucket: "homeassistant")
|
||||||
|
|> range(start: -5m)
|
||||||
|
|> filter(fn: (r) => r._measurement == "W")
|
||||||
|
|> filter(fn: (r) => r.domain == "sensor")
|
||||||
|
|> filter(fn: (r) => r.entity_id == "p1_meter_power")
|
||||||
|
|> filter(fn: (r) => r._field == "value")
|
||||||
|
|> mean()
|
||||||
|
|> yield(name: "mean")
|
||||||
|
""";
|
||||||
|
var content = new StringContent(flux, Encoding.UTF8, "application/vnd.flux");
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
$"http://influxdb.pladijs/api/v2/query?org={org}",
|
||||||
|
content
|
||||||
|
);
|
||||||
|
var result = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
return Results.Content(result, "text/csv");
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.WithName("ExampleInfluxQuery");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
allow_anonymous false
|
|
||||||
listener 1883
|
|
||||||
listener 9001
|
|
||||||
protocol websockets
|
|
||||||
persistence true
|
|
||||||
password_file /mosquitto/config/pwfile
|
|
||||||
persistence_file mosquitto.db
|
|
||||||
persistence_location /mosquitto/data/
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
admin:$7$101$kEph6X+U4i2qdSC5$Osz6gdmEmkLtO4ek0eGRBWzJpuaA6CcLImyPaGs13TWkKopFNUWCtHUDzyZ1U55yhFzIADhe0ppncJCFMlg5lQ==
|
|
||||||
36
smappee-api/ChargerOptions.cs
Normal file
36
smappee-api/ChargerOptions.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace options;
|
||||||
|
|
||||||
|
internal class ChargerOptions
|
||||||
|
{
|
||||||
|
public const string Name = "CHARGER";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[ConfigurationKeyName("URL")]
|
||||||
|
public required Uri Url { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[ConfigurationKeyName("CLIENT_ID")]
|
||||||
|
public required string ClientId { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[ConfigurationKeyName("CLIENT_SECRET")]
|
||||||
|
public required string ClientSecret { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[ConfigurationKeyName("USERNAME")]
|
||||||
|
public required string UserName { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[ConfigurationKeyName("PASSWORD")]
|
||||||
|
public required string Password { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[ConfigurationKeyName("STATION")]
|
||||||
|
public required string ChargingStation { get; init; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[ConfigurationKeyName("CONNECTOR")]
|
||||||
|
public required int Connector { get; init; }
|
||||||
|
}
|
||||||
17
smappee-api/Dockerfile
Normal file
17
smappee-api/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY smappee-api.csproj ./
|
||||||
|
RUN dotnet restore
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet publish -c Release -o /app
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app .
|
||||||
|
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "smappee-api.dll"]
|
||||||
42
smappee-api/Program.cs
Normal file
42
smappee-api/Program.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using enums;
|
||||||
|
using interfaces;
|
||||||
|
using options;
|
||||||
|
using services;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
builder.Services.AddRedaction();
|
||||||
|
builder.Services.AddExtendedHttpClientLogging(
|
||||||
|
builder.Configuration.GetSection("HttpClientLogging"));
|
||||||
|
|
||||||
|
builder
|
||||||
|
.Services.AddOptions<ChargerOptions>()
|
||||||
|
.Bind(builder.Configuration.GetSection(ChargerOptions.Name))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
builder.Services.AddSingleton<IBearerTokenService, SmappeeTokenService>();
|
||||||
|
builder.Services.AddScoped<IChargerService, SmappeeChargerService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapPost(
|
||||||
|
"/enable",
|
||||||
|
async (IChargerService chargerService) =>
|
||||||
|
{
|
||||||
|
await chargerService.ExecuteRequest(ChargerRequests.Enable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.WithName("Enable");
|
||||||
|
|
||||||
|
app.MapPost(
|
||||||
|
"/disable",
|
||||||
|
async (IChargerService chargerService) =>
|
||||||
|
{
|
||||||
|
await chargerService.ExecuteRequest(ChargerRequests.Disable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.WithName("Disable");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
23
smappee-api/Properties/launchSettings.json
Normal file
23
smappee-api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5057",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7028;http://localhost:5057",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
smappee-api/appsettings.Development.json
Normal file
37
smappee-api/appsettings.Development.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Information"
|
||||||
|
},
|
||||||
|
"Console": {
|
||||||
|
"FormatterName": "json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CHARGER": {
|
||||||
|
"URL": "https://app1pub.smappee.net",
|
||||||
|
"CLIENT_ID": "5700002178",
|
||||||
|
"CLIENT_SECRET": "cPcRKc26ul",
|
||||||
|
"USERNAME": "serruysw@gmail.com",
|
||||||
|
"PASSWORD": "uAfUhnBXkZeqs$@7ca7P3M9QE6z0Dg1N0cbz&",
|
||||||
|
"STATION": "6230003897",
|
||||||
|
"CONNECTOR": 1
|
||||||
|
},
|
||||||
|
"HttpClientLogging": {
|
||||||
|
"LogRequestStart": false,
|
||||||
|
"LogBody": true,
|
||||||
|
"LogContentHeaders": true,
|
||||||
|
"BodySizeLimit": 32768,
|
||||||
|
"BodyReadTimeout": "00:00:01",
|
||||||
|
"RequestBodyContentTypes": [ "application/json", "application/x-www-form-urlencoded" ],
|
||||||
|
"ResponseBodyContentTypes": [ "application/json" ],
|
||||||
|
"RequestHeadersDataClasses": {
|
||||||
|
"User-Agent": "None",
|
||||||
|
"Content-Type": "None"
|
||||||
|
},
|
||||||
|
"ResponseHeadersDataClasses": {
|
||||||
|
"Content-Type": "None"
|
||||||
|
},
|
||||||
|
"RequestPathParameterRedactionMode": "None"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
smappee-api/appsettings.json
Normal file
9
smappee-api/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
7
smappee-api/docker-compose.yml
Normal file
7
smappee-api/docker-compose.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
smappee-api:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5057:8080"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
7
smappee-api/enums/ChargerRequests.cs
Normal file
7
smappee-api/enums/ChargerRequests.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace enums;
|
||||||
|
|
||||||
|
internal enum ChargerRequests
|
||||||
|
{
|
||||||
|
Enable,
|
||||||
|
Disable
|
||||||
|
}
|
||||||
5
smappee-api/interfaces/IBearerTokenService.cs
Normal file
5
smappee-api/interfaces/IBearerTokenService.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
namespace interfaces;
|
||||||
|
|
||||||
|
internal interface IBearerTokenService {
|
||||||
|
ValueTask<string> GetToken(bool forceNewToken);
|
||||||
|
}
|
||||||
8
smappee-api/interfaces/IChargerService.cs
Normal file
8
smappee-api/interfaces/IChargerService.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using enums;
|
||||||
|
|
||||||
|
namespace interfaces;
|
||||||
|
|
||||||
|
internal interface IChargerService
|
||||||
|
{
|
||||||
|
Task ExecuteRequest(ChargerRequests request);
|
||||||
|
}
|
||||||
51
smappee-api/services/SmappeeChargerService.cs
Normal file
51
smappee-api/services/SmappeeChargerService.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
using enums;
|
||||||
|
using interfaces;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using options;
|
||||||
|
|
||||||
|
namespace services;
|
||||||
|
|
||||||
|
internal class SmappeeChargerService : IChargerService
|
||||||
|
{
|
||||||
|
private readonly ChargerOptions options;
|
||||||
|
private readonly IBearerTokenService tokenService;
|
||||||
|
private readonly IHttpClientFactory httpClientFactory;
|
||||||
|
|
||||||
|
public SmappeeChargerService(
|
||||||
|
IOptions<ChargerOptions> options,
|
||||||
|
IBearerTokenService tokenService,
|
||||||
|
IHttpClientFactory httpClientFactory
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tokenService);
|
||||||
|
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
this.tokenService = tokenService;
|
||||||
|
this.httpClientFactory =
|
||||||
|
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteRequest(ChargerRequests request)
|
||||||
|
{
|
||||||
|
var token = await tokenService.GetToken(false);
|
||||||
|
var httpClient = httpClientFactory.CreateClient("smappee");
|
||||||
|
httpClient.BaseAddress = options.Url;
|
||||||
|
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
|
||||||
|
var response = request switch
|
||||||
|
{
|
||||||
|
ChargerRequests.Enable => await httpClient.PutAsJsonAsync<SmappeeRequestBody>(
|
||||||
|
$"/dev/v3/chargingstations/{options.ChargingStation}/connectors/{options.Connector}/mode",
|
||||||
|
new SmappeeRequestBody("NORMAL", new Limit("AMPERE", 6))
|
||||||
|
),
|
||||||
|
ChargerRequests.Disable => await httpClient.PutAsJsonAsync(
|
||||||
|
$"/dev/v3/chargingstations/{options.ChargingStation}/connectors/{options.Connector}/mode",
|
||||||
|
new SmappeeRequestBody("PAUSED", null)
|
||||||
|
),
|
||||||
|
_ => throw new NotImplementedException(),
|
||||||
|
};
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
record SmappeeRequestBody(string mode, Limit? limit);
|
||||||
|
|
||||||
|
record Limit(string unit, int value);
|
||||||
|
}
|
||||||
68
smappee-api/services/SmappeeTokenService.cs
Normal file
68
smappee-api/services/SmappeeTokenService.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using interfaces;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using options;
|
||||||
|
|
||||||
|
namespace services;
|
||||||
|
|
||||||
|
internal class SmappeeTokenService : IBearerTokenService
|
||||||
|
{
|
||||||
|
private readonly ChargerOptions options;
|
||||||
|
private readonly IHttpClientFactory httpClientFactory;
|
||||||
|
|
||||||
|
private TokenData? tokenData = null;
|
||||||
|
|
||||||
|
public SmappeeTokenService(
|
||||||
|
IOptions<ChargerOptions> options,
|
||||||
|
IHttpClientFactory httpClientFactory
|
||||||
|
)
|
||||||
|
{
|
||||||
|
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
this.httpClientFactory =
|
||||||
|
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<string> GetToken(bool forceNewToken)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
tokenData == null
|
||||||
|
|| tokenData.Expiration < DateTime.UtcNow.AddSeconds(-10)
|
||||||
|
|| forceNewToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
tokenData = await RequestNewToken();
|
||||||
|
}
|
||||||
|
return tokenData.Token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TokenData> RequestNewToken()
|
||||||
|
{
|
||||||
|
var httpClient = httpClientFactory.CreateClient("smappeeTokenService");
|
||||||
|
httpClient.BaseAddress = options.Url;
|
||||||
|
|
||||||
|
var result = await httpClient.PostAsync(
|
||||||
|
"/dev/v3/oauth2/token",
|
||||||
|
new FormUrlEncodedContent(
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "password",
|
||||||
|
["client_id"] = options.ClientId,
|
||||||
|
["client_secret"] = options.ClientSecret,
|
||||||
|
["username"] = options.UserName,
|
||||||
|
["password"] = options.Password,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
result.EnsureSuccessStatusCode();
|
||||||
|
var tokenResponse =
|
||||||
|
await result.Content.ReadFromJsonAsync<TokenResponse>()
|
||||||
|
?? throw new InvalidOperationException("Token could not be fetched");
|
||||||
|
return new TokenData(
|
||||||
|
tokenResponse.access_token,
|
||||||
|
DateTime.UtcNow.AddSeconds(tokenResponse.expires_in)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
record TokenResponse(string access_token, int expires_in);
|
||||||
|
|
||||||
|
record TokenData(string Token, DateTime Expiration);
|
||||||
|
}
|
||||||
16
smappee-api/smappee-api.csproj
Normal file
16
smappee-api/smappee-api.csproj
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>smappee_api</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Compliance.Redaction" Version="10.9.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http.Diagnostics" Version="10.9.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
smappee-api/smappee-api.http
Normal file
6
smappee-api/smappee-api.http
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@smappee_api_HostAddress = http://localhost:5057
|
||||||
|
|
||||||
|
GET {{smappee_api_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Setup OpenVPN
|
|
||||||
|
|
||||||
## Intro
|
|
||||||
|
|
||||||
OpenVPN is used for the connection between the home-server and the home network.
|
|
||||||
|
|
||||||
It can be easily set up using [this link](https://pivpn.io/)
|
|
||||||
|
|
||||||
|
|
||||||
OpenVPN3 clients can easily be downloaded and installed using the
|
|
||||||
[following link](https://openvpn.net/cloud-docs/openvpn-3-client-for-linux/)
|
|
||||||
|
|
||||||
## To Do
|
|
||||||
|
|
||||||
Make sure that the home-server is always reconnecting to the VPN when starting up.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
dev tun
|
|
||||||
proto udp
|
|
||||||
port 1194
|
|
||||||
ca /etc/openvpn/easy-rsa/pki/ca.crt
|
|
||||||
cert /etc/openvpn/easy-rsa/pki/issued/rpi-main_9c719429-6726-4df0-b9a6-a6054de4df85.crt
|
|
||||||
key /etc/openvpn/easy-rsa/pki/private/rpi-main_9c719429-6726-4df0-b9a6-a6054de4df85.key
|
|
||||||
dh none
|
|
||||||
ecdh-curve prime256v1
|
|
||||||
topology subnet
|
|
||||||
server 10.11.175.0 255.255.255.0
|
|
||||||
# Set your primary domain name server address for clients
|
|
||||||
push "dhcp-option DNS 192.168.1.81"
|
|
||||||
# Prevent DNS leaks on Windows
|
|
||||||
push "block-outside-dns"
|
|
||||||
# Override the Client default gateway by using 0.0.0.0/1 and
|
|
||||||
# 128.0.0.0/1 rather than 0.0.0.0/0. This has the benefit of
|
|
||||||
# overriding but not wiping out the original default gateway.
|
|
||||||
push "redirect-gateway def1"
|
|
||||||
client-to-client
|
|
||||||
client-config-dir /etc/openvpn/ccd
|
|
||||||
keepalive 15 120
|
|
||||||
remote-cert-tls client
|
|
||||||
tls-version-min 1.2
|
|
||||||
tls-crypt /etc/openvpn/easy-rsa/pki/ta.key
|
|
||||||
cipher AES-256-CBC
|
|
||||||
auth SHA256
|
|
||||||
user openvpn
|
|
||||||
group openvpn
|
|
||||||
persist-key
|
|
||||||
persist-tun
|
|
||||||
crl-verify /etc/openvpn/crl.pem
|
|
||||||
status /var/log/openvpn-status.log 20
|
|
||||||
status-version 3
|
|
||||||
syslog
|
|
||||||
verb 3
|
|
||||||
#DuplicateCNs allow access control on a less-granular, per user basis.
|
|
||||||
#Remove # if you will manage access by user instead of device.
|
|
||||||
#duplicate-cn
|
|
||||||
# Generated for use by PiVPN.io
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# VPN Setup
|
|
||||||
|
|
||||||
I used 2 VPNs in my home setup
|
|
||||||
|
|
||||||
1. Unify VPN
|
|
||||||
2. OpenVPN (= PiVPN)
|
|
||||||
|
|
||||||
Reason being that Unify VPN is not easy to set up for an ubuntu machine, and I was unable to configure my VPS with it.
|
|
||||||
That is why I set up an openVPN to connect my local network to the home-server.
|
|
||||||
Reference in New Issue
Block a user