From ba16630cf63708750674b41a4f6a73218eebbccd Mon Sep 17 00:00:00 2001 From: Sabbir Ahmed Shourov Date: Mon, 18 Aug 2025 08:18:58 +0600 Subject: [PATCH 1/2] feat: add typing, docs deploy workflow, and logger typing - Add precise type annotations to MQTT callbacks and station handler (mqtt_client.on_connect/on_disconnect and station_simulator __handle_control) to improve static checking and readability. - Add typing imports in relevant modules (Any, LogRecord) and annotate logger setup/getter to return Logger and accept typed level. - Create GitHub Actions workflow docs.yml to deploy MkDocs to gh-pages, enabling CI-triggered documentation deployment on push or manual dispatch. - Enhance mkdocs.yml with project metadata (repo_url, author, site_url, theme fonts) and docs site configuration. - Replace placeholder docs/README.md with a project-specific overview and key features for better project documentation. These changes improve code quality through typing, and add automated documentation deployment and more useful documentation metadata. --- .github/workflows/docs.yml | 35 +++++++ docs/CodeBase/powerstation_simulator.md | 15 +++ src/powerstation-simulator/mqtt_client.py | 6 +- .../station_simulator.py | 3 +- src/powerstation_simulator/config.py | 68 +++++++++++++ src/powerstation_simulator/logger.py | 82 ++++++++++++++++ src/powerstation_simulator/main.py | 96 +++++++++++++++++++ 7 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/CodeBase/powerstation_simulator.md create mode 100644 src/powerstation_simulator/config.py create mode 100644 src/powerstation_simulator/logger.py create mode 100644 src/powerstation_simulator/main.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..5e202b0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,35 @@ +name: Documentation Deployment + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + DeployMkdocs: + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout code repository + id: code_checkout + uses: actions/checkout@v4 + + - name: Setup python environment + id: setup_python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install the required python modules + id: module_installation + run: | + python -m pip install -r requirements.docs.txt + + - name: Deploy Documentation on gh-pages branch + id: deploy_mkdocs + run: | + mkdocs gh-deploy --force diff --git a/docs/CodeBase/powerstation_simulator.md b/docs/CodeBase/powerstation_simulator.md new file mode 100644 index 0000000..c9b80af --- /dev/null +++ b/docs/CodeBase/powerstation_simulator.md @@ -0,0 +1,15 @@ +# Power Station Simulator + +This section contains the Python API documentation for the power station simulation code. + +The following modules are documented here: + +::: powerstation_simulator.main + +::: powerstation_simulator.station_simulator + +::: powerstation_simulator.mqtt_client + +::: powerstation_simulator.config + +::: powerstation_simulator.logger diff --git a/src/powerstation-simulator/mqtt_client.py b/src/powerstation-simulator/mqtt_client.py index bdc5e0f..0b34fd6 100644 --- a/src/powerstation-simulator/mqtt_client.py +++ b/src/powerstation-simulator/mqtt_client.py @@ -41,14 +41,16 @@ def disconnect(self): self.client.loop_stop() self.client.disconnect() - def on_connect(self, client, userdata, flags, rc) -> None: + def on_connect( + self, client: mqtt_client.Client, userdata: Any, flags: dict, rc: int + ) -> None: if rc == 0: self.connected = True logger.info(f"Connected to MQTT broker at {self.host}:{self.port}") else: logger.error(f"Failed to connect, return code {rc}") - def on_disconnect(self, client, userdata, rc) -> None: + def on_disconnect(self, client: mqtt_client.Client, userdata: Any, rc: int) -> None: self.connected = False logger.info("Disconnected from MQTT broker") diff --git a/src/powerstation-simulator/station_simulator.py b/src/powerstation-simulator/station_simulator.py index 41fdc02..939a318 100644 --- a/src/powerstation-simulator/station_simulator.py +++ b/src/powerstation-simulator/station_simulator.py @@ -1,6 +1,7 @@ from random import random from threading import Thread from time import sleep +from typing import Any from config import AppConfig from logger import getLogger @@ -105,7 +106,7 @@ def __publish_output_loop(self): self.publish_output() sleep(self.app_config.PUBLISH_INTERVAL_SECONDS) - def __handle_control(self, client, userdata, message): + def __handle_control(self, client: Any, userdata: Any, message: Any): """ Handle incoming control messages: - '1' => start() diff --git a/src/powerstation_simulator/config.py b/src/powerstation_simulator/config.py new file mode 100644 index 0000000..3f685ad --- /dev/null +++ b/src/powerstation_simulator/config.py @@ -0,0 +1,68 @@ +from os import getenv +from typing import Annotated + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AppConfig(BaseSettings): + """ + AppConfig is the main configuration class for the power station application. + + This class defines all the necessary configuration parameters for the application, + including station metadata, MQTT broker settings, and simulator configurations. + It uses Pydantic's BaseSettings for environment variable loading and validation. + + Environment variables are loaded from a .env.sample file by default. + """ + + model_config = SettingsConfigDict( + env_file=".env.sample", + env_file_encoding="utf-8", + validate_by_name=False, + case_sensitive=True, + extra="ignore", + ) + + # Metadata (static info) + POWER_STATION_ID: Annotated[str, Field(alias="POWER_STATION_ID")] = "ps-001" + LOCATION: Annotated[str, Field(alias="LOCATION")] = "Dhaka, Bangladesh" + CAPACITY_KW: Annotated[int, Field(alias="CAPACITY_KW")] = 1000 + + # MQTT broker config + MQTT_HOST: Annotated[str, Field(alias="MQTT_HOST")] = "localhost" + MQTT_PORT: Annotated[int, Field(alias="MQTT_PORT")] = 1883 + MQTT_USERNAME: Annotated[str, Field(alias="MQTT_USERNAME")] = "extinctcoder" + MQTT_PASSWORD: Annotated[str, Field(alias="MQTT_PASSWORD")] = "Mosquitto123456#" + MQTT_TOPIC_PREFIX: Annotated[str, Field(alias="MQTT_TOPIC_PREFIX")] = ( + "smartgrid/powerstation" + ) + + ENABLE_WEBSOCKET: Annotated[bool, Field(alias="ENABLE_WEBSOCKET")] = False + + # Simulator settings + PUBLISH_INTERVAL_SECONDS: Annotated[ + int, Field(alias="PUBLISH_INTERVAL_SECONDS") + ] = 2 + + STATUS_PUBLISH_INTERVAL_SECONDS: int = PUBLISH_INTERVAL_SECONDS * 2 + METADATA_PUBLISH_INTERVAL_SECONDS: int = PUBLISH_INTERVAL_SECONDS * 5 + + +def load_power_station_configs(station_prefix: str | None = None) -> AppConfig: + """ + Dynamically loads the config for a specific power station prefix, e.g., 'PS_001'. + If no prefix is provided explicitly, reads from the STATION_PREFIX env var. + """ + station_prefix = station_prefix or getenv("STATION_PREFIX") + + if not station_prefix: + return AppConfig() # fallback to default or global settings + + class AppConfigWithPrefix(AppConfig): + model_config = { + **AppConfig.model_config, + "env_prefix": station_prefix.upper().replace("-", "_") + "_", + } + + return AppConfigWithPrefix() diff --git a/src/powerstation_simulator/logger.py b/src/powerstation_simulator/logger.py new file mode 100644 index 0000000..523f5be --- /dev/null +++ b/src/powerstation_simulator/logger.py @@ -0,0 +1,82 @@ +import logging +import sys + +COLORS = { + "DEBUG": "\033[34m", # BLUE + "INFO": "\033[32m", # GREEN + "WARNING": "\033[33m", # YELLOW + "ERROR": "\033[31m", # RED + "CRITICAL": "\033[1m\033[31m", # BOLD RED + "RESET": "\033[0m", # RESET +} + + +class ColoredFormatter(logging.Formatter): + """ + A custom logging formatter that adds color to log level names in terminal output. + + This formatter wraps the log level name with ANSI color codes based on the + severity level. Colors are defined in the COLORS dictionary. + + Methods: + format: Overrides the base Formatter's format method to add colors. + """ + + def format(self, record) -> str: + """ + Format the specified record with colored level names. + + Args: + record: A LogRecord object containing all the information + needed to generate a log message. + + Returns: + str: The formatted log message with colored level name. + """ + color = COLORS.get(record.levelname, COLORS["RESET"]) + record.levelname = f"{color}{record.levelname}{COLORS['RESET']}" + return super().format(record) + + +def setup_base_logger(level=logging.DEBUG): + """ + Configure and return the root logger with colored output. + + This function sets up the root logger with a StreamHandler that outputs + to stdout and formats messages using the ColoredFormatter. If the root + logger already has handlers configured, this function does nothing. + + Args: + level: The logging level to set for the root logger. + Default is logging.DEBUG. + + Returns: + logging.Logger: The configured root logger instance. + """ + root_logger = logging.getLogger() + if not root_logger.hasHandlers(): + root_logger.setLevel(level) + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter( + ColoredFormatter("%(asctime)s %(filename)s: %(levelname)s, %(message)s") + ) + root_logger.addHandler(handler) + root_logger.propagate = False + return root_logger + + +def getLogger(name: str): + """ + Get a logger with the specified name, ensuring the base logger is configured. + + This function calls setup_base_logger() to ensure that logging is properly + configured with colored output, then returns a logger with the given name. + + Args: + name: A string that identifies the logger. + + Returns: + logging.Logger: A configured logger instance with the specified name. + """ + setup_base_logger() + return logging.getLogger(name) diff --git a/src/powerstation_simulator/main.py b/src/powerstation_simulator/main.py new file mode 100644 index 0000000..e410e56 --- /dev/null +++ b/src/powerstation_simulator/main.py @@ -0,0 +1,96 @@ +import argparse +from time import sleep + +from config import AppConfig, load_power_station_configs +from logger import getLogger +from station_simulator import StationSimulator + +logger = getLogger(__name__) + +ps_banner = r""" + ____ ____ _________ ____ ____ ____ ____ ____ ____ ____ ____ ____ +||P |||S ||| |||S |||I |||M |||U |||L |||A |||T |||O |||R || +||__|||__|||_______|||__|||__|||__|||__|||__|||__|||__|||__|||__|| +|/__\|/__\|/_______\|/__\|/__\|/__\|/__\|/__\|/__\|/__\|/__\|/__\| + +""" + + +def main() -> None: + """ + Main function to run the Power Station Simulator. + + This function: + 1. Sets up command-line argument parsing + 2. Loads configuration based on the provided station prefix + 3. Initializes the station simulator with the configuration + 4. Runs the simulator in an infinite loop until interrupted + 5. Performs graceful shutdown on keyboard interrupt + + Command-line arguments: + -sp, --station-prefix: Prefix for power station-specific environment variables + (e.g., PS_001) + + Returns: + None + """ + parser = argparse.ArgumentParser( + description="⚡ Power Station Simulator", + epilog=""" +IMPORTANT: +- Use the --station-prefix (or -sp) flag to load power station-specific config. +- The required environment variables should be defined in a `.env` file or OS environment. +- When a prefix is given, all relevant keys will be loaded with that prefix applied. + +See `src/config.py` for the complete list of supported configuration fields. + +Examples: + # Load config with prefix PS_001 (looks for variables like PS_001_*) + python main.py --station-prefix PS_001 + + # Load config with prefix TEST (looks for variables like TEST_*) + python main.py -sp TEST +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-sp", + "--station-prefix", + type=str, + help="Prefix for power station-specific environment variables (e.g., PS_001)", + ) + + args = parser.parse_args() + + print(ps_banner) + logger.info( + f"Simple Power Station SIMULATOR serving station : {args.station_prefix}" + ) + app_config: AppConfig = load_power_station_configs( + station_prefix=args.station_prefix + ) + + simulator: StationSimulator = StationSimulator(app_config=app_config) + simulator.startup_sequence() + try: + while True: + sleep(1) + except KeyboardInterrupt: + simulator.shutdown_sequence() + + +if __name__ == "__main__": + """ + Entry point for the Power Station Simulator application. + + When this script is executed directly (not imported as a module), + this block will run the main() function which: + 1. Parses command-line arguments + 2. Loads configuration based on the provided station prefix + 3. Initializes and runs the station simulator + 4. Handles graceful shutdown on keyboard interrupt + + Example usage: + python main.py --station-prefix PS_001 + """ + main() From 4cb57ad07a3f5150a871a862450595c817774b03 Mon Sep 17 00:00:00 2001 From: Sabbir Ahmed Shourov Date: Mon, 18 Aug 2025 08:34:41 +0600 Subject: [PATCH 2/2] Update docs and config for Smart Grid Simulator - Rewrite README with simulator overview and features - Add project metadata to mkdocs.yml - Improve logger type hints and docstrings --- docs/README.md | 32 ++++++++++------------------ mkdocs.yml | 10 +++++++++ src/powerstation_simulator/logger.py | 14 ++++++------ 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/docs/README.md b/docs/README.md index 43383ff..0e48d67 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,24 +1,14 @@ -# Welcome to MkDocs +# Smart Grid Simulator -For full documentation visit [mkdocs.org](https://www.mkdocs.org). +The Smart Grid Simulator is a virtual power station network designed to emulate real-world power generation and distribution. It allows testing and monitoring of smart grid systems without relying on physical hardware. -## Commands +## Key Features +- Simulated Power Stations: Generate realistic power output based on configurable parameters. +- Real-Time Data Publishing: Station metadata, output, and status are continuously published via MQTT. +- Configurable & Extensible: Easily set up multiple stations using environment variables or configuration files. +- Monitoring Ready: Data can be visualized through dashboards or consumed by other applications. -* `mkdocs new [dir-name]` - Create a new project. -* `mkdocs serve` - Start the live-reloading docs server. -* `mkdocs build` - Build the documentation site. -* `mkdocs -h` - Print help message and exit. - -## Project layout - - mkdocs.yml # The configuration file. - docs/ - index.md # The documentation homepage. - ... # Other markdown pages, images and other files. - - - -1. [[mash network|mash_network]] -2. [[current sensor|current sensor]] -3. [[voltage sensor|voltage sensor]] -4. +## Use Cases +- Test smart grid applications safely and efficiently. +- Prototype dashboards and analytics for power monitoring. +- Simulate renewable and conventional energy sources. diff --git a/mkdocs.yml b/mkdocs.yml index e24757d..1b82936 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,7 +1,17 @@ site_name: Smart Grid +repo_url: https://github.com/extinctCoder/smart_grid +edit_uri: https://github.com/extinctCoder/smart_grid/docs + +site_author: extinctCoder +site_description: "A smart grid project for efficient energy management." +site_url: https://extinctcoder.github.io/smart_grid +copyright: MIT License theme: name: material + font: + text: "Roboto" + code: "Fira Code" docs_dir: docs site_dir: site diff --git a/src/powerstation_simulator/logger.py b/src/powerstation_simulator/logger.py index 523f5be..d8496bc 100644 --- a/src/powerstation_simulator/logger.py +++ b/src/powerstation_simulator/logger.py @@ -1,5 +1,6 @@ import logging import sys +from logging import Logger, LogRecord COLORS = { "DEBUG": "\033[34m", # BLUE @@ -22,7 +23,7 @@ class ColoredFormatter(logging.Formatter): format: Overrides the base Formatter's format method to add colors. """ - def format(self, record) -> str: + def format(self, record: LogRecord) -> str: """ Format the specified record with colored level names. @@ -38,7 +39,7 @@ def format(self, record) -> str: return super().format(record) -def setup_base_logger(level=logging.DEBUG): +def setup_base_logger(level: int | str = logging.DEBUG) -> Logger: """ Configure and return the root logger with colored output. @@ -47,11 +48,12 @@ def setup_base_logger(level=logging.DEBUG): logger already has handlers configured, this function does nothing. Args: - level: The logging level to set for the root logger. - Default is logging.DEBUG. + level: + The logging level to set for the root logger. + Default is logging.DEBUG. Returns: - logging.Logger: The configured root logger instance. + Logger: The configured root logger instance. """ root_logger = logging.getLogger() if not root_logger.hasHandlers(): @@ -65,7 +67,7 @@ def setup_base_logger(level=logging.DEBUG): return root_logger -def getLogger(name: str): +def getLogger(name: str) -> Logger: """ Get a logger with the specified name, ensuring the base logger is configured.