Plant controller system for greenhouse monitoring and actuation.
A modular plant controller that provides sensing and actuation capabilities
for plants in a greenhouse environment, designed as a Physical Twin (PT)
for Digital Twin (DT) research.
The controller is configured via TOML and JSON files stored in
~/.plant_controller/. Each plant gets its own JSON configuration file
in ~/.plant_controller/plants/, and pump schedules are stored in
~/.plant_controller/pump_schedules/.
Subcommands
run Start the controller (sensing loops, watering schedules, HTTP API).
setup Interactive setup utility for calibration and diagnostics.
Extending the controller
Custom sensors, pumps, and pump schedules can be added as submodules.
See the plant_controller.sensors, plant_controller.pumps, and
plant_controller.pump_schedules packages for the abstract base
classes that must be implemented.
controller_run(args)
async
Start the controller: sensing loops, watering schedules, and HTTP API.
Source code in pt/controller_3/src/plant_controller/__init__.py
| async def controller_run(args: argparse.Namespace):
"""Start the controller: sensing loops, watering schedules, and HTTP API."""
print("Starting plant controller...")
units, db_client, busses = await common_startup_tasks()
try:
api = web_api.WebAPI(
host="0.0.0.0",
port=8099,
db_client=db_client,
units=units,
log_level=args.log_level
)
_logger.info("Starting main loop")
async with anyio.create_task_group() as tg:
tg.start_soon(api.start)
for u in units:
tg.start_soon(u.start_sensing)
if isinstance(u, plant.Plant):
tg.start_soon(u.start_watering)
print("Plant controller is now running. Press Ctrl+C to quit.")
except Exception as e:
_logger.error(f"Error in main loop: {e}", exc_info=True)
finally:
busses[com_bus._MODBUS].close()
|
controller_setup(args)
async
Interactive setup utility for calibration and diagnostics.
Source code in pt/controller_3/src/plant_controller/__init__.py
| async def controller_setup(args: argparse.Namespace):
"""Interactive setup utility for calibration and diagnostics."""
clear_screen()
print("Welcome to the plant controller setup utility.")
print("Here you are able to perform different setup actions, depending on the connected units and sensors.")
print("")
print("Checking connected units and sensors for setup actions...")
units, _, busses = await common_startup_tasks()
try:
setup_actions = {}
for u in units:
unit_setup_functions = u.setup_functions()
if unit_setup_functions:
setup_actions[u.name] = unit_setup_functions
if not setup_actions:
print("The connected units and sensors do not have any setup actions.")
print("The setup utility will now exit.")
return
while True:
print("")
print("Available setup actions:")
for unit_name, actions in setup_actions.items():
for action_name, action_info in actions.items():
print(f" {unit_name}.{action_name}: {action_info['description']}")
print(" exit: Exit the setup utility.")
print("")
choice = input("Enter the name of the setup action you want to perform: ")
if choice == "exit":
clear_screen()
print("Exiting setup utility. Goodbye!")
break
if "." in choice:
chosen_unit, chosen_action = choice.split(".", 1)
if chosen_unit in setup_actions and chosen_action in setup_actions[chosen_unit]:
action_info = setup_actions[chosen_unit][chosen_action]
clear_screen()
print(f"Performing setup action '{choice}'...")
await action_info["function"]()
continue
clear_screen()
print(f"Invalid choice '{choice}'. Please try again.")
except KeyboardInterrupt:
_logger.info("Got SIGINT, shutting down...")
except Exception as e:
_logger.error(f"Error in main loop: {e}")
finally:
busses[com_bus._MODBUS].close()
|
common_startup_tasks()
async
Perform shared initialization: directories, logging, config, DB, busses, units.
Returns:
| Type |
Description |
|
|
Tuple of (units, db_client, busses).
|
Source code in pt/controller_3/src/plant_controller/__init__.py
| async def common_startup_tasks():
"""Perform shared initialization: directories, logging, config, DB, busses, units.
Returns:
Tuple of (units, db_client, busses).
"""
_logger.info("Performing common startup tasks...")
if not os.path.exists(_IMPL_DIR):
_logger.info(f"Implementation directory not found at '{_IMPL_DIR}', creating it...")
os.makedirs(_IMPL_DIR)
if not os.path.exists(_LOG_PATH):
_logger.info(f"Log file not found at '{_LOG_PATH}', creating it...")
with open(_LOG_PATH, "w") as f:
pass
file_handler = logging.FileHandler(_LOG_PATH)
file_handler.setFormatter(logging.Formatter("%(asctime)s - [%(levelname)s] %(message)s"))
_logger.addHandler(file_handler)
if not os.path.exists(_CONFIG_PATH):
_logger.critical(f"Config file not found at '{_CONFIG_PATH}'.")
exit(1)
if not os.path.exists(_PLANTS_DIR):
_logger.info(f"Plants directory not found at '{_PLANTS_DIR}', creating it...")
os.makedirs(_PLANTS_DIR)
if not os.path.exists(_SCHEDULES_DIR):
_logger.info(f"Pump schedules directory not found at '{_SCHEDULES_DIR}', creating it...")
os.makedirs(_SCHEDULES_DIR)
config = load_config()
db_client = connect_to_db(config)
busses = await com_bus.busses()
try:
units = create_units(
db_client=db_client,
busses=busses
)
return units, db_client, busses
except Exception as e:
_logger.error(f"Error during startup: {e}")
busses[com_bus._MODBUS].close()
raise
|
load_config(path=_CONFIG_PATH)
Load and parse the TOML configuration file.
Source code in pt/controller_3/src/plant_controller/__init__.py
| def load_config(path: str = _CONFIG_PATH) -> dict:
"""Load and parse the TOML configuration file."""
with open(path, "rb") as f:
config = tomllib.load(f)
return config
|
create_units(db_client, busses)
Instantiate all configured units (plants and the greenhouse).
Reads plant JSON configs from the plants directory and creates Plant
instances for each. Always appends a Greenhouse unit.
Parameters:
| Name |
Type |
Description |
Default |
db_client
|
DatabaseClient
|
Database client for persisting measurements.
|
required
|
busses
|
dict[str, Bus]
|
Dictionary mapping bus type names to Bus instances.
|
required
|
Returns:
| Type |
Description |
list[Unit]
|
List of initialized Unit instances.
|
Source code in pt/controller_3/src/plant_controller/__init__.py
| def create_units(
db_client: database.DatabaseClient,
busses: dict[str, com_bus.Bus]
) -> list[unit.Unit]:
"""Instantiate all configured units (plants and the greenhouse).
Reads plant JSON configs from the plants directory and creates Plant
instances for each. Always appends a Greenhouse unit.
Args:
db_client: Database client for persisting measurements.
busses: Dictionary mapping bus type names to Bus instances.
Returns:
List of initialized Unit instances.
"""
units = []
for config, config_location in [
(plant.Plant.parse_config(os.path.join(_PLANTS_DIR, f)), os.path.join(_PLANTS_DIR, f))
for f
in os.listdir(_PLANTS_DIR)
if f.endswith(".json")
]:
units.append(
plant.Plant(
config=config,
db_client=db_client,
busses=busses,
schedules_directory=_SCHEDULES_DIR,
config_path=config_location
)
)
if not units:
_logger.warning(f"No plants configured for the controller. Add plant configuration files to '{_PLANTS_DIR}'.")
units.append(
greenhouse.Greenhouse(
db_client=db_client,
busses=busses
)
)
return units
|
connect_to_db(config)
Create a database client from the 'database' section of the config.
Source code in pt/controller_3/src/plant_controller/__init__.py
| def connect_to_db(config: dict) -> database.DatabaseClient:
"""Create a database client from the 'database' section of the config."""
_logger.info(f"Connecting to database {config['database']['name']} at {config['database']['host']}...")
db = database.Database(
name=config["database"]["name"],
host=config["database"]["host"],
token=config["database"]["token"]
)
return db.spawn_client()
|
parse_args(*args, **kwargs)
Parse command-line arguments for the plant controller.
Source code in pt/controller_3/src/plant_controller/__init__.py
| def parse_args(*args, **kwargs) -> argparse.Namespace:
"""Parse command-line arguments for the plant controller."""
parser = argparse.ArgumentParser(
prog="plant_controller",
description="System for monitoring and watering of plants."
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {_version.__version__}"
)
parser.add_argument(
"--log-level",
type=str,
default=os.getenv("PLANT_CONTROLLER_LOG_LEVEL", "WARNING").upper(),
help="Set the logging level. Default is INFO. Can also be set via the PLANT_CONTROLLER_LOG_LEVEL environment variable.",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
)
subparsers = parser.add_subparsers(
title='subcommands',
description='Valid subcommands'
)
run_parser = subparsers.add_parser(
"run",
help="Run the plant controller."
)
run_parser.set_defaults(func=controller_run)
setup_parser = subparsers.add_parser(
"setup",
help="Enter setup mode. Setup mode allows for peripheral calibration and other one-time setup tasks."
)
setup_parser.set_defaults(func=controller_setup)
return parser.parse_args(*args, **kwargs)
|
main()
async
Entry point: parse arguments and dispatch to the chosen subcommand.
Source code in pt/controller_3/src/plant_controller/__init__.py
| async def main():
"""Entry point: parse arguments and dispatch to the chosen subcommand."""
args = parse_args()
_logger.setLevel(args.log_level)
if not hasattr(args, "func"):
parse_args(["--help"])
return
await args.func(args)
|