bacnet.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. from ipaddress import ip_address
  2. import re
  3. from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
  4. OBJECT_ID_MAX = 4_194_303
  5. OBJECT_TYPE_ALIASES = {
  6. "analoginput": ("AnalogInput", "analogInput"),
  7. "analogoutput": ("AnalogOutput", "analogOutput"),
  8. "analogvalue": ("AnalogValue", "analogValue"),
  9. "binaryinput": ("BinaryInput", "binaryInput"),
  10. "binaryoutput": ("BinaryOutput", "binaryOutput"),
  11. "binaryvalue": ("BinaryValue", "binaryValue"),
  12. "multistateinput": ("MultiStateInput", "multiStateInput"),
  13. "multistateoutput": ("MultiStateOutput", "multiStateOutput"),
  14. "multistatevalue": ("MultiStateValue", "multiStateValue"),
  15. "device": ("Device", "device"),
  16. "calendar": ("Calendar", "calendar"),
  17. "schedule": ("Schedule", "schedule"),
  18. "trendlog": ("TrendLog", "trendLog"),
  19. "notificationclass": ("NotificationClass", "notificationClass"),
  20. }
  21. def object_type_key(value: str) -> str:
  22. return re.sub(r"[^0-9a-z]", "", value.lower())
  23. def normalize_object_type(value: str) -> str:
  24. key = object_type_key(value)
  25. if key in OBJECT_TYPE_ALIASES:
  26. return OBJECT_TYPE_ALIASES[key][0]
  27. if not re.fullmatch(r"[A-Za-z][0-9A-Za-z_-]*", value):
  28. raise ValueError("object_type must be a valid BACnet object type")
  29. parts = re.split(r"[-_]", value)
  30. return "".join(part[:1].upper() + part[1:] for part in parts if part)
  31. def bac0_object_type(value: str) -> str:
  32. key = object_type_key(value)
  33. if key in OBJECT_TYPE_ALIASES:
  34. return OBJECT_TYPE_ALIASES[key][1]
  35. normalized = normalize_object_type(value)
  36. return normalized[:1].lower() + normalized[1:]
  37. class BacnetBaseRequest(BaseModel):
  38. model_config = ConfigDict(extra="forbid")
  39. ip: str = Field(min_length=1)
  40. bacnet_device_id: int = Field(ge=0, le=OBJECT_ID_MAX)
  41. port: int = Field(default=47808, ge=1, le=65535)
  42. @field_validator("ip")
  43. @classmethod
  44. def validate_ip(cls, value: str) -> str:
  45. try:
  46. ip_address(value)
  47. except ValueError as exc:
  48. raise ValueError("ip must be a valid IP address") from exc
  49. return value
  50. class BacnetPointSpec(BaseModel):
  51. model_config = ConfigDict(extra="forbid")
  52. object_type: str
  53. object_id: int = Field(ge=0, le=OBJECT_ID_MAX)
  54. @field_validator("object_type")
  55. @classmethod
  56. def validate_object_type(cls, value: str) -> str:
  57. return normalize_object_type(value)
  58. class BacnetPointReadRequest(BacnetBaseRequest):
  59. points: list[BacnetPointSpec] = Field(min_length=1)
  60. class BacnetPointSearchRequest(BacnetBaseRequest):
  61. pass
  62. class BacnetBBMDWhoIsRequest(BaseModel):
  63. model_config = ConfigDict(extra="forbid")
  64. bbmd_ip: str = Field(min_length=1)
  65. bbmd_port: int = Field(default=47808, ge=1, le=65535)
  66. local_ip: str | None = Field(default=None, min_length=1)
  67. local_port: int | None = Field(default=None, ge=1, le=65535)
  68. ttl: int = Field(default=60, ge=1, le=65535)
  69. timeout: float = Field(default=5, gt=0, le=120)
  70. low_limit: int | None = Field(default=0, ge=0, le=OBJECT_ID_MAX)
  71. high_limit: int | None = Field(default=OBJECT_ID_MAX, ge=0, le=OBJECT_ID_MAX)
  72. local_device_id: int | None = Field(default=None, ge=0, le=OBJECT_ID_MAX)
  73. @field_validator("bbmd_ip", "local_ip")
  74. @classmethod
  75. def validate_optional_ip(cls, value: str | None) -> str | None:
  76. if value is None:
  77. return value
  78. try:
  79. ip_address(value)
  80. except ValueError as exc:
  81. raise ValueError("must be a valid IP address") from exc
  82. return value
  83. @model_validator(mode="after")
  84. def validate_whois_range(self) -> "BacnetBBMDWhoIsRequest":
  85. if (self.low_limit is None) != (self.high_limit is None):
  86. raise ValueError("low_limit and high_limit must be provided together or both omitted")
  87. if self.low_limit is not None and self.high_limit is not None and self.low_limit > self.high_limit:
  88. raise ValueError("low_limit must be less than or equal to high_limit")
  89. return self