Skip to content

Commit

Permalink
Merge branch 'feature/WebApp' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
wene37 committed May 29, 2023
2 parents ed5fc72 + b442ec1 commit 6c56588
Show file tree
Hide file tree
Showing 35 changed files with 11,473 additions and 48 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ src/logs/*
dist/
build/
src/WeConnect_SolarManager.egg-info/
src/pushNotifications.json
*.user
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": true,
"cwd": "${fileDirname}"
}
]
}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ In the config.txt file you find different entries you can or have to change.
||MinBatteryLoad|Minium battery load you want to have. If battery load goes below this, SolarManager will stop charging your car.|
||SimulationMode|You can enable simulation mode to only log everything without really start or stop charging your car.|
||VehicleNameSuffix|The suffix you need to add to your car's nickname (see below).|
|WebApp|Port|Port for the web app.|
||WebPushSubject|VAPID setting for push notifications.|
||WebPushPublicKey|VAPID setting for push notifications.|
||WebPushPrivateKey|VAPID setting for push notifications.|

## Enable/Disable SolarManager
As SolarManager can't know if you want to load your car with solar power only or just load it because you need a full battery, there's a switch you can use right from your WeConnect ID App. If you want SolarManager to be active for your car, please extend your car's nickname in the app with the suffix `(SMC)` (= SolarManager Control). If you want to disable SolarManager for your car, just remove the suffix again.
Expand Down
5 changes: 4 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
requests >= 2.27.1
weconnect[Images] >= 0.54.2
weconnect[Images] >= 0.55.0
flask >= 2.3.1
cryptography == 3.3.2
pywebpush >= 1.14.0
16 changes: 10 additions & 6 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
# Always prefer setuptools over distutils
from setuptools import setup, find_packages
from setuptools.command.install import install
import pathlib

here = pathlib.Path(__file__).parent.resolve()
long_description = (here / "README.md").read_text(encoding="utf-8")

setup(
name="WeConnect-SolarManager",
version="0.2.2",
version="0.3.0rc4",
description="With WeConnect-SolarManager you can automatically charge your Volkswagen ID car (e.g. ID.4) with solar electricity, even if your wallbox does not support this.",
long_description=long_description,
long_description_content_type="text/markdown",
Expand All @@ -20,13 +18,19 @@
package_dir={"": "src"},
packages=find_packages(where="src"),
data_files=[
('SolarManager', ['src/init.py', 'src/main.py', 'src/config.txt']),
('SolarManager/logs', ['src/logs/.gitkeep'])
('SolarManager', ['src/init.py', 'src/main.py', 'src/app.py', 'src/Helper.py', 'src/config.txt']),
('SolarManager/logs', ['src/logs/.gitkeep']),
('SolarManager/static', ['src/static/axios.min.js', 'src/static/default.css', 'src/static/jquery-3.6.4.js', 'src/static/jquery-3.6.4.min.js', 'src/static/manifest.json', 'src/static/pushNotification.js', 'src/static/serviceWorker.js']),
('SolarManager/static/images/favicon', ['src/static/images/favicon/icon-16.png', 'src/static/images/favicon/icon-32.png', 'src/static/images/favicon/icon-57.png', 'src/static/images/favicon/icon-60.png', 'src/static/images/favicon/icon-64.png', 'src/static/images/favicon/icon-72.png', 'src/static/images/favicon/icon-76.png', 'src/static/images/favicon/icon-96.png', 'src/static/images/favicon/icon-114.png', 'src/static/images/favicon/icon-120.png', 'src/static/images/favicon/icon-144.png', 'src/static/images/favicon/icon-152.png', 'src/static/images/favicon/icon-180.png', 'src/static/images/favicon/icon-192.png', 'src/static/images/favicon/icon-512.png']),
('SolarManager/templates', ['src/templates/index.html'])
],
python_requires=">=3.7, <4",
install_requires=[
'requests >= 2.27.1',
'weconnect[Images] >= 0.54.2'
'weconnect[Images] >= 0.55.0',
'flask >= 2.3.1',
'cryptography==3.3.2',
'pywebpush >= 1.14.0'
],
project_urls={
"Bug Reports": "https://github.com/wene37/WeConnect-SolarManager/issues",
Expand Down
80 changes: 80 additions & 0 deletions src/Helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import json
import os
import configparser
import logging
import logging.handlers

from pathlib import Path
from pywebpush import webpush
from datetime import datetime

class Helper:

@staticmethod
def getLogEntries():
filePath = Path(os.path.join(os.path.dirname(__file__), "logs/SolarManager.log"))

if filePath.is_file():
with open(filePath) as file:
return [line.rstrip() for line in file]

return []

@staticmethod
def loadConfig() -> configparser:
configFileName = "config.txt"
userConfigFileName = "config.txt.user"
userConfigFile = Path(os.path.join(os.path.dirname(__file__), userConfigFileName))

if userConfigFile.is_file():
configFileName = userConfigFileName

configFilePath = os.path.join(os.path.dirname(__file__), configFileName)
configParser = configparser.ConfigParser()
configParser.read(configFilePath)

return configParser

@staticmethod
def loadPushNotifications() -> json:
fileName = "pushNotifications.json"
filePath = Path(os.path.join(os.path.dirname(__file__), fileName))

if filePath.is_file():
with open(filePath) as json_file:
return json.load(json_file)

return json.loads('{"devices":[]}')

@staticmethod
def savePushNotifications(jsonObj: json) -> None:
fileName = "pushNotifications.json"
filePath = Path(os.path.join(os.path.dirname(__file__), fileName))

with open(filePath, "w") as outfile:
json.dump(jsonObj, outfile)

@staticmethod
def sendPushNotification(title: str, message: str) -> None:
configParser = Helper.loadConfig()
pushNotifications = Helper.loadPushNotifications()

for device in pushNotifications["devices"]:

try:
subscription_information = {
"endpoint": device["endpoint"],
"keys": { "auth": device["auth"], "p256dh": device["p256dh"] }
}

data = json.dumps({"title": title, "message": message, "tag": "", "dateTime": datetime.utcnow()}, default=str)

webpush(
subscription_info=subscription_information,
data=data,
vapid_private_key=configParser.get("WebApp", "WebPushPrivateKey"),
vapid_claims={"sub": configParser.get("WebApp", "WebPushSubject")}
)
except Exception as e:
LOG = logging.getLogger("SolarManager.Helper")
LOG.error("An error occured while sending push notification: " + str(e))
9 changes: 4 additions & 5 deletions src/SolarEdge/SolarEdge.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import logging
import requests
import configparser
import json
import urllib.parse

from Helper import Helper

class SolarEdge:
def __init__(
self,
configFileName: str
self
) -> None:

self.logger = logging.getLogger("SolarEdge")

configParser = configparser.ConfigParser()
configParser.read(configFileName)
configParser = Helper.loadConfig()

apiKey = configParser.get("SolarEdge", "ApiKey")
locationId = configParser.get("SolarEdge", "LocationId")
Expand Down
14 changes: 8 additions & 6 deletions src/SolarManager/SolarManager.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
import configparser
import json

from Helper import Helper
from SolarEdge import SolarEdge
from SolarManager.Elements.enums import ChargingState

Expand All @@ -16,14 +16,12 @@ class SolarManager:
def __init__(
self,
username: str,
password: str,
configFileName: str
password: str
) -> None:

self.logger = logging.getLogger("SolarManager")

configParser = configparser.ConfigParser()
configParser.read(configFileName)
configParser = Helper.loadConfig()

self.minBatteryLoadToStartCharging = configParser.getfloat("SolarManager", "MinBatteryLoadToStartCharging")
self.minPowerToGridToStartCharging = configParser.getfloat("SolarManager", "MinPowerToGridToStartCharging")
Expand All @@ -38,7 +36,7 @@ def __init__(

self.logger.info(f"Simulation mode: {self.simulationMode}")

self.solarEdge = SolarEdge.SolarEdge(configFileName)
self.solarEdge = SolarEdge.SolarEdge()

self.logger.info("Initialize WeConnect")
self.weConnect = weconnect.WeConnect(username=username, password=password, updateAfterLogin=False, loginOnInit=False)
Expand Down Expand Up @@ -171,11 +169,15 @@ def charging(self, vehicle: Vehicle, newState: ChargingState) -> None:

if newState == ChargingState.On:
self.logger.info("Start charging")
Helper.sendPushNotification("Info", "Start charging")

if not self.simulationMode:
vehicle.controls.chargingControl.value = ControlOperation.START

else:
self.logger.info("Stop charging")
Helper.sendPushNotification("Info", "Stop charging")

if not self.simulationMode:
vehicle.controls.chargingControl.value = ControlOperation.STOP

Expand Down
47 changes: 47 additions & 0 deletions src/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import json
import os

from flask import Flask, render_template, request

from Helper import Helper

configParser = Helper.loadConfig()
port = configParser.getint("WebApp", "Port")
publicKey = configParser.get("WebApp", "WebPushPublicKey")

templateDir = os.path.join(os.path.dirname(__file__), "templates")
staticDir = os.path.join(os.path.dirname(__file__), "static")
app = Flask("WeConnect-SolarManager", template_folder=templateDir, static_folder=staticDir)

def getLogs() -> str:
logEntries = Helper.getLogEntries()
return '<br/>'.join(logEntries)

@app.route('/api/pushnotification', methods=['POST'])
def pushNotification():

data = request.json
endpoint = data["endpoint"]
auth = data["auth"]
p256dh = data["p256dh"]

pushNotifications = Helper.loadPushNotifications()
alreadyDefined = False

for device in pushNotifications["devices"]:
if device["endpoint"] == endpoint and device["auth"] == auth and device["p256dh"] == p256dh:
alreadyDefined = True
break

if alreadyDefined == False:
pushNotifications["devices"].append({"endpoint": endpoint, "auth": auth, "p256dh": p256dh})
Helper.savePushNotifications(pushNotifications)

return json.dumps({'success':True}), 200, {'ContentType':'application/json'}

@app.route('/')
def index():
return render_template('index.html', PublicKey=publicKey, LogOutput=getLogs())

if __name__ == '__main__':
app.run(debug=False, ssl_context='adhoc', port=port, host='0.0.0.0')
6 changes: 6 additions & 0 deletions src/config.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ ApiKey=
[WeConnect]
Username=
Password=

[WebApp]
Port=5001
WebPushSubject=mailto: <support@witro.ch>
WebPushPublicKey=BIp6-DDjAqtOLvwyK3EOdNdj7RVFsIlWMqAGTjSTI5Wj9sCwmTirbc7QjbMrtzoJTeVRu4XtHBr6kyPHoGhmzf0
WebPushPrivateKey=luQ-M30MYdVhVEhzdNFFBPwhf5ZwQMed4Iw1svej_qg
43 changes: 26 additions & 17 deletions src/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,51 @@
import stat
from datetime import datetime

def initService(configFilePath: string, currentDirectoryPath: string):
print("Init service.")
def initService(serviceName: string, serviceStartFile: string, currentDirectoryPath: string):
print("Init '" + serviceName + "' service.")

print("Create a copy of current config file.")
os.system("cp -rf " + configFilePath + " " + configFilePath + "-" + datetime.today().strftime('%Y%m%d'))

serviceFilePath = "/lib/systemd/system/SolarManager.service"
serviceFilePath = "/lib/systemd/system/" + serviceName + ".service"

if os.path.exists(serviceFilePath):
print("Stopping current service.")
os.system("sudo systemctl stop SolarManager.service")
print("Stopping existing service.")
os.system("sudo systemctl stop " + serviceName + ".service")

print("Disabling current service.")
os.system("sudo systemctl disable SolarManager.service")
print("Disabling existing service.")
os.system("sudo systemctl disable " + serviceName + ".service")

print("Delete existing service file at '" + serviceFilePath + "'.")
os.remove(serviceFilePath)

serviceFileContent = "[Unit]\nDescription=SolarManager\nAfter=multi-user.target\nStartLimitIntervalSec=120s\nStartLimitBurst=50\n\n[Service]\nType=simple\nWorkingDirectory={{WORKING_DIRECTORY}}\nUser=pi\nExecStart=/usr/bin/python ./main.py\nRestart=on-failure\nRestartSec=120s\n\n[Install]\nWantedBy=multi-user.target"
serviceFileContent = serviceFileContent.replace("{{WORKING_DIRECTORY}}", currentDirectoryPath)
serviceFileContent = "[Unit]\nDescription=" + serviceName + "\nAfter=multi-user.target\nStartLimitIntervalSec=120s\nStartLimitBurst=50\n\n[Service]\nType=simple\nWorkingDirectory=" + currentDirectoryPath + "\nUser=pi\nExecStart=/usr/bin/python ./" + serviceStartFile + "\nRestart=on-failure\nRestartSec=120s\n\n[Install]\nWantedBy=multi-user.target"

with open(serviceFilePath, 'w') as f:
print("Writing new service file at '" + serviceFilePath + "'.")
f.write(serviceFileContent)

os.chmod(serviceFilePath, 644)

mainFilePath = currentDirectoryPath + "/main.py"
mainFilePath = currentDirectoryPath + "/" + serviceStartFile
st = os.stat(mainFilePath)
os.chmod(mainFilePath, st.st_mode | stat.S_IEXEC)

print("Register and start service.")
os.system("sudo systemctl daemon-reload")
os.system("sudo systemctl enable SolarManager.service")
os.system("sudo systemctl start SolarManager.service")
os.system("sudo systemctl enable " + serviceName + ".service")
os.system("sudo systemctl start " + serviceName + ".service")

print("Init of '" + serviceName + "' done.")

def initSolarManagerService(configFilePath: string, currentDirectoryPath: string):

print("Create a copy of current config file.")
os.system("cp -rf " + configFilePath + " " + configFilePath + "-" + datetime.today().strftime('%Y%m%d'))

initService("SolaraManager", "main.py", currentDirectoryPath)

def initWebAppService(currentDirectoryPath: string, port: int):

print("Init done. Please check the log files for errors.")
initService("SolaraManagerWebApp", "app.py", currentDirectoryPath)
print("Web app is running on this host on port " + str(port) + ".")

try:

Expand All @@ -70,7 +78,8 @@ def initService(configFilePath: string, currentDirectoryPath: string):
print("Please set all needed configurations in config file and run this script again.")
exit
else:
initService(str(configFilePath), str(here))
initSolarManagerService(str(configFilePath), str(here))
initWebAppService(str(here), configParser.getint("WebApp", "Port"))

except Exception as e:
raise e
Loading

0 comments on commit 6c56588

Please sign in to comment.