config.py 3.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. """Runtime configuration. Secrets are read from the environment, not JSON."""
  2. from dataclasses import dataclass
  3. import json
  4. import os
  5. from pathlib import Path
  6. @dataclass(frozen=True)
  7. class Config:
  8. system_id: str
  9. uav_type: int
  10. serial_port: str
  11. serial_baudrate: int
  12. mqtt_host: str
  13. mqtt_port: int = 1883
  14. mqtt_client_id: str = ""
  15. mqtt_username: str | None = None
  16. mqtt_password_env: str | None = None
  17. mqtt_tls: bool = False
  18. mqtt_ca_file: str | None = None
  19. mqtt_cert_file: str | None = None
  20. mqtt_key_file: str | None = None
  21. mqtt_keepalive: int = 60
  22. max_chunk_bytes: int = 1024
  23. max_command_bytes: int = 4096
  24. reconnect_seconds: float = 2.0
  25. publish_raw_mavlink: bool = True
  26. def __post_init__(self) -> None:
  27. if not self.system_id or any(c in self.system_id for c in "/+#\x00"):
  28. raise ValueError("system_id must be a nonempty single MQTT topic level")
  29. if type(self.uav_type) is not int or self.uav_type < 0:
  30. raise ValueError("uav_type must be a nonnegative integer assigned by the platform")
  31. if not self.serial_port or not self.mqtt_host:
  32. raise ValueError("serial_port and mqtt_host are required")
  33. if not 1 <= self.serial_baudrate or not 1 <= self.mqtt_port <= 65535:
  34. raise ValueError("invalid serial_baudrate or mqtt_port")
  35. if not 1 <= self.max_chunk_bytes <= self.max_command_bytes:
  36. raise ValueError("max_chunk_bytes must be positive and <= max_command_bytes")
  37. if self.mqtt_keepalive < 1 or self.reconnect_seconds <= 0:
  38. raise ValueError("invalid keepalive or reconnect interval")
  39. if type(self.publish_raw_mavlink) is not bool:
  40. raise ValueError("publish_raw_mavlink must be a boolean")
  41. if self.mqtt_password_env and not self.mqtt_username:
  42. raise ValueError("mqtt_username is required when mqtt_password_env is set")
  43. if (self.mqtt_cert_file or self.mqtt_key_file or self.mqtt_ca_file) and not self.mqtt_tls:
  44. raise ValueError("TLS certificate settings require mqtt_tls=true")
  45. if bool(self.mqtt_cert_file) != bool(self.mqtt_key_file):
  46. raise ValueError("mqtt_cert_file and mqtt_key_file must be set together")
  47. @property
  48. def obtain_topic(self) -> str:
  49. return f"/OBTAIN/{self.system_id}"
  50. @property
  51. def publish_topic(self) -> str:
  52. return f"/PUBLISH/{self.system_id}"
  53. @property
  54. def receive_topic(self) -> str:
  55. return f"/RECEIVE/{self.system_id}"
  56. @property
  57. def client_id(self) -> str:
  58. return self.mqtt_client_id or f"mavlink-bridge-{self.system_id}"
  59. @classmethod
  60. def from_file(cls, path: str | Path) -> "Config":
  61. with open(path, encoding="utf-8") as file:
  62. values = json.load(file)
  63. if not isinstance(values, dict):
  64. raise ValueError("configuration must be a JSON object")
  65. return cls(**values)
  66. def mqtt_password(self) -> str | None:
  67. if self.mqtt_password_env is None:
  68. return None
  69. value = os.environ.get(self.mqtt_password_env)
  70. if value is None:
  71. raise ValueError(f"missing MQTT password environment variable: {self.mqtt_password_env}")
  72. return value