config.py 3.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  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. request_params_on_start: bool = True
  27. param_request_timeout: float = 15.0
  28. param_request_max_attempts: int = 3
  29. def __post_init__(self) -> None:
  30. if not self.system_id or any(c in self.system_id for c in "/+#\x00"):
  31. raise ValueError("system_id must be a nonempty single MQTT topic level")
  32. if type(self.uav_type) is not int or self.uav_type < 0:
  33. raise ValueError("uav_type must be a nonnegative integer assigned by the platform")
  34. if not self.serial_port or not self.mqtt_host:
  35. raise ValueError("serial_port and mqtt_host are required")
  36. if not 1 <= self.serial_baudrate or not 1 <= self.mqtt_port <= 65535:
  37. raise ValueError("invalid serial_baudrate or mqtt_port")
  38. if not 1 <= self.max_chunk_bytes <= self.max_command_bytes:
  39. raise ValueError("max_chunk_bytes must be positive and <= max_command_bytes")
  40. if self.mqtt_keepalive < 1 or self.reconnect_seconds <= 0:
  41. raise ValueError("invalid keepalive or reconnect interval")
  42. if type(self.publish_raw_mavlink) is not bool:
  43. raise ValueError("publish_raw_mavlink must be a boolean")
  44. if type(self.request_params_on_start) is not bool:
  45. raise ValueError("request_params_on_start must be a boolean")
  46. if self.param_request_timeout <= 0:
  47. raise ValueError("param_request_timeout must be positive")
  48. if type(self.param_request_max_attempts) is not int or self.param_request_max_attempts < 1:
  49. raise ValueError("param_request_max_attempts must be a positive integer")
  50. if self.mqtt_password_env and not self.mqtt_username:
  51. raise ValueError("mqtt_username is required when mqtt_password_env is set")
  52. if (self.mqtt_cert_file or self.mqtt_key_file or self.mqtt_ca_file) and not self.mqtt_tls:
  53. raise ValueError("TLS certificate settings require mqtt_tls=true")
  54. if bool(self.mqtt_cert_file) != bool(self.mqtt_key_file):
  55. raise ValueError("mqtt_cert_file and mqtt_key_file must be set together")
  56. @property
  57. def obtain_topic(self) -> str:
  58. return f"/OBTAIN/{self.system_id}"
  59. @property
  60. def publish_topic(self) -> str:
  61. return f"/PUBLISH/{self.system_id}"
  62. @property
  63. def receive_topic(self) -> str:
  64. return f"/RECEIVE/{self.system_id}"
  65. @property
  66. def client_id(self) -> str:
  67. return self.mqtt_client_id or f"mavlink-bridge-{self.system_id}"
  68. @classmethod
  69. def from_file(cls, path: str | Path) -> "Config":
  70. with open(path, encoding="utf-8") as file:
  71. values = json.load(file)
  72. if not isinstance(values, dict):
  73. raise ValueError("configuration must be a JSON object")
  74. return cls(**values)
  75. def mqtt_password(self) -> str | None:
  76. if self.mqtt_password_env is None:
  77. return None
  78. value = os.environ.get(self.mqtt_password_env)
  79. if value is None:
  80. raise ValueError(f"missing MQTT password environment variable: {self.mqtt_password_env}")
  81. return value