17 Commits
1.0.1 ... 1.1.0

Author SHA1 Message Date
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
52d690c382 fix 2023-11-08 14:48:19 +01:00
24 changed files with 607 additions and 617 deletions

View File

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

19
.woodpecker/build.yml Normal file
View File

@@ -0,0 +1,19 @@
when:
event: [tag]
ref:
exclude:
- refs/tags/*-configchange
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

22
.woodpecker/config.yml Normal file
View File

@@ -0,0 +1,22 @@
when:
event: [tag]
depends_on:
- namespace
steps:
apply_configuration:
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
environment:
KUBE_CONFIG_CONTENT:
from_secret: kube_config
NAMESPACE: "homea-ctrl-1"
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 -
- kubectl apply -f deployment/configmap.yaml -n $NAMESPACE

15
.woodpecker/namespace.yml Normal file
View File

@@ -0,0 +1,15 @@
when:
event: [tag]
steps:
create_namespace:
image: quay.io/wollud1969/k8s-admin-helper:0.3.4
environment:
KUBE_CONFIG_CONTENT:
from_secret: kube_config
NAMESPACE: "homea-ctrl-1"
commands:
- printf "$KUBE_CONFIG_CONTENT" > /tmp/kubeconfig
- export KUBECONFIG=/tmp/kubeconfig
- kubectl create namespace $NAMESPACE || echo "Namespace $NAMESPACE already exists"

View File

@@ -2,11 +2,17 @@ FROM python:latest
LABEL Maintainer="Wolfgang Hottgenroth wolfgang.hottgenroth@icloud.com" LABEL Maintainer="Wolfgang Hottgenroth wolfgang.hottgenroth@icloud.com"
LABEL ImageName="registry.hottis.de/dockerized/pv-controller" LABEL ImageName="registry.hottis.de/dockerized/pv-controller"
LABEL HubImageName="wollud1969/pv-controller"
ARG APP_DIR="/opt/app" ARG APP_DIR="/opt/app"
ARG CONF_DIR="${APP_DIR}/config"
ENV MQTT__BROKER ""
ENV MQTT__PORT "1883"
ENV MQTT__METERPUBLISHTOPIC "IoT/PV/Values"
ENV MQTT__METERPUBLISHPERIOD "15"
ENV MQTT__RELAISSUBSCRIBETOPIC "IoT/PV/Cmd"
ENV MODBUS__GATEWAY ""
RUN \ RUN \
apt update && \ apt update && \
@@ -16,18 +22,15 @@ RUN \
RUN \ RUN \
mkdir -p ${APP_DIR} && \ mkdir -p ${APP_DIR} && \
mkdir -p ${CONF_DIR} && \
useradd -d ${APP_DIR} -u 1000 user useradd -d ${APP_DIR} -u 1000 user
COPY ./src/pv_controller/*.py ${APP_DIR}/ COPY ./src/pv_controller/*.py ${APP_DIR}/
COPY config.ini ${CONF_DIR}/
USER 1000:1000 USER 1000:1000
WORKDIR ${APP_DIR} WORKDIR ${APP_DIR}
VOLUME ${CONF_DIR}
CMD [ "python", "pvc.py", "-f", "/opt/app/config/config.ini" ] 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

247
config/config.yaml Normal file
View File

@@ -0,0 +1,247 @@
global:
scan_interval: 1
log_level: INFO
mqtt:
broker: 172.16.2.16
port: 1883
modbus:
gateway: 172.16.2.42
# 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 },
# ]
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
slave_id: 1
registers:
- address: 0x0001
attribute: state
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
slave_id: 5
registers:
- address: 0x0001
attribute: state
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

View File

@@ -0,0 +1,23 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: pv-controller
namespace: homea
labels:
app: pv-controller
spec:
replicas: 1
selector:
matchLabels:
app: pv-controller
template:
metadata:
labels:
app: pv-controller
spec:
containers:
- name: pv-controller
image: %IMAGE
envFrom:
- configMapRef:
name: pv-controller

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.0
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,60 @@
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 bool(i)
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"
except Exception as e:
logger.error(f"Caught exception: {str(e)}")
payload['cnt'] = cnt
payloadStr = json.dumps(payload)
self.client.publish(device.publish_topic, payloadStr)
logger.debug(f"mqtt message sent: {device.publish_topic} -> {payloadStr}")
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.client import ModbusTcpClient
from pymodbus.exceptions import ModbusIOException from pymodbus.exceptions import ModbusIOException
from pymodbus.register_read_message import ReadHoldingRegistersResponse, ReadInputRegistersResponse # from pymodbus.register_read_message import ReadHoldingRegistersResponse, ReadInputRegistersResponse
from pymodbus.payload import BinaryPayloadBuilder, BinaryPayloadDecoder # from pymodbus.payload import BinaryPayloadBuilder, BinaryPayloadDecoder
from pymodbus.constants import Endian # from pymodbus.constants import Endian
from loguru import logger from loguru import logger
import sys import sys
@@ -19,51 +21,54 @@ class LocalModbusException(Exception):
def __str__(self): def __str__(self):
return f"LocalModbusException: Msg:{self.msg}, Cause:{self.cause}" 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: class ModbusHandler:
def __init__(self, config): def __init__(self, config):
self.config = config['modbus'] self.config = config.modbus
self.client = ModbusTcpClient(self.config['gateway']) self.client = ModbusTcpClient(self.config.gateway)
self.client.connect() self.client.connect()
def readRegister(self, typ, slave, addr): def readRegister(self, typ, slave, addr, data_type):
if typ == 'input': try:
return self.readInputRegister(slave, addr) readFunc = READ_REGISTER_FUNCTIONS[typ]
elif typ == 'holding': dataType = DATA_TYPES[data_type]
return self.readHoldingRegister(slave, addr) count = dataType.value[1]
else:
raise LocalModbusException('unsupported read type')
def readInputRegister(self, slave, addr): logger.debug(f"{addr=}, {count=}, {slave=}")
res = self.client.read_input_registers(addr, 2, slave=slave) res = readFunc(self.client, addr, count=count, device_id=slave)
if (isinstance(res, ReadInputRegistersResponse)): if (isinstance(res, pymodbus.pdu.register_message.ReadHoldingRegistersResponse) or
v = BinaryPayloadDecoder.fromRegisters(res.registers, byteorder=Endian.Big, wordorder=Endian.Big).decode_32bit_float() isinstance(res, pymodbus.pdu.register_message.ReadInputRegistersResponse) or
return v isinstance(res, pymodbus.pdu.bits_message.ReadCoilsResponse) or
elif (isinstance(res, LocalModbusException)): isinstance(res, pymodbus.pdu.bits_message.ReadDiscreteInputsResponse)):
msg = f"Error: {type(res)}, Content: {res}" v = self.client.convert_from_registers(res.registers, data_type=dataType)
logger.warning(msg) return v
raise LocalModbusException(msg=msg, cause=res) else:
else: raise LocalModbusException(f"Read register failed: slave={slave}, addr={addr}, type={typ}, data_type={data_type}, response={res}")
msg = f"Unknown type: {type(res)}, Content: {res}" except Exception as e:
logger.warning(msg) raise LocalModbusException(f"Exception during read register: slave={slave}, addr={addr}, type={typ}, data_type={data_type}", cause=e)
raise LocalModbusException(msg=msg)
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): 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}") logger.debug(f"write coil result {res}")
return value return value

View File

@@ -1,7 +1,7 @@
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from loguru import logger from loguru import logger
import uuid
import threading import threading
import ssl
@@ -19,9 +19,11 @@ class AbstractMqttPublisher(threading.Thread):
def __init__(self, config): def __init__(self, config):
super().__init__() 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 # consider this flag in the localLoop
self.killBill = False self.killBill = False
@@ -32,20 +34,7 @@ class AbstractMqttPublisher(threading.Thread):
self.client.on_connect = mqttOnConnectCallback self.client.on_connect = mqttOnConnectCallback
self.client.on_disconnect = mqttOnDisconnectCallback self.client.on_disconnect = mqttOnDisconnectCallback
if ("login" in self.config) and ("password" in self.config): self.client.connect(self.config.mqtt.broker, int(self.config.mqtt.port))
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.loop_start() self.client.loop_start()
logger.info("mqtt loop started") 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,94 @@
import os 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 = { class RegisterConfig(BaseModel):
'mqtt': [ 'login', """Modbus Register Configuration"""
'password', address: int
'ca', attribute: str
'cert', name: str
'key', unit: str
'broker', register_type: str
'port', data_type: str
'meterPublishTopic', adaptor: str
'meterPublishPeriod',
'relaisSubscribeTopic' ],
'modbus': [ 'gateway' ] 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
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:
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 FromDevices import FromDevices
from RelaisSubscribe import RelaisSubscribe from ToDevices import ToDevices
from ModbusBase import ModbusHandler from ModbusBase import ModbusHandler
from loguru import logger from loguru import logger
from config import Config from config import Config
import logging import logging
import threading import threading
import sys
l = logging.getLogger()
for h in l.handlers:
l.removeHandler(h)
deathBell = threading.Event() deathBell = threading.Event()
@@ -22,17 +20,22 @@ def exceptHook(args):
logger.info("pv controller starting") 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) modbusHandler = ModbusHandler(config)
relaisSubscribeThread = RelaisSubscribe(config, modbusHandler) toDevicesThread = ToDevices(config, modbusHandler)
relaisSubscribeThread.start() toDevicesThread.start()
logger.info("relaisSubscribe started") logger.info("toDevices started")
meterPublishThread = MeterPublish(config, modbusHandler) fromDevicesThread = FromDevices(config, modbusHandler)
meterPublishThread.start() fromDevicesThread.start()
logger.info("meterPublishThread started") logger.info("fromDevices started")
threading.excepthook = exceptHook threading.excepthook = exceptHook
logger.info("Threading excepthook set") logger.info("Threading excepthook set")
@@ -43,13 +46,12 @@ logger.info("pv controller is running")
deathBell.wait() deathBell.wait()
logger.error("pv controller is dying") logger.error("pv controller is dying")
relaisSubscribeThread.stop() toDevicesThread.stop()
meterPublishThread.stop() fromDevicesThread.stop()
relaisSubscribeThread.join() toDevicesThread.join()
logger.error("relaisSubscribe joined") logger.error("toDevices joined")
fromDevicesThread.join()
meterPublishThread.join() logger.error("fromDevices joined")
logger.error("meterPublishThread joined")
logger.error("pv controller is terminated") 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