35 Commits

Author SHA1 Message Date
d2ee8a80c2 add car feedback 3
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2025-12-06 14:20:13 +01:00
5e0127b571 add car feedback 2
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2025-12-06 14:17:25 +01:00
311d4cf555 add car feedback
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2025-12-06 14:15:03 +01:00
ad043b5921 add raw output 3
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline failed
2025-12-05 15:00:22 +01:00
7c90962de1 add raw output 2
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2025-12-05 14:58:46 +01:00
3a4cd499a5 add raw output
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2025-12-05 14:44:24 +01:00
6e50654d00 lower case
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2025-12-05 13:45:00 +01:00
e820aa2000 fix in ci 2025-12-05 13:36:19 +01:00
8e60802a7a fix configuration
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2025-12-05 13:33:55 +01:00
2f87ec6d37 test ci 10
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2025-12-05 13:31:41 +01:00
3290982be1 test ci 9
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline failed
2025-12-05 13:28:23 +01:00
e96e361414 test ci 8
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline failed
2025-12-05 13:24:48 +01:00
87ec74dd0e test ci 7
Some checks failed
ci/woodpecker/tag/woodpecker Pipeline failed
2025-12-05 13:23:01 +01:00
a067be9d9e test ci 6
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2025-12-05 13:21:31 +01:00
190021bb84 test ci 5
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-05 13:18:21 +01:00
2842b3e4ec test ci 4
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-05 13:16:36 +01:00
cf62f384ac test ci 3
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-05 13:14:30 +01:00
5496c5e94e test ci 2 2025-12-05 13:14:00 +01:00
4405f5f7e7 test ci 1
Some checks failed
ci/woodpecker/tag/namespace Pipeline was successful
ci/woodpecker/tag/config Pipeline failed
ci/woodpecker/tag/build Pipeline was successful
2025-12-05 12:37:21 +01:00
651f370a8f ci 2025-12-05 12:30:19 +01:00
3f13a5adfa should work so far 2025-12-05 11:54:53 +01:00
6faed5441c fix 2025-12-03 19:03:46 +01:00
1a32b20648 car control 2025-12-03 18:09:25 +01:00
78946ac4c7 changes working so far 2025-12-03 12:19:51 +01:00
0f0da63a8f initial for multiple devices, introduce real configuration 2025-12-02 14:49:51 +01:00
86139754bd cd-script fixed
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-01-11 14:28:23 +01:00
6127129b03 cd-script fixed
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-01-11 14:26:25 +01:00
6564722727 cd-script added
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-11 14:23:54 +01:00
a65b991307 add woodpecker ci script
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-01-11 14:11:43 +01:00
4ea8a3688b fix 2023-11-08 18:33:49 +01:00
0eace0be7c env 2023-11-08 18:31:21 +01:00
88c442697f deployment added 2023-11-08 17:14:29 +01:00
c2053acee3 fix 2023-11-08 17:12:22 +01:00
827ebdadad fix 2023-11-08 14:55:05 +01:00
3d28188833 fix 2023-11-08 14:49:48 +01:00
22 changed files with 634 additions and 633 deletions

View File

@@ -1,5 +0,0 @@
include:
- project: dockerized/commons
ref: master
file: gitlab-ci-template.yml

69
.woodpecker.yml Normal file
View File

@@ -0,0 +1,69 @@
when:
event:
- tag
variables:
- &NAMESPACE 'homea-ctrl-1'
steps:
build:
image: plugins/kaniko
settings:
registry:
from_secret: local_registry
username:
from_secret: local_username
password:
from_secret: local_password
repo: ${FORGE_NAME}/${CI_REPO}
auto_tag: true
dockerfile: Dockerfile
when:
ref:
exclude:
- refs/tags/*-configchange
namespace:
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
environment:
KUBE_CONFIG_CONTENT:
from_secret: kube_config
NAMESPACE: *NAMESPACE
commands:
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
- export KUBECONFIG=/tmp/kubeconfig
- kubectl create namespace $NAMESPACE || echo "Namespace $NAMESPACE already exists"
when:
ref:
exclude:
- refs/tags/*-configchange
configuration:
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
environment:
KUBE_CONFIG_CONTENT:
from_secret: kube_config
NAMESPACE: *NAMESPACE
commands:
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
- export KUBECONFIG=/tmp/kubeconfig
- kubectl create configmap pv-controller-config
--from-file=config.yaml=config/config.yaml
--namespace=$NAMESPACE
--dry-run=client -o yaml | kubectl apply -f -
deploy:
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
environment:
KUBE_CONFIG_CONTENT:
from_secret: kube_config
NAMESPACE: *NAMESPACE
IMAGE: "${FORGE_NAME}/${CI_REPO}:${CI_COMMIT_TAG}"
commands:
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
- export KUBECONFIG=/tmp/kubeconfig
- cat deployment/install-yml.tmpl | sed "s,%IMAGE%,$IMAGE,g" | kubectl apply -n $NAMESPACE -f -
when:
ref:
exclude:
- refs/tags/*-configchange

View File

@@ -1,33 +1,23 @@
FROM python:latest
FROM python:3.14-alpine
LABEL Maintainer="Wolfgang Hottgenroth wolfgang.hottgenroth@icloud.com"
LABEL ImageName="registry.hottis.de/dockerized/pv-controller"
LABEL HubImageName="wn/pv-controller"
ARG APP_DIR="/opt/app"
ARG CONF_DIR="${APP_DIR}/config"
ARG APP_USER="app"
ENV CFG_FILE ""
RUN \
apt update && \
pip3 install loguru && \
pip3 install pymodbus && \
pip3 install paho-mqtt
RUN \
mkdir -p ${APP_DIR} && \
mkdir -p ${CONF_DIR} && \
useradd -d ${APP_DIR} -u 1000 user
COPY ./src/pv_controller/*.py ${APP_DIR}/
COPY config.ini ${CONF_DIR}/
USER 1000:1000
WORKDIR ${APP_DIR}
VOLUME ${CONF_DIR}
COPY ./src/pv_controller/requirements.txt requirements.txt
COPY ./src/pv_controller/*.py ${APP_DIR}/
CMD [ "python", "pvc.py", "-f", "/opt/app/config/config.ini" ]
RUN addgroup -g 10001 -S ${APP_USER} && \
adduser -u 10001 -S ${APP_USER} -G ${APP_USER} && \
pip install --no-cache-dir -r requirements.txt
USER ${APP_USER}
CMD ["python", "pvc.py"]

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Wolfgang Hottgenroth
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,14 +0,0 @@
[mqtt]
broker = 172.16.2.16
port = 1883
# login =
# password =
# ca =
# cert =
# key =
relaisSubscribeTopic = IoT/PV/Cmd
meterPublishTopic = IoT/PV/Values
meterPublishPeriod = 15
[modbus]
gateway = 172.16.2.42

246
config/config.yaml Normal file
View File

@@ -0,0 +1,246 @@
global:
scan_interval: 1
log_level: INFO
mqtt:
broker: emqx01-anonymous-cluster-internal.broker.svc.cluster.local
port: 1883
modbus:
gateway: 172.16.2.42
input:
- name: pv_control
subscribe_topic: IoT/PV/Control
slave_id: 1
address: 0
register_type: coil
- name: car_control
subscribe_topic: IoT/Car/Control
slave_id: 5
address: 0
register_type: coil
output:
- name: pv_meter
enabled: true
scan_rate: 15
publish_topic: IoT/PV/Values
slave_id: 2
registers:
- address: 0x0048
attribute: importEnergyActive
name: Import active energy
unit: kWh
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x004c
attribute: importEnergyReactive
name: Import reactive energy
unit: kVAh
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x004a
attribute: exportEnergyActive
name: Export active energy
unit: kWh
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x004e
attribute: exportEnergyReactive
name: Export reactive energy
unit: kVAh
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x0012
attribute: powerApparent
name: Apparent Power
unit: W
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x000c
attribute: powerActive
name: Active Power
unit: W
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x0018
attribute: powerReactive
name: Reactive Power
unit: W
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x0058
attribute: powerDemandPositive
name: PositivePowerDemand
unit: W
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x005c
attribute: powerDemandReverse
name: ReversePowerDemand
unit: W
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x001e
attribute: factor
name: Factor
unit: "-"
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x0024
attribute: angle
name: Angle
unit: degree
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x0000
attribute: voltage
name: Voltage
unit: V
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x0006
attribute: current
name: Current
unit: A
register_type: input
data_type: float32
adaptor: floatAdaptor
- name: pv_control
publish_topic: IoT/PV/Control/State
scan_rate: 1
raw_output: true # use only for output device with only one register, name this register 'output'
slave_id: 1
registers:
- address: 0x0001
attribute: output
name: State
unit: "-"
register_type: holding
data_type: int32
adaptor: onOffAdaptor
- name: car_control
enabled: true
publish_topic: IoT/Car/Control/State
scan_rate: 1
raw_output: true # use only for output device with only one register, name this register 'output'
slave_id: 5
registers:
- address: 0x0001
attribute: output
name: State
unit: "-"
register_type: holding
data_type: int32
adaptor: onOffAdaptor
- name: car_meter
enabled: true
publish_topic: IoT/Car/Values
scan_rate: 15
slave_id: 6
registers:
- address: 0
attribute: voltageL1
name: Voltage L1
unit: V
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 2
attribute: voltageL2
name: Voltage L2
unit: V
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 4
attribute: voltageL3
name: Voltage L3
unit: V
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 6
attribute: currentL1
name: Current L1
unit: A
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 8
attribute: currentL2
name: Current L2
unit: A
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 10
attribute: currentL3
name: Current L3
unit: A
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 12
attribute: powerL1
name: Power L1
unit: W
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 14
attribute: powerL2
name: Power L2
unit: W
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 16
attribute: powerL3
name: Power L3
unit: W
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x0048
attribute: totalImportEnergy
name: Total Import Energy
unit: kWh
register_type: input
data_type: float32
adaptor: floatAdaptor
- address: 0x004a
attribute: totalExportEnergy
name: Total Export Energy
unit: kWh
register_type: input
data_type: float32
adaptor: floatAdaptor
- name: car_feedback
enabled: false
publish_topic: IoT/Car/Feedback/State
scan_rate: 1
raw_output: true # use only for output device with only one register, name this register 'output'
slave_id: 7
registers:
- address: 0x0010
attribute: output
name: State
unit: "-"
register_type: holding
data_type: int32
adaptor: onOffAdaptor

View File

@@ -0,0 +1,33 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: pv-controller
labels:
app: pv-controller
annotations:
reloader.stakater.com/auto: "true"
reloader.stakater.com/configmap: "pv-controller-config"
spec:
replicas: 1
selector:
matchLabels:
app: pv-controller
template:
metadata:
labels:
app: pv-controller
spec:
containers:
- name: pv-controller
image: %IMAGE%
env:
- name: CFG_FILE
value: /config/config.yaml
volumeMounts:
- name: pv-controller-config
mountPath: /config/config.yaml
subPath: config.yaml
volumes:
- name: pv-controller-config
configMap:
name: pv-controller-config

View File

@@ -1,41 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: pv-controller
---
apiVersion: v1
kind: ConfigMap
metadata:
name: pv-controller
namespace: pv-controller
data:
MQTT__BROKER: "emqx01-anonymous-cluster-internal.broker.svc.cluster.local"
MQTT__PORT: "1883"
MQTT__METERPUBLISHTOPIC: "IoT/PV/Values"
MQTT__METERPUBLISHPERIOD: "15"
MQTT__RELAISSUBSCRIBETOPIC: "IoT/PV/Cmd"
MODBUS__GATEWAY: "172.16.2.42"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: pv-controller
namespace: pv-controller
labels:
app: pv-controller
spec:
replicas: 1
selector:
matchLabels:
app: pv-controller
template:
metadata:
labels:
app: pv-controller
spec:
containers:
- name: pv-controller
image: wollud1969/pv-controller:1.0.1
envFrom:
- configMapRef:
name: pv-controller

View File

@@ -1,67 +0,0 @@
with
first_day_in_year as (
select
date_trunc('day', min(time)) as day
from pv_power_measurement_t
where
time between date_trunc('year', time) and now()
),
first_value_in_year as (
select
time_bucket('1 day', time) as interval,
first(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between (select day from first_day_in_year) and (select day from first_day_in_year) + interval '1 day' and
status = 'Ok'
group by interval
),
first_day_in_month as (
select
date_trunc('day', min(time)) as day
from pv_power_measurement_t
where
time between date_trunc('month', now()) and now()
),
first_value_in_month as (
select
time_bucket('1 day', time) as interval,
first(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between (select day from first_day_in_month) and (select day from first_day_in_month) + interval '1 day' and
status = 'Ok'
group by interval
),
first_value_in_day as (
select
time_bucket('1 day', time) as interval,
first(exportenergyactive, time) as energy
from pv_power_measurement_t
where time >= date_trunc('day', now())
group by interval
),
last_value as (
select
time_bucket('1 day', time) as interval,
last(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between date_trunc('day', now()) and date_trunc('day', now()) + interval '1 day' and
status = 'Ok'
group by interval
)
select
extract(year from (select day from first_day_in_year))::text as period_value,
'Year' as period_name,
round(((select energy from last_value) - (select energy from first_value_in_year))::numeric, 2) as yield
union
select
to_char((select day from first_day_in_month), 'Month') as period_value,
'Month' as period_name,
round(((select energy from last_value) - (select energy from first_value_in_month))::numeric, 2) as yield
union
select
now()::date::text as period_value,
'Day' as period_name,
round(((select energy from last_value) - (select energy from first_value_in_day))::numeric, 2) as yield;

View File

@@ -1,88 +0,0 @@
create table pv_power_measurement_t (
time timestamp without time zone not null,
deviceid text,
status text,
state integer,
importEnergyActive double precision,
importEnergyReactive double precision,
exportEnergyActive double precision,
exportEnergyReactive double precision,
powerApparent double precision,
powerActive double precision,
powerReactive double precision,
powerDemandPositive double precision,
powerDemandReverse double precision,
powerDemand double precision,
factor double precision,
angle double precision,
voltage double precision,
current double precision
);
select create_hypertable('pv_power_measurement_t', 'time');
grant insert on pv_power_measurement_t to nodered;
grant select on pv_power_measurement_t to grafana;
create view pv_stats_v as
select time, importEnergyActive, importEnergyReactive, exportEnergyActive, exportEnergyReactive,
powerApparent, powerActive, powerReactive, powerDemandPositive, powerDemandReverse, powerDemand,
factor, angle, voltage, current
from pv_power_measurement_t
order by time;
create table pv_stats_t (
id serial not null primary key,
"date" date not null,
dateType varchar(5) not null,
first numeric(10,2) not null default 0,
total numeric(10,2) not null default 0
);
alter table pv_stats_t add constraint ddT_uk unique ("date", dateType);
grant insert, select, update on pv_stats_t to nodered;
grant select, update on pv_stats_t_id_seq to nodered;
create or replace function pv_stats_func ()
returns trigger
language plpgsql
as $$
declare
v_stat_id pv_stats_t.id%TYPE;
v_dateTypes varchar[] := array['day', 'month', 'year'];
v_dateType varchar;
begin
foreach v_dateType in array v_dateTypes
loop
select id
from pv_stats_t
into v_stat_id
where "date" = date_trunc(v_dateType, NEW.time::date) and
dateType = v_dateType;
if not found then
insert into pv_stats_t ("date", dateType, first)
values (date_trunc(v_dateType, NEW.time::date), v_dateType, NEW.exportEnergyActive);
else
update pv_stats_t
set total = NEW.exportEnergyActive - first
where id = v_stat_id;
end if;
end loop;
return NEW;
end;
$$
create trigger pv_stats_trig
after insert on pv_power_measurement_t
for each row
execute function pv_stats_func();
insert into pv_stats_t("date", dateType, first, total) values (date_trunc('month', now()), 'month', 0.01, 0)
on conflict on constraint ddT_uk do update set total = 3.26 - excluded.first;
;

View File

@@ -1,160 +0,0 @@
-- current year's gain
with
first_day_in_year as (
select
date_trunc('day', min(time)) as day
from pv_power_measurement_t
where
time between date_trunc('year', time) and now()
),
first_value as (
select
time_bucket('1 day', time) as interval,
first(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between (select day from first_day_in_year) and (select day from first_day_in_year) + interval '1 day' and
status = 'Ok'
group by interval
),
last_value as (
select
time_bucket('1 day', time) as interval,
last(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between date_trunc('day', now()) and date_trunc('day', now()) + interval '1 day' and
status = 'Ok'
group by interval
)
select
extract(year from (select day from first_day_in_year))::text as period_value,
'Year' as period_name,
(select energy from last_value) - (select energy from first_value) as yield;
-- current month's gain
with
first_day_in_month as (
select
date_trunc('day', min(time)) as day
from pv_power_measurement_t
where
time between date_trunc('month', now()) and now()
),
first_value as (
select
time_bucket('1 day', time) as interval,
first(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between (select day from first_day_in_month) and (select day from first_day_in_month) + interval '1 day' and
status = 'Ok'
group by interval
),
last_value as (
select
time_bucket('1 day', time) as interval,
last(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between date_trunc('day', now()) and date_trunc('day', now()) + interval '1 day' and
status = 'Ok'
group by interval
)
select
(select day from first_day_in_month) as v1,
(select energy from first_value) as v2,
(select energy from last_value) as v3,
to_char((select day from first_day_in_month), 'Month') as period_value,
'Month' as period_name,
(select energy from last_value) - (select energy from first_value) as yield;
-- current day's gain
with
values as (
select
time_bucket('1 day', time) as interval,
first(exportenergyactive, time) as first_value,
last(exportenergyactive, time) as last_value
from pv_power_measurement_t
where time >= date_trunc('day', now())
group by interval
)
select
(select interval from values)::date::text as period_value,
'Day' as period_name,
(select last_value from values) - (select first_value from values) as yield;
-- all in one
with
first_day_in_year as (
select
date_trunc('day', min(time)) as day
from pv_power_measurement_t
where
time between date_trunc('year', time) and now()
),
first_value_in_year as (
select
time_bucket('1 day', time) as interval,
first(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between (select day from first_day_in_year) and (select day from first_day_in_year) + interval '1 day' and
status = 'Ok'
group by interval
),
first_day_in_month as (
select
date_trunc('day', min(time)) as day
from pv_power_measurement_t
where
time between date_trunc('month', time) and now()
),
first_value_in_month as (
select
time_bucket('1 day', time) as interval,
first(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between (select day from first_day_in_month) and (select day from first_day_in_month) + interval '1 day' and
status = 'Ok'
group by interval
),
first_value_in_day as (
select
time_bucket('1 day', time) as interval,
first(exportenergyactive, time) as energy
from pv_power_measurement_t
where time >= date_trunc('day', now())
group by interval
),
last_value as (
select
time_bucket('1 day', time) as interval,
last(exportenergyactive, time) as energy
from pv_power_measurement_t
where
time between date_trunc('day', now()) and date_trunc('day', now()) + interval '1 day' and
status = 'Ok'
group by interval
)
select
extract(year from (select day from first_day_in_year))::text as period_value,
'Year' as period_name,
round(((select energy from last_value) - (select energy from first_value_in_year))::numeric, 2) as yield
union
select
to_char((select day from first_day_in_month), 'Month') as period_value,
'Month' as period_name,
round(((select energy from last_value) - (select energy from first_value_in_month))::numeric, 2) as yield
union
select
now()::date::text as period_value,
'Day' as period_name,
round(((select energy from last_value) - (select energy from first_value_in_day))::numeric, 2) as yield;

View File

@@ -1,20 +0,0 @@
select time_bucket('1 day', time) as interval,
round((last(exportenergyactive, time) - first(exportenergyactive, time))::numeric, 2) as energy
from pv_power_measurement_t
where time between date_trunc('month', now()) and date_trunc('month', now()) + interval '1 month'
group by interval
order by interval;
-- daily stats of current month
select time_bucket('1 day', time) as interval,
round((last(exportenergyactive, time) - first(exportenergyactive, time))::numeric, 2) as energy
from pv_power_measurement_t
where time between date_trunc('month', now()) and date_trunc('month', now()) + interval '1 month'
group by interval
order by interval;

View File

@@ -0,0 +1,61 @@
from threading import Event
from loguru import logger
from MqttBase import AbstractMqttPublisher
import json
import datetime
def floatAdaptor(i):
return float(f"{i:0.2f}") if i else 0.0
def onOffAdaptor(i):
return 'on' if bool(i) else 'off'
class FromDevices(AbstractMqttPublisher):
def __init__(self, config, modbusHandler):
super().__init__(config)
self.modbusHandler = modbusHandler
def localLoop(self):
cnt = 0
while not self.killBill:
cnt += 1
for device in self.config.output:
try:
payload = {}
payload['status'] = "Error"
payload['timestamp'] = datetime.datetime.isoformat(datetime.datetime.utcnow())
logger.debug(f"{device.name=} {device.publish_topic=}")
if not device.enabled:
logger.debug(f" device disabled, skipping")
continue
if cnt % device.scan_rate != 0:
logger.debug(f" not scan_rate yet, skipping")
continue
for registers in device.registers:
logger.debug(f" {registers.name=} {registers.address=} {registers.register_type=}")
rawValue = self.modbusHandler.readRegister(registers.register_type, device.slave_id, registers.address, registers.data_type)
logger.debug(f" {rawValue=}")
if registers.adaptor == "floatAdaptor":
value = floatAdaptor(rawValue)
elif registers.adaptor == "onOffAdaptor":
value = onOffAdaptor(rawValue)
else:
value = rawValue
logger.debug(f" {value=}")
payload[registers.attribute] = value
payload['status'] = "Ok"
payload['cnt'] = cnt
payloadStr = json.dumps(payload) if not device.raw_output else str(payload['output'])
self.client.publish(device.publish_topic, payloadStr)
logger.debug(f"mqtt message sent: {device.publish_topic} -> {payloadStr}")
except Exception as e:
logger.error(f"Caught exception: {str(e)}")
self.killEvent.wait(timeout=float(self.config.global_.scan_interval))

View File

@@ -1,63 +0,0 @@
from threading import Event
from loguru import logger
from MqttBase import AbstractMqttPublisher
import json
import datetime
def floatAdaptor(i):
return float(f"{i:0.2f}") if i else 0.0
def onOffAdaptor(i):
return i[0] if i else '-1'
REGISTERS = [
{ "slave":2, "addr":0x0048, "type":"input", "attr": "importEnergyActive", "name":"Import active energy", "unit":"kWh", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x004c, "type":"input", "attr": "importEnergyReactive", "name":"Import reactive energy", "unit":"kVAh", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x004a, "type":"input", "attr": "exportEnergyActive", "name":"Export active energy", "unit":"kWh", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x004e, "type":"input", "attr": "exportEnergyReactive", "name":"Export reactive energy", "unit":"kVAh", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x0012, "type":"input", "attr": "powerApparent", "name":"Apparent Power", "unit":"W", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x000c, "type":"input", "attr": "powerActive", "name":"Active Power", "unit":"W", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x0018, "type":"input", "attr": "powerReactive", "name":"Reactive Power", "unit":"W", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x0058, "type":"input", "attr": "powerDemandPositive", "name":"PositivePowerDemand", "unit":"W", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x005c, "type":"input", "attr": "powerDemandReverse", "name":"ReversePowerDemand", "unit":"W", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x001e, "type":"input", "attr": "factor", "name":"Factor", "unit":"-", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x0024, "type":"input", "attr": "angle", "name":"Angle", "unit":"degree", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x0000, "type":"input", "attr": "voltage", "name":"Voltage", "unit":"V", "adaptor": floatAdaptor },
{ "slave":2, "addr":0x0006, "type":"input", "attr": "current", "name":"Current", "unit":"A", "adaptor": floatAdaptor },
{ "slave":1, "addr":0x0001, "type":"holding", "attr": "state", "name":"State", "unit":"-", "adaptor": onOffAdaptor },
]
class MeterPublish(AbstractMqttPublisher):
def __init__(self, config, modbusHandler):
super().__init__(config)
self.modbusHandler = modbusHandler
self.registers = REGISTERS
def localLoop(self):
cnt = 0
while not self.killBill:
cnt += 1
topic = self.config["meterPublishTopic"]
payload = str(cnt)
try:
payload = { r['attr']: r['adaptor'](None) for r in self.registers }
payload['status'] = "Error"
payload['timestamp'] = datetime.datetime.isoformat(datetime.datetime.utcnow())
for reg in self.registers:
v = self.modbusHandler.readRegister(reg['type'], reg['slave'], reg['addr'])
logger.debug(f"{reg['name']}: {v} {reg['unit']}")
payload[reg['attr']] = reg['adaptor'](v)
payload['status'] = "Ok"
except Exception as e:
logger.error(f"Caught exception: {str(e)}")
payload['cnt'] = cnt
payloadStr = json.dumps(payload)
self.client.publish(topic, payloadStr)
logger.info(f"mqtt message sent: {topic} -> {payloadStr}")
self.killEvent.wait(timeout=float(self.config["meterPublishPeriod"]))

View File

@@ -1,8 +1,10 @@
from enum import IntEnum
import pymodbus
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusIOException
from pymodbus.register_read_message import ReadHoldingRegistersResponse, ReadInputRegistersResponse
from pymodbus.payload import BinaryPayloadBuilder, BinaryPayloadDecoder
from pymodbus.constants import Endian
# from pymodbus.register_read_message import ReadHoldingRegistersResponse, ReadInputRegistersResponse
# from pymodbus.payload import BinaryPayloadBuilder, BinaryPayloadDecoder
# from pymodbus.constants import Endian
from loguru import logger
import sys
@@ -19,51 +21,54 @@ class LocalModbusException(Exception):
def __str__(self):
return f"LocalModbusException: Msg:{self.msg}, Cause:{self.cause}"
READ_REGISTER_FUNCTIONS = {
'coils': ModbusTcpClient.read_coils,
'discrete': ModbusTcpClient.read_discrete_inputs,
'holding': ModbusTcpClient.read_holding_registers,
'input': ModbusTcpClient.read_input_registers
}
DATA_TYPES = {
'int16': ModbusTcpClient.DATATYPE.INT16,
'uint16': ModbusTcpClient.DATATYPE.UINT16,
'int32': ModbusTcpClient.DATATYPE.INT32,
'uint32': ModbusTcpClient.DATATYPE.UINT32,
'float32': ModbusTcpClient.DATATYPE.FLOAT32,
'float64': ModbusTcpClient.DATATYPE.FLOAT64
}
class ModbusHandler:
def __init__(self, config):
self.config = config['modbus']
self.client = ModbusTcpClient(self.config['gateway'])
self.config = config.modbus
self.client = ModbusTcpClient(self.config.gateway)
self.client.connect()
def readRegister(self, typ, slave, addr):
if typ == 'input':
return self.readInputRegister(slave, addr)
elif typ == 'holding':
return self.readHoldingRegister(slave, addr)
else:
raise LocalModbusException('unsupported read type')
def readRegister(self, typ, slave, addr, data_type):
try:
readFunc = READ_REGISTER_FUNCTIONS[typ]
dataType = DATA_TYPES[data_type]
count = dataType.value[1]
def readInputRegister(self, slave, addr):
res = self.client.read_input_registers(addr, 2, slave=slave)
if (isinstance(res, ReadInputRegistersResponse)):
v = BinaryPayloadDecoder.fromRegisters(res.registers, byteorder=Endian.Big, wordorder=Endian.Big).decode_32bit_float()
return v
elif (isinstance(res, LocalModbusException)):
msg = f"Error: {type(res)}, Content: {res}"
logger.warning(msg)
raise LocalModbusException(msg=msg, cause=res)
else:
msg = f"Unknown type: {type(res)}, Content: {res}"
logger.warning(msg)
raise LocalModbusException(msg=msg)
logger.debug(f"{addr=}, {count=}, {slave=}")
res = readFunc(self.client, addr, count=count, device_id=slave)
if (isinstance(res, pymodbus.pdu.register_message.ReadHoldingRegistersResponse) or
isinstance(res, pymodbus.pdu.register_message.ReadInputRegistersResponse) or
isinstance(res, pymodbus.pdu.bits_message.ReadCoilsResponse) or
isinstance(res, pymodbus.pdu.bits_message.ReadDiscreteInputsResponse)):
v = self.client.convert_from_registers(res.registers, data_type=dataType)
return v
else:
raise LocalModbusException(f"Read register failed: slave={slave}, addr={addr}, type={typ}, data_type={data_type}, response={res}")
except Exception as e:
raise LocalModbusException(f"Exception during read register: slave={slave}, addr={addr}, type={typ}, data_type={data_type}", cause=e)
def readHoldingRegister(self, slave, addr):
res = self.client.read_holding_registers(addr, 2, slave=slave)
if (isinstance(res, ReadHoldingRegistersResponse)):
v = res.registers
return v
elif (isinstance(res, LocalModbusException)):
msg = f"Error: {type(res)}, Content: {res}"
logger.warning(msg)
raise LocalModbusException(msg=msg, cause=res)
else:
msg = f"Unknown type: {type(res)}, Content: {res}"
logger.warning(msg)
raise LocalModbusException(msg=msg)
def writeCoil(self, slave, addr, value):
res = self.client.write_coil(addr, value, slave=slave)
res = self.client.write_coil(addr, value, device_id=slave)
logger.debug(f"write coil result {res}")
return value

View File

@@ -1,7 +1,7 @@
import paho.mqtt.client as mqtt
from loguru import logger
import uuid
import threading
import ssl
@@ -19,9 +19,11 @@ class AbstractMqttPublisher(threading.Thread):
def __init__(self, config):
super().__init__()
self.config = config["mqtt"]
self.config = config
self.client = mqtt.Client(userdata=self)
client_id = f"pv-controller-{uuid.uuid4()}"
logger.info(f"mqtt client id: {client_id}")
self.client = mqtt.Client(client_id=client_id, userdata=self)
# consider this flag in the localLoop
self.killBill = False
@@ -32,20 +34,7 @@ class AbstractMqttPublisher(threading.Thread):
self.client.on_connect = mqttOnConnectCallback
self.client.on_disconnect = mqttOnDisconnectCallback
if ("login" in self.config) and ("password" in self.config):
self.client.username_pw_set(self.config["login"], self.config["password"])
if ("ca" in self.config) and ("cert" in self.config) and ("key" in self.config):
self.client.tls_set(
ca_certs=self.config["ca"],
certfile=self.config["cert"],
keyfile=self.config["key"],
cert_reqs=ssl.CERT_REQUIRED,
tls_version=ssl.PROTOCOL_TLSv1_2,
ciphers=None # this does not mean "no cipher" but it means "default ciphers"
)
self.client.connect(self.config["broker"], int(self.config["port"]))
self.client.connect(self.config.mqtt.broker, int(self.config.mqtt.port))
self.client.loop_start()
logger.info("mqtt loop started")

View File

@@ -1,29 +0,0 @@
from MqttBase import AbstractMqttPublisher
from loguru import logger
from time import sleep
class RelaisSubscribe(AbstractMqttPublisher):
def __init__(self, config, modbusHandler):
super().__init__(config)
self.modbusHandler = modbusHandler
def localLoop(self):
while not self.killBill:
sleep(60.0)
def onMessage(self, topic, payload):
logger.info("mqtt message received: {} -> {}".format(topic, str(payload)))
if payload == b'On':
self.modbusHandler.writeCoil(1, 0, 1)
elif payload == b'Off':
self.modbusHandler.writeCoil(1, 0, 0)
else:
logger.warning(f"Illegal command {payload} received")
def onConnect(self):
logger.info("mqtt connected")
self.client.subscribe("{}".format(self.config["relaisSubscribeTopic"]))
logger.info("subscribed")

View File

@@ -0,0 +1,38 @@
from MqttBase import AbstractMqttPublisher
from loguru import logger
from time import sleep
class ToDevices(AbstractMqttPublisher):
def __init__(self, config, modbusHandler):
super().__init__(config)
self.modbusHandler = modbusHandler
def localLoop(self):
while not self.killBill:
sleep(60.0)
def onMessage(self, topic, payload):
try:
logger.debug("mqtt message received: {} -> {}".format(topic, str(payload)))
for device in self.config.input:
if topic != device.subscribe_topic:
continue
logger.debug(f"{topic=} matches {device.subscribe_topic=}, processing")
if not device.enabled:
logger.debug(f" device disabled, skipping")
continue
if device.register_type != 'coil':
raise Exception(f"Unsupported register type {device.register_type} for input device {device.name}")
value = payload == b'on'
self.modbusHandler.writeCoil(device.slave_id, device.address, value)
except Exception as e:
logger.error(f"Caught exception in onMessage: {str(e)}")
def onConnect(self):
logger.info("mqtt connected")
for device in self.config.input:
self.client.subscribe(device.subscribe_topic)
logger.info(f"subscribed to topic: {device.subscribe_topic}")
logger.info("subscribed")

View File

@@ -1,31 +1,95 @@
import os
import re
from pathlib import Path
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
import yaml
from loguru import logger
class Config:
OPTIONS = {
'mqtt': [ 'login',
'password',
'ca',
'cert',
'key',
'broker',
'port',
'meterPublishTopic',
'meterPublishPeriod',
'relaisSubscribeTopic' ],
'modbus': [ 'gateway' ]
}
class RegisterConfig(BaseModel):
"""Modbus Register Configuration"""
address: int
attribute: str
name: str
unit: str
register_type: str
data_type: str
adaptor: str
class OutputConfig(BaseModel):
"""Output Configuration for Modbus Devices"""
name: str
enabled: bool = Field(default=True)
scan_rate: Optional[int] = Field(default=60)
publish_topic: str
raw_output: Optional[bool] = Field(default=False)
slave_id: int
registers: List[RegisterConfig]
class InputConfig(BaseModel):
"""Input Configuration for Modbus Devices (MQTT -> Modbus)"""
name: str
enabled: bool = Field(default=True)
subscribe_topic: str
slave_id: int
address: int
register_type: str
class MqttConfig(BaseModel):
"""MQTT Configuration"""
broker: str
port: int
class ModbusConfig(BaseModel):
"""Modbus Configuration"""
gateway: str
class GlobalConfig(BaseModel):
"""Global settings"""
scan_interval: int
log_level: str
class Config(BaseModel):
"""Main Configuration"""
global_: GlobalConfig = Field(alias="global")
mqtt: MqttConfig
modbus: ModbusConfig
input: List[InputConfig]
output: List[OutputConfig]
@classmethod
def load_from_file(cls, config_path: Optional[str] = None) -> 'Config':
"""
Load configuration from YAML file with environment variable substitution.
Args:
config_path: Path to config file. If None, uses CFG_FILE environment variable.
Returns:
Config instance
"""
if config_path is None:
config_path = os.getenv('CFG_FILE')
if config_path is None:
raise ValueError("Config path not provided and CFG_FILE environment variable not set")
config_file = Path(config_path)
if not config_file.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
# Read YAML file
with open(config_file, 'r', encoding='utf-8') as f:
yaml_content = f.read()
# Parse YAML
config_dict = yaml.safe_load(yaml_content)
logger.info(f"Configuration loaded from: {config_path}")
return cls(**config_dict)
def __init__(self):
self.values = {}
for section, keys in Config.OPTIONS.items():
self.values[section] = {}
for key in keys:
varname = f"{section}__{key}".upper()
try:
self.values[section][key] = os.environ[varname]
except KeyError:
pass
def __getitem__(self, section):
return self.values[index]

View File

@@ -1,15 +1,13 @@
from MeterPublish import MeterPublish
from RelaisSubscribe import RelaisSubscribe
from FromDevices import FromDevices
from ToDevices import ToDevices
from ModbusBase import ModbusHandler
from loguru import logger
from config import Config
import logging
import threading
import sys
l = logging.getLogger()
for h in l.handlers:
l.removeHandler(h)
deathBell = threading.Event()
@@ -22,17 +20,22 @@ def exceptHook(args):
logger.info("pv controller starting")
config = Config()
config = Config.load_from_file()
# configure loguru: only log INFO and above
logger.remove()
logger.add(sys.stdout, level=config.global_.log_level)
modbusHandler = ModbusHandler(config)
relaisSubscribeThread = RelaisSubscribe(config, modbusHandler)
relaisSubscribeThread.start()
logger.info("relaisSubscribe started")
toDevicesThread = ToDevices(config, modbusHandler)
toDevicesThread.start()
logger.info("toDevices started")
meterPublishThread = MeterPublish(config, modbusHandler)
meterPublishThread.start()
logger.info("meterPublishThread started")
fromDevicesThread = FromDevices(config, modbusHandler)
fromDevicesThread.start()
logger.info("fromDevices started")
threading.excepthook = exceptHook
logger.info("Threading excepthook set")
@@ -43,13 +46,12 @@ logger.info("pv controller is running")
deathBell.wait()
logger.error("pv controller is dying")
relaisSubscribeThread.stop()
meterPublishThread.stop()
toDevicesThread.stop()
fromDevicesThread.stop()
relaisSubscribeThread.join()
logger.error("relaisSubscribe joined")
meterPublishThread.join()
logger.error("meterPublishThread joined")
toDevicesThread.join()
logger.error("toDevices joined")
fromDevicesThread.join()
logger.error("fromDevices joined")
logger.error("pv controller is terminated")

View File

@@ -0,0 +1,12 @@
# Configuration and validation
pydantic>=2.0.0
pyyaml>=6.0
# Logging
loguru>=0.7.0
# MQTT client
paho-mqtt>=1.6.0
# Modbus communication
pymodbus>=3.0.0