Skip to content

Commit

Permalink
remove temp excel (#10)
Browse files Browse the repository at this point in the history
* remove temp excel

* Add test depencencies

* Remove reference to deleted config

* fix path to sql init files

* fix missing parameter in db test
  • Loading branch information
scottcha authored Jan 1, 2024
1 parent 7d62954 commit 95b6d87
Show file tree
Hide file tree
Showing 33 changed files with 963 additions and 832 deletions.
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
run: |
python -m pip install --upgrade pip
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
- name: Set up Docker Compose
run: |
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,5 @@ cython_debug/
*.pbix

#project specific ignores
em_cache.json
em_cache.json
*.csv
16 changes: 16 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
// 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
}
]
}
1,348 changes: 674 additions & 674 deletions LICENSE

Large diffs are not rendered by default.

54 changes: 52 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,52 @@
# kasa_carbon
Show CO2 emissions of devices connected to a Kasa energy meter.
[![PyPI version](https://badge.fury.io/py/kasa-carbon.svg)](https://badge.fury.io/py/kasa-carbon)

# kasa_carbon
Show CO2 emissions of devices connected to a Kasa energy meter. This project depends on and extends the python-kasa project which you can see here: https://python-kasa.readthedocs.io/en/stable/. It requires a supported Kasa brand plug.

# Quickstart instructions (file mode)
This utility is installed as a python module. Its recommended to install within a python virtual environment such as conda. For the example below I'll be using conda.

Currently the application supports getting carbon intensity data from Electricity Maps and you'll need an api key here https://www.electricitymaps.com/pricing. The plan for personal non-commercial use is what has been tested.

```bash
conda create -n kasa python=3.12
conda activate kasa
pip install kasa-carbon
kasa-carbon --storage=file --em_api_key=<your electricty maps api key> --em_cache_expiry_mins=30 --local_lat=<latitiude of the device under test> --local_lon=<longitude of the device under test>
```

You will see console output like the following:
```
The energy monitoring task is still running.
['TP-LINK_Power Strip_D581-Orange pi 1,2023-12-05 00:24:39.716662+00:00,1.387,3.0513999999999997e-06,528.0\n', 'TP-LINK_Power Strip_D581-Plug 1,2024-01-01 16:53:37.793121+00:00,3.11,6.2588749999999994e-06,483.0\n', 'TP-LINK_Power Strip_D581-Plug 2,2024-01-01 16:53:37.794128+00:00,0.0,0.0,483.0\n', 'TP-LINK_Power Strip_D581-Plug 3,2024-01-01 16:53:37.794128+00:00,8.009,1.61181125e-05,483.0\n', 'TP-LINK_Power Strip_D581-Plug 4,2024-01-01 16:53:37.794128+00:00,1.5,3.01875e-06,483.0\n', 'TP-LINK_Power Strip_D581-Plug 5,2024-01-01 16:53:37.794128+00:00,1.707,3.4353375e-06,483.0\n', 'TP-LINK_Power Strip_D581-Orange pi 1,2024-01-01 16:53:37.795126+00:00,1.624,3.2683e-06,483.0\n']
```

The monitoring task will run until killed while appending the readings to the output file.

# Database mode
There is also a more advanced database mode. You can deploy the required postgres databased to a docker container using the provided docker-compose.yml file in the source.
There are several parameters which are required for this which can be read from environment variables. Here are some recommended values.
```python
DB_HOST=host.docker.internal
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=<create an admin password>
DB_NAME=kasa_carbon
DB_VIEW_USER=energy_view_user
DB_VIEW_USER_PASSWORD=<create readonly a password>
```

With the database running in the docker container you can launch the application using the relevant database parameters like:
'kasa-carbon --db_host=host.docker.internal --db_port=5432 --db_user=postgres --db_password=<admin password> --db_name=kasa_carbon --db_view_user=energy_view_user --em_api_key=<your electricity maps api key> --em_cache_expiry_mins=30 --local_lat=39.633971 --local_lon=-105.329563

# Example output
Here is an example of using excel to graph the output obtained while running two instances of the Phoronix Apache Benchmark across both a Orange Pi and an Intel NUC. I've only plotted mgCO2e on this chart but watts is also an available reading. I live in a place with relatively low veraibility in our grid carbon intensity but had the co2/kwhr changed in the local grid during the course of this test it would have impacted the co2 data but not the power data.
![Example Graph](visualization/apachebenchmarkexample.jpg)
The full example table is also available at (visualization/energy_usage.xlsx)

# Development instructions
You can run the project from source as
'python -m kasa_carbon.kasa_carbon_main

more instructions coming . . .

22 changes: 0 additions & 22 deletions config.py

This file was deleted.

46 changes: 0 additions & 46 deletions kasa_carbon.py

This file was deleted.

File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from abc import ABC, abstractmethod
from modules.energy_usage import EnergyUsage
from kasa_carbon.modules.energy_usage import EnergyUsage

class DatastoreAPI(ABC):
@abstractmethod
async def write_usage(self, energy_usage: EnergyUsage) -> None:
pass

@abstractmethod
async def read_usage(self, columns="*"):
async def read_usage(self, last_n=10, columns="*"):
pass

@abstractmethod
Expand Down
83 changes: 83 additions & 0 deletions kasa_carbon/kasa_carbon_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python3

import asyncio
import argparse
import os
from kasa_carbon.modules import kasa_monitor, database, file_storage
from dotenv import load_dotenv, find_dotenv

async def main():
load_dotenv(find_dotenv())

parser = argparse.ArgumentParser(description='kasa-carbon arguments')
parser.add_argument('--storage', choices=['database', 'file'], default='database', help='Storage method')
parser.add_argument('--file-path', default='energy_usage.csv', help='File path for file storage')
parser.add_argument('--file-mode', choices=['append', 'overwrite'], default='append', help='File mode for file storage append will continuously append and overwrite will only keep the most recent value')
parser.add_argument("--db_host", default=os.getenv("DB_HOST"), help="Database host")
parser.add_argument("--db_port", default=os.getenv("DB_PORT"), type=int, help="Database port")
parser.add_argument("--db_user", default=os.getenv("DB_USER"), help="Database user")
parser.add_argument("--db_password", default=os.getenv("DB_PASSWORD"), help="Database password")
parser.add_argument("--db_name", default=os.getenv("DB_NAME"), help="Database name")
parser.add_argument("--db_view_user", default=os.getenv("DB_VIEW_USER"), help="Database view user")
parser.add_argument("--em_api_key", default=os.getenv("EM_API_KEY"), help="API key")
parser.add_argument("--em_cache_expiry_mins", default=os.getenv("EM_CACHE_EXPIRY_MINS"), type=int, help="Carbon Data cache expiry in minutes")
parser.add_argument("--local_lat", default=os.getenv("LOCAL_LAT"), type=float, help="Local latitude")
parser.add_argument("--local_lon", default=os.getenv("LOCAL_LON"), type=float, help="Local longitude")
parser.add_argument("--update_interval_sec", default=15, type=int, help="Update interval in seconds")


args = parser.parse_args()

# Set the values as global constants
DB_HOST = args.db_host
DB_PORT = args.db_port
DB_USER = args.db_user
DB_NAME = args.db_name
DB_VIEW_USER = args.db_view_user
EM_API_KEY = args.em_api_key
EM_CACHE_EXPIRY_MINS = args.em_cache_expiry_mins
LOCAL_LAT = args.local_lat
LOCAL_LON = args.local_lon

# Configuration for General API
API_KEY = EM_API_KEY
API_TYPE = "ElectricityMaps" # or "watttime"

# Update interval in seconds
UPDATE_INTERVAL_SEC = args.update_interval_sec

# database connection information
db_config = {
"user": args.db_user,
"password": args.db_password,
"database": args.db_name,
"host": args.db_host,
"port": args.db_port,
}
kasa = kasa_monitor.KasaMonitor(api_key = API_KEY, local_lon=LOCAL_LON, local_lat=LOCAL_LAT, co2_api_provider=API_TYPE, em_cache_expiry_mins=EM_CACHE_EXPIRY_MINS)

if args.storage == 'file':
storage = file_storage.FileStorage(args.file_path, args.file_mode)
else: #database is default
storage = database.Database(db_config)

# Monitor energy use continuously in a subthread to avoid blocking the main thread
energy_use_task = asyncio.create_task(kasa.monitor_energy_use_continuously(storage, delay=UPDATE_INTERVAL_SEC))

while True:
if energy_use_task.done():
print("The energy monitoring task has completed.")
else:
print("The energy monitoring task is still running.")

# view the current database values
print(await storage.read_usage())

# Wait for next update
await asyncio.sleep(UPDATE_INTERVAL_SEC)

def main_wrapper():
asyncio.run(main())

if __name__ == "__main__":
main_wrapper()
File renamed without changes.
12 changes: 6 additions & 6 deletions modules/database.py → kasa_carbon/modules/database.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import asyncio
import asyncpg
from modules.energy_usage import EnergyUsage
from interfaces.datastore_api import DatastoreAPI
from kasa_carbon.modules.energy_usage import EnergyUsage
from kasa_carbon.interfaces.datastore_api import DatastoreAPI

class Database(DatastoreAPI):
def __init__(self, db_config):
Expand All @@ -17,10 +17,10 @@ async def write_usage(self, energy_usage: EnergyUsage) -> None:
finally:
await self.close()

async def read_usage(self, columns="*"):
async def read_usage(self, last_n=10, columns="*"):
self.conn = await asyncpg.connect(**self.db_config)
try:
sql_query = self._generate_select_sql_query(columns)
sql_query = self._generate_select_sql_query(last_n, columns=columns)
result = await self.conn.fetch(sql_query)
finally:
await self.close()
Expand All @@ -37,9 +37,9 @@ def _generate_insert_sql_query(self, energy_usage: EnergyUsage):
values = ', '.join(['$' + str(i) for i in range(1, len(energy_usage_dict) + 1)])
return f"INSERT INTO energy_usage ({columns}) VALUES ({values})"

def _generate_select_sql_query(self, columns="*"):
def _generate_select_sql_query(self, last_n, columns="*"):
if columns == "*":
columns_str = "*"
else:
columns_str = ', '.join(columns)
return f"SELECT {columns_str} FROM energy_usage"
return f"SELECT {columns_str} FROM energy_usage ORDER BY timestamp DESC LIMIT {last_n}"
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

import aiohttp
from interfaces.carbon_api import CarbonAPI
from kasa_carbon.interfaces.carbon_api import CarbonAPI
import os
from datetime import datetime, timezone, timedelta
import json
Expand All @@ -9,10 +9,10 @@ class ElectricityMapAPI(CarbonAPI):
BASE_URL = "https://api-access.electricitymaps.com/free-tier/carbon-intensity/latest"
CACHE_FILE = "em_cache.json"

def __init__(self, co2_time_threshold_mins=120, clear_cache=False):
self.api_key = os.getenv("EM_API_KEY")
def __init__(self, em_api_key, co2_time_threshold_mins=120, clear_cache=False, em_cache_expiry_mins=30):
self.api_key = em_api_key
self.co2_time_threshold_mins = co2_time_threshold_mins
self.CACHE_EXPIRY = timedelta(int(os.getenv("EM_CACHE_EXPIRY_MINS")))
self.CACHE_EXPIRY = timedelta(int(em_cache_expiry_mins))
if clear_cache:
self._clear_cache()
else:
Expand Down
File renamed without changes.
10 changes: 6 additions & 4 deletions modules/file_storage.py → kasa_carbon/modules/file_storage.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import asyncio
from modules.energy_usage import EnergyUsage
from interfaces.datastore_api import DatastoreAPI
from kasa_carbon.modules.energy_usage import EnergyUsage
from kasa_carbon.interfaces.datastore_api import DatastoreAPI
import csv, os

class FileStorage(DatastoreAPI):
Expand All @@ -21,9 +21,11 @@ async def write_usage(self, energy_usage: EnergyUsage) -> None:
writer = csv.writer(f)
writer.writerow(energy_usage.get_dict().values())

async def read_usage(self, columns="*"):
async def read_usage(self, last_n=10, columns="*"):
with open(self.file_path, 'r') as f:
return f.readlines()
#read last_n lines
lines = f.readlines()[-last_n:]
return lines

async def close(self):
pass
Empty file.
23 changes: 10 additions & 13 deletions modules/kasa_monitor.py → kasa_carbon/modules/kasa_monitor.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,26 @@
from kasa import Discover, SmartPlug, SmartStrip
from modules.database import Database
from interfaces.datastore_api import DatastoreAPI
from modules.electricitymaps_api import ElectricityMapAPI
from modules.energy_usage import EnergyUsage
from kasa_carbon.modules.database import Database
from kasa_carbon.interfaces.datastore_api import DatastoreAPI
from kasa_carbon.modules.electricitymaps_api import ElectricityMapAPI
from kasa_carbon.modules.energy_usage import EnergyUsage
import asyncio
import time, os
from datetime import datetime, timezone

class KasaMonitor:
def __init__(self, local_lat=None, local_lon=None, local_grid_id=None, co2_api_provider="ElectricityMaps"):
def __init__(self, api_key, local_lat=None, local_lon=None, local_grid_id=None, co2_api_provider="ElectricityMaps", em_cache_expiry_mins=30):
self.devices = []
self.lat = None
self.lon = None
self.lat = local_lat
self.lon = local_lon
self.grid_id = None
if (local_lat is None or local_lon is None) and local_grid_id is None:
#TODO get the location from the smart plug; it should have it
self.lat = os.getenv("LOCAL_LAT")
self.lon = os.getenv("LOCAL_LON")
raise ValueError("Must provide either local_lat/local_lon or local_grid_id")
elif local_grid_id is not None:
self.grid_id = local_grid_id
else:
raise ValueError("Must provide either local_lat/local_lon or local_grid_id")
#else should be just use local_lat/local_lon

if co2_api_provider == "ElectricityMaps":
self.co2_api = ElectricityMapAPI()
self.co2_api = ElectricityMapAPI(em_api_key=api_key, em_cache_expiry_mins=em_cache_expiry_mins)
else:
raise ValueError("co2_api_provider must be 'EM' until others are supported")

Expand Down
Empty file.
Loading

0 comments on commit 95b6d87

Please sign in to comment.