opensignalbox-interface 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. opensignalbox/interface/__init__.py +42 -0
  2. opensignalbox/interface/adapters/__init__.py +3 -0
  3. opensignalbox/interface/adapters/base.py +148 -0
  4. opensignalbox/interface/adapters/controller.py +286 -0
  5. opensignalbox/interface/adapters/modbus/__init__.py +3 -0
  6. opensignalbox/interface/adapters/modbus/adapter.py +485 -0
  7. opensignalbox/interface/assets/favicon.ico +0 -0
  8. opensignalbox/interface/interfaces/__init__.py +3 -0
  9. opensignalbox/interface/interfaces/base.py +170 -0
  10. opensignalbox/interface/interfaces/basicio/__init__.py +3 -0
  11. opensignalbox/interface/interfaces/basicio/connections.py +239 -0
  12. opensignalbox/interface/interfaces/basicio/handler.py +1096 -0
  13. opensignalbox/interface/interfaces/basicio/models.py +165 -0
  14. opensignalbox/interface/interfaces/bell/__init__.py +3 -0
  15. opensignalbox/interface/interfaces/bell/connections.py +279 -0
  16. opensignalbox/interface/interfaces/bell/handler.py +802 -0
  17. opensignalbox/interface/interfaces/bell/models.py +103 -0
  18. opensignalbox/interface/interfaces/controller.py +467 -0
  19. opensignalbox/interface/main.py +187 -0
  20. opensignalbox/interface/models.py +155 -0
  21. opensignalbox/interface/routes/__init__.py +4 -0
  22. opensignalbox/interface/routes/adapters.py +119 -0
  23. opensignalbox/interface/routes/interfaces.py +104 -0
  24. opensignalbox/interface/routes.py +204 -0
  25. opensignalbox/interface/utils.py +29 -0
  26. opensignalbox/interface/version.py +5 -0
  27. opensignalbox/interface/web_ui/.gitignore +24 -0
  28. opensignalbox/interface/web_ui/README.md +5 -0
  29. opensignalbox/interface/web_ui/WEB_UI_REFACTORING.md +136 -0
  30. opensignalbox/interface/web_ui/components.json +17 -0
  31. opensignalbox/interface/web_ui/dist/assets/index-DhsEc6uo.css +2020 -0
  32. opensignalbox/interface/web_ui/dist/assets/index-t__5JZjf.js +39056 -0
  33. opensignalbox/interface/web_ui/dist/assets/index-t__5JZjf.js.map +1 -0
  34. opensignalbox/interface/web_ui/dist/index.html +14 -0
  35. opensignalbox/interface/web_ui/index.html +13 -0
  36. opensignalbox/interface/web_ui/package-lock.json +3710 -0
  37. opensignalbox/interface/web_ui/package.json +39 -0
  38. opensignalbox/interface/web_ui/src/App.vue +32 -0
  39. opensignalbox/interface/web_ui/src/assets/favicon.ico +0 -0
  40. opensignalbox/interface/web_ui/src/assets/index.css +83 -0
  41. opensignalbox/interface/web_ui/src/components/SharedVariablePicker.vue +112 -0
  42. opensignalbox/interface/web_ui/src/components/adapters/AdapterList.vue +183 -0
  43. opensignalbox/interface/web_ui/src/components/adapters/types.ts +35 -0
  44. opensignalbox/interface/web_ui/src/components/interfaces/InterfaceList.vue +200 -0
  45. opensignalbox/interface/web_ui/src/components/ui/badge/Badge.vue +16 -0
  46. opensignalbox/interface/web_ui/src/components/ui/badge/index.ts +25 -0
  47. opensignalbox/interface/web_ui/src/components/ui/breadcrumb/Breadcrumb.vue +13 -0
  48. opensignalbox/interface/web_ui/src/components/ui/breadcrumb/BreadcrumbEllipsis.vue +22 -0
  49. opensignalbox/interface/web_ui/src/components/ui/breadcrumb/BreadcrumbItem.vue +16 -0
  50. opensignalbox/interface/web_ui/src/components/ui/breadcrumb/BreadcrumbLink.vue +19 -0
  51. opensignalbox/interface/web_ui/src/components/ui/breadcrumb/BreadcrumbList.vue +16 -0
  52. opensignalbox/interface/web_ui/src/components/ui/breadcrumb/BreadcrumbPage.vue +19 -0
  53. opensignalbox/interface/web_ui/src/components/ui/breadcrumb/BreadcrumbSeparator.vue +21 -0
  54. opensignalbox/interface/web_ui/src/components/ui/breadcrumb/index.ts +7 -0
  55. opensignalbox/interface/web_ui/src/components/ui/button/Button.vue +26 -0
  56. opensignalbox/interface/web_ui/src/components/ui/button/index.ts +35 -0
  57. opensignalbox/interface/web_ui/src/components/ui/card/Card.vue +21 -0
  58. opensignalbox/interface/web_ui/src/components/ui/card/CardContent.vue +14 -0
  59. opensignalbox/interface/web_ui/src/components/ui/card/CardDescription.vue +14 -0
  60. opensignalbox/interface/web_ui/src/components/ui/card/CardFooter.vue +14 -0
  61. opensignalbox/interface/web_ui/src/components/ui/card/CardHeader.vue +14 -0
  62. opensignalbox/interface/web_ui/src/components/ui/card/CardTitle.vue +18 -0
  63. opensignalbox/interface/web_ui/src/components/ui/card/index.ts +6 -0
  64. opensignalbox/interface/web_ui/src/components/ui/checkbox/Checkbox.vue +33 -0
  65. opensignalbox/interface/web_ui/src/components/ui/checkbox/index.ts +1 -0
  66. opensignalbox/interface/web_ui/src/components/ui/collapsible/Collapsible.vue +15 -0
  67. opensignalbox/interface/web_ui/src/components/ui/collapsible/CollapsibleContent.vue +11 -0
  68. opensignalbox/interface/web_ui/src/components/ui/collapsible/CollapsibleTrigger.vue +11 -0
  69. opensignalbox/interface/web_ui/src/components/ui/collapsible/index.ts +3 -0
  70. opensignalbox/interface/web_ui/src/components/ui/command/Command.vue +30 -0
  71. opensignalbox/interface/web_ui/src/components/ui/command/CommandDialog.vue +21 -0
  72. opensignalbox/interface/web_ui/src/components/ui/command/CommandEmpty.vue +20 -0
  73. opensignalbox/interface/web_ui/src/components/ui/command/CommandGroup.vue +29 -0
  74. opensignalbox/interface/web_ui/src/components/ui/command/CommandInput.vue +33 -0
  75. opensignalbox/interface/web_ui/src/components/ui/command/CommandItem.vue +26 -0
  76. opensignalbox/interface/web_ui/src/components/ui/command/CommandList.vue +27 -0
  77. opensignalbox/interface/web_ui/src/components/ui/command/CommandSeparator.vue +23 -0
  78. opensignalbox/interface/web_ui/src/components/ui/command/CommandShortcut.vue +14 -0
  79. opensignalbox/interface/web_ui/src/components/ui/command/index.ts +9 -0
  80. opensignalbox/interface/web_ui/src/components/ui/dialog/Dialog.vue +14 -0
  81. opensignalbox/interface/web_ui/src/components/ui/dialog/DialogClose.vue +11 -0
  82. opensignalbox/interface/web_ui/src/components/ui/dialog/DialogContent.vue +50 -0
  83. opensignalbox/interface/web_ui/src/components/ui/dialog/DialogDescription.vue +24 -0
  84. opensignalbox/interface/web_ui/src/components/ui/dialog/DialogFooter.vue +19 -0
  85. opensignalbox/interface/web_ui/src/components/ui/dialog/DialogHeader.vue +16 -0
  86. opensignalbox/interface/web_ui/src/components/ui/dialog/DialogScrollContent.vue +59 -0
  87. opensignalbox/interface/web_ui/src/components/ui/dialog/DialogTitle.vue +29 -0
  88. opensignalbox/interface/web_ui/src/components/ui/dialog/DialogTrigger.vue +11 -0
  89. opensignalbox/interface/web_ui/src/components/ui/dialog/index.ts +9 -0
  90. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenu.vue +14 -0
  91. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +40 -0
  92. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuContent.vue +38 -0
  93. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +11 -0
  94. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuItem.vue +28 -0
  95. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +24 -0
  96. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +19 -0
  97. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +41 -0
  98. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +22 -0
  99. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +14 -0
  100. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
  101. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +30 -0
  102. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +33 -0
  103. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +13 -0
  104. opensignalbox/interface/web_ui/src/components/ui/dropdown-menu/index.ts +16 -0
  105. opensignalbox/interface/web_ui/src/components/ui/form/FormControl.vue +16 -0
  106. opensignalbox/interface/web_ui/src/components/ui/form/FormDescription.vue +20 -0
  107. opensignalbox/interface/web_ui/src/components/ui/form/FormItem.vue +19 -0
  108. opensignalbox/interface/web_ui/src/components/ui/form/FormLabel.vue +23 -0
  109. opensignalbox/interface/web_ui/src/components/ui/form/FormMessage.vue +16 -0
  110. opensignalbox/interface/web_ui/src/components/ui/form/index.ts +7 -0
  111. opensignalbox/interface/web_ui/src/components/ui/form/injectionKeys.ts +4 -0
  112. opensignalbox/interface/web_ui/src/components/ui/form/useFormField.ts +30 -0
  113. opensignalbox/interface/web_ui/src/components/ui/input/Input.vue +24 -0
  114. opensignalbox/interface/web_ui/src/components/ui/input/index.ts +1 -0
  115. opensignalbox/interface/web_ui/src/components/ui/label/Label.vue +27 -0
  116. opensignalbox/interface/web_ui/src/components/ui/label/index.ts +1 -0
  117. opensignalbox/interface/web_ui/src/components/ui/number-field/NumberField.vue +23 -0
  118. opensignalbox/interface/web_ui/src/components/ui/number-field/NumberFieldContent.vue +14 -0
  119. opensignalbox/interface/web_ui/src/components/ui/number-field/NumberFieldDecrement.vue +25 -0
  120. opensignalbox/interface/web_ui/src/components/ui/number-field/NumberFieldIncrement.vue +25 -0
  121. opensignalbox/interface/web_ui/src/components/ui/number-field/NumberFieldInput.vue +16 -0
  122. opensignalbox/interface/web_ui/src/components/ui/number-field/index.ts +5 -0
  123. opensignalbox/interface/web_ui/src/components/ui/popover/Popover.vue +15 -0
  124. opensignalbox/interface/web_ui/src/components/ui/popover/PopoverContent.vue +48 -0
  125. opensignalbox/interface/web_ui/src/components/ui/popover/PopoverTrigger.vue +11 -0
  126. opensignalbox/interface/web_ui/src/components/ui/popover/index.ts +3 -0
  127. opensignalbox/interface/web_ui/src/components/ui/radio-group/RadioGroup.vue +25 -0
  128. opensignalbox/interface/web_ui/src/components/ui/radio-group/RadioGroupItem.vue +39 -0
  129. opensignalbox/interface/web_ui/src/components/ui/radio-group/index.ts +2 -0
  130. opensignalbox/interface/web_ui/src/components/ui/scroll-area/ScrollArea.vue +29 -0
  131. opensignalbox/interface/web_ui/src/components/ui/scroll-area/ScrollBar.vue +30 -0
  132. opensignalbox/interface/web_ui/src/components/ui/scroll-area/index.ts +2 -0
  133. opensignalbox/interface/web_ui/src/components/ui/select/Select.vue +15 -0
  134. opensignalbox/interface/web_ui/src/components/ui/select/SelectContent.vue +53 -0
  135. opensignalbox/interface/web_ui/src/components/ui/select/SelectGroup.vue +19 -0
  136. opensignalbox/interface/web_ui/src/components/ui/select/SelectItem.vue +44 -0
  137. opensignalbox/interface/web_ui/src/components/ui/select/SelectItemText.vue +11 -0
  138. opensignalbox/interface/web_ui/src/components/ui/select/SelectLabel.vue +13 -0
  139. opensignalbox/interface/web_ui/src/components/ui/select/SelectScrollDownButton.vue +24 -0
  140. opensignalbox/interface/web_ui/src/components/ui/select/SelectScrollUpButton.vue +24 -0
  141. opensignalbox/interface/web_ui/src/components/ui/select/SelectSeparator.vue +17 -0
  142. opensignalbox/interface/web_ui/src/components/ui/select/SelectTrigger.vue +31 -0
  143. opensignalbox/interface/web_ui/src/components/ui/select/SelectValue.vue +11 -0
  144. opensignalbox/interface/web_ui/src/components/ui/select/index.ts +11 -0
  145. opensignalbox/interface/web_ui/src/components/ui/separator/Separator.vue +35 -0
  146. opensignalbox/interface/web_ui/src/components/ui/separator/index.ts +1 -0
  147. opensignalbox/interface/web_ui/src/components/ui/sheet/Sheet.vue +14 -0
  148. opensignalbox/interface/web_ui/src/components/ui/sheet/SheetClose.vue +11 -0
  149. opensignalbox/interface/web_ui/src/components/ui/sheet/SheetContent.vue +56 -0
  150. opensignalbox/interface/web_ui/src/components/ui/sheet/SheetDescription.vue +22 -0
  151. opensignalbox/interface/web_ui/src/components/ui/sheet/SheetFooter.vue +19 -0
  152. opensignalbox/interface/web_ui/src/components/ui/sheet/SheetHeader.vue +16 -0
  153. opensignalbox/interface/web_ui/src/components/ui/sheet/SheetTitle.vue +22 -0
  154. opensignalbox/interface/web_ui/src/components/ui/sheet/SheetTrigger.vue +11 -0
  155. opensignalbox/interface/web_ui/src/components/ui/sheet/index.ts +31 -0
  156. opensignalbox/interface/web_ui/src/components/ui/table/Table.vue +16 -0
  157. opensignalbox/interface/web_ui/src/components/ui/table/TableBody.vue +14 -0
  158. opensignalbox/interface/web_ui/src/components/ui/table/TableCaption.vue +14 -0
  159. opensignalbox/interface/web_ui/src/components/ui/table/TableCell.vue +21 -0
  160. opensignalbox/interface/web_ui/src/components/ui/table/TableEmpty.vue +37 -0
  161. opensignalbox/interface/web_ui/src/components/ui/table/TableFooter.vue +14 -0
  162. opensignalbox/interface/web_ui/src/components/ui/table/TableHead.vue +14 -0
  163. opensignalbox/interface/web_ui/src/components/ui/table/TableHeader.vue +14 -0
  164. opensignalbox/interface/web_ui/src/components/ui/table/TableRow.vue +14 -0
  165. opensignalbox/interface/web_ui/src/components/ui/table/index.ts +9 -0
  166. opensignalbox/interface/web_ui/src/components/ui/tabs/Tabs.vue +15 -0
  167. opensignalbox/interface/web_ui/src/components/ui/tabs/TabsContent.vue +22 -0
  168. opensignalbox/interface/web_ui/src/components/ui/tabs/TabsList.vue +25 -0
  169. opensignalbox/interface/web_ui/src/components/ui/tabs/TabsTrigger.vue +29 -0
  170. opensignalbox/interface/web_ui/src/components/ui/tabs/index.ts +4 -0
  171. opensignalbox/interface/web_ui/src/components/ui/tags-input/TagsInput.vue +22 -0
  172. opensignalbox/interface/web_ui/src/components/ui/tags-input/TagsInputInput.vue +19 -0
  173. opensignalbox/interface/web_ui/src/components/ui/tags-input/TagsInputItem.vue +22 -0
  174. opensignalbox/interface/web_ui/src/components/ui/tags-input/TagsInputItemDelete.vue +24 -0
  175. opensignalbox/interface/web_ui/src/components/ui/tags-input/TagsInputItemText.vue +19 -0
  176. opensignalbox/interface/web_ui/src/components/ui/tags-input/index.ts +5 -0
  177. opensignalbox/interface/web_ui/src/components/ui/textarea/Textarea.vue +24 -0
  178. opensignalbox/interface/web_ui/src/components/ui/textarea/index.ts +1 -0
  179. opensignalbox/interface/web_ui/src/components/ui/tooltip/Tooltip.vue +14 -0
  180. opensignalbox/interface/web_ui/src/components/ui/tooltip/TooltipContent.vue +31 -0
  181. opensignalbox/interface/web_ui/src/components/ui/tooltip/TooltipProvider.vue +11 -0
  182. opensignalbox/interface/web_ui/src/components/ui/tooltip/TooltipTrigger.vue +11 -0
  183. opensignalbox/interface/web_ui/src/components/ui/tooltip/index.ts +4 -0
  184. opensignalbox/interface/web_ui/src/libs/utils.ts +6 -0
  185. opensignalbox/interface/web_ui/src/main.ts +141 -0
  186. opensignalbox/interface/web_ui/src/views/Overview.vue +11 -0
  187. opensignalbox/interface/web_ui/src/views/Settings.vue +6 -0
  188. opensignalbox/interface/web_ui/src/views/SleafordEast.vue +205 -0
  189. opensignalbox/interface/web_ui/src/views/adapters/modbus/ModbusAdapterEdit.vue +343 -0
  190. opensignalbox/interface/web_ui/src/views/adapters/modbus/ModbusAdapterView.vue +270 -0
  191. opensignalbox/interface/web_ui/src/views/interfaces/InterfaceCreate.vue +0 -0
  192. opensignalbox/interface/web_ui/src/views/interfaces/basicio/BasicIOInterfaceEdit.vue +795 -0
  193. opensignalbox/interface/web_ui/src/views/interfaces/basicio/BasicIOInterfaceView.vue +648 -0
  194. opensignalbox/interface/web_ui/src/views/interfaces/bell/BellInterfaceEdit.vue +790 -0
  195. opensignalbox/interface/web_ui/src/views/interfaces/bell/BellInterfaceView.vue +437 -0
  196. opensignalbox/interface/web_ui/src/vite-env.d.ts +1 -0
  197. opensignalbox/interface/web_ui/tailwind.config.js +94 -0
  198. opensignalbox/interface/web_ui/tsconfig.app.json +27 -0
  199. opensignalbox/interface/web_ui/tsconfig.json +30 -0
  200. opensignalbox/interface/web_ui/tsconfig.node.json +12 -0
  201. opensignalbox/interface/web_ui/tsconfig.tsbuildinfo +1 -0
  202. opensignalbox/interface/web_ui/vite.config.d.ts +2 -0
  203. opensignalbox/interface/web_ui/vite.config.js +60 -0
  204. opensignalbox/interface/web_ui/vite.config.ts +62 -0
  205. opensignalbox_interface-0.1.0.dist-info/METADATA +49 -0
  206. opensignalbox_interface-0.1.0.dist-info/RECORD +209 -0
  207. opensignalbox_interface-0.1.0.dist-info/WHEEL +4 -0
  208. opensignalbox_interface-0.1.0.dist-info/entry_points.txt +2 -0
  209. opensignalbox_interface-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,802 @@
1
+ """
2
+ Handler for Bell interfaces.
3
+ """
4
+
5
+ import logging
6
+ from typing import Any, Dict, cast
7
+
8
+ from anyio import sleep
9
+ from fastapi import APIRouter, HTTPException, Request
10
+ from fastapi.responses import StreamingResponse
11
+ from opensignalbox.common.messaging import get_messager
12
+ from opensignalbox.common.sv_wire import decode_sv_message
13
+ from opensignalbox.interface.adapters.base import BaseAdapter
14
+ from opensignalbox.interface.interfaces.base import InterfaceHandler
15
+ from opensignalbox.interface.interfaces.basicio.models import IfaceChanTuple
16
+ from opensignalbox.interface.interfaces.bell.connections import (
17
+ BellConnection,
18
+ BellModbusConnection,
19
+ BellTestConnection,
20
+ )
21
+ from opensignalbox.interface.models import Interface
22
+
23
+ from .models import (
24
+ BellConnectionType,
25
+ BellForceMessage,
26
+ BellInterface,
27
+ BellInterfaceCreate,
28
+ BellInterfacePublic,
29
+ BellInterfaceUpdate,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+ messager = get_messager()
34
+
35
+ STATUS_UPDATE_PERIOD = 0.2 # seconds
36
+
37
+
38
+ class BellInterfaceHandler(InterfaceHandler):
39
+ """Handler for Bell interfaces."""
40
+
41
+ # Metadata for the API discovery
42
+ metadata = {
43
+ "supported_connection_types": ["BellModbusConnection", "BellTestConnection"],
44
+ "required_fields": ["system_name", "user_name", "interface_type"],
45
+ "optional_fields": ["description", "adapter_name", "enabled", "modbus_address"],
46
+ }
47
+
48
+ @classmethod
49
+ def get_router(cls) -> Any:
50
+ """Get API router for Bell interface endpoints.
51
+
52
+ Returns:
53
+ FastAPI router with Bell interface specific endpoints
54
+ """
55
+
56
+ router = APIRouter(tags=["interfaces/bell"])
57
+
58
+ @router.get("/{system_name}", response_model=BellInterfacePublic)
59
+ async def get_interface(system_name: str):
60
+ """Get a Bell interface by system name."""
61
+ if not cls.interface_controller:
62
+ raise HTTPException(
63
+ status_code=500, detail="Interface controller not initialized"
64
+ )
65
+
66
+ try:
67
+ interface = cls.interface_controller.get(system_name)
68
+ if interface.interface_type != "Bell":
69
+ raise HTTPException(
70
+ status_code=404,
71
+ detail=f"Bell interface '{system_name}' not found",
72
+ )
73
+ return BellInterfacePublic(**interface.model_dump())
74
+ except KeyError as exc:
75
+ raise HTTPException(
76
+ status_code=404, detail=f"Interface '{system_name}' not found"
77
+ ) from exc
78
+
79
+ @router.post("/")
80
+ async def create_interface(interface: BellInterfaceCreate):
81
+ """Create a new Bell interface."""
82
+ if not cls.interface_controller:
83
+ raise HTTPException(
84
+ status_code=500, detail="Interface controller not initialized"
85
+ )
86
+
87
+ if interface.interface_type != "Bell":
88
+ raise HTTPException(
89
+ status_code=400, detail="Interface type must be Bell"
90
+ )
91
+
92
+ try:
93
+ interface_model = BellInterface(**interface.model_dump())
94
+
95
+ # Add the interface
96
+ new_interface = cls.interface_controller.add(interface_model)
97
+ return new_interface
98
+ except FileExistsError as exc:
99
+ raise HTTPException(
100
+ status_code=400,
101
+ detail=f"Interface '{interface.system_name}' already exists",
102
+ ) from exc
103
+ except TypeError as exc:
104
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
105
+
106
+ @router.patch("/{system_name}")
107
+ async def update_interface(
108
+ system_name: str, interface_update: BellInterfaceUpdate
109
+ ):
110
+ """Update a Bell interface."""
111
+ if not cls.interface_controller:
112
+ raise HTTPException(
113
+ status_code=500, detail="Interface controller not initialized"
114
+ )
115
+
116
+ try:
117
+ # Check that the interface exists and is Bell type
118
+ interface = cls.interface_controller.get(system_name)
119
+ if interface.interface_type != "Bell":
120
+ raise HTTPException(
121
+ status_code=404,
122
+ detail=f"Bell interface '{system_name}' not found",
123
+ )
124
+
125
+ # Extract update fields
126
+ fields = {
127
+ k: v
128
+ for k, v in interface_update.model_dump().items()
129
+ if v is not None
130
+ }
131
+
132
+ # Update the interface
133
+ updated_interface = cls.interface_controller.update(system_name, fields)
134
+ return updated_interface
135
+ except KeyError as exc:
136
+ raise HTTPException(
137
+ status_code=404, detail=f"Interface '{system_name}' not found"
138
+ ) from exc
139
+
140
+ @router.delete("/{system_name}")
141
+ async def delete_interface(system_name: str):
142
+ """Delete a Bell interface."""
143
+ if not cls.interface_controller:
144
+ raise HTTPException(
145
+ status_code=500, detail="Interface controller not initialized"
146
+ )
147
+
148
+ try:
149
+ # Check that the interface exists and is Bell type
150
+ interface = cls.interface_controller.get(system_name)
151
+ if interface.interface_type != "Bell":
152
+ raise HTTPException(
153
+ status_code=404,
154
+ detail=f"Bell interface '{system_name}' not found",
155
+ )
156
+
157
+ cls.interface_controller.remove(system_name)
158
+ return {"status": "success"}
159
+ except KeyError as exc:
160
+ raise HTTPException(
161
+ status_code=404, detail=f"Interface '{system_name}' not found"
162
+ ) from exc
163
+
164
+ @router.post("/{system_name}/connect")
165
+ async def connect_interface(system_name: str):
166
+ """Connect to a Bell interface."""
167
+ if not cls.interface_controller:
168
+ raise HTTPException(
169
+ status_code=500, detail="Interface controller not initialized"
170
+ )
171
+
172
+ try:
173
+ interface = cls.interface_controller.get(system_name)
174
+ if interface.interface_type != "Bell":
175
+ raise HTTPException(
176
+ status_code=404,
177
+ detail=f"Bell interface '{system_name}' not found",
178
+ )
179
+
180
+ # Connect to the interface
181
+ success = cls.connect(interface)
182
+ if success:
183
+ return {"status": "success"}
184
+ else:
185
+ raise HTTPException(
186
+ status_code=500,
187
+ detail=f"Failed to connect to interface. Detail: {interface.error}",
188
+ )
189
+ except KeyError as exc:
190
+ raise HTTPException(
191
+ status_code=404, detail=f"Interface '{system_name}' not found"
192
+ ) from exc
193
+
194
+ @router.get("/{system_name}/channel-status")
195
+ async def status_stream(request: Request, system_name: str):
196
+ """Stream the channel status of a Bell interface."""
197
+ if not cls.interface_controller:
198
+ raise HTTPException(
199
+ status_code=500, detail="Interface controller not initialized"
200
+ )
201
+
202
+ try:
203
+
204
+ async def status_generator():
205
+ if not cls.interface_controller:
206
+ raise HTTPException(
207
+ status_code=500,
208
+ detail="Interface controller not initialized",
209
+ )
210
+ interface = cls.interface_controller.get(system_name)
211
+ if interface.interface_type != "Bell":
212
+ raise HTTPException(
213
+ status_code=404,
214
+ detail=f"Bell interface '{system_name}' not found",
215
+ )
216
+ while True:
217
+ if await request.is_disconnected():
218
+ break
219
+ # Use controller's get_status method
220
+ yield f"data: {cls.get_channel_status(interface)}\n\n"
221
+ await sleep(STATUS_UPDATE_PERIOD)
222
+
223
+ return StreamingResponse(
224
+ status_generator(), media_type="text/event-stream"
225
+ )
226
+
227
+ except KeyError as exc:
228
+ raise HTTPException(
229
+ status_code=404, detail=f"Interface '{system_name}' not found"
230
+ ) from exc
231
+
232
+ @router.put("/{system_name}/forcing")
233
+ async def set_forcing(system_name: str, force_message: BellForceMessage):
234
+ """Set forcing for a channel in a Bell interface."""
235
+ if not cls.interface_controller:
236
+ raise HTTPException(
237
+ status_code=500, detail="Interface controller not initialized"
238
+ )
239
+
240
+ try:
241
+ interface = cls.interface_controller.get(system_name)
242
+ if interface.interface_type != "Bell":
243
+ raise HTTPException(
244
+ status_code=404,
245
+ detail=f"Bell interface '{system_name}' not found",
246
+ )
247
+
248
+ cls.set_forcing(interface, force_message.channel, force_message.state)
249
+ return {"status": "success"}
250
+ except KeyError as exc:
251
+ raise HTTPException(
252
+ status_code=404, detail=f"Interface '{system_name}' not found"
253
+ ) from exc
254
+
255
+ @router.put("/{system_name}/send-code")
256
+ async def send_code(system_name: str, request: Request):
257
+ """Send a code to a Bell interface."""
258
+ if not cls.interface_controller:
259
+ raise HTTPException(
260
+ status_code=500, detail="Interface controller not initialized"
261
+ )
262
+
263
+ try:
264
+ interface = cls.interface_controller.get(system_name)
265
+ if interface.interface_type != "Bell":
266
+ raise HTTPException(
267
+ status_code=404,
268
+ detail=f"Bell interface '{system_name}' not found",
269
+ )
270
+
271
+ data = await request.json()
272
+ if "code" not in data or "channel" not in data:
273
+ raise HTTPException(
274
+ status_code=400,
275
+ detail="Missing 'code' or 'channel' in request body",
276
+ )
277
+
278
+ code = data["code"]
279
+ channel = int(data["channel"])
280
+
281
+ # Validate channel
282
+ if channel < 1 or channel > 4:
283
+ raise HTTPException(
284
+ status_code=400, detail="Channel must be between 1 and 4"
285
+ )
286
+
287
+ # Send the code
288
+ cls.send_code(interface, code, channel)
289
+ return {"status": "success"}
290
+ except KeyError as exc:
291
+ raise HTTPException(
292
+ status_code=404, detail=f"Interface '{system_name}' not found"
293
+ ) from exc
294
+
295
+ @router.put("/{system_name}/forceReceiveCode")
296
+ async def force_receive_code(system_name: str, request: Request):
297
+ """Force receiving a code on a Bell interface."""
298
+ if not cls.interface_controller:
299
+ raise HTTPException(
300
+ status_code=500, detail="Interface controller not initialized"
301
+ )
302
+
303
+ try:
304
+ interface = cls.interface_controller.get(system_name)
305
+ if interface.interface_type != "Bell":
306
+ raise HTTPException(
307
+ status_code=404,
308
+ detail=f"Bell interface '{system_name}' not found",
309
+ )
310
+
311
+ data = await request.json()
312
+ if "code" not in data or "channel" not in data:
313
+ raise HTTPException(
314
+ status_code=400,
315
+ detail="Missing 'code' or 'channel' in request body",
316
+ )
317
+
318
+ code = data["code"]
319
+ channel = int(data["channel"])
320
+
321
+ # Validate channel
322
+ if channel < 1 or channel > 4:
323
+ raise HTTPException(
324
+ status_code=400, detail="Channel must be between 1 and 4"
325
+ )
326
+
327
+ # Force receiving the code
328
+ cls.force_receive_code(interface, code, channel)
329
+ return {"status": "success"}
330
+ except KeyError as exc:
331
+ raise HTTPException(
332
+ status_code=404, detail=f"Interface '{system_name}' not found"
333
+ ) from exc
334
+
335
+ return router
336
+
337
+ @staticmethod
338
+ def add(interface: Interface) -> Interface:
339
+ """
340
+ Initialize a BellInterface from an Interface.
341
+
342
+ Args:
343
+ interface: The interface to initialize. Must be a BellInterface or
344
+ convertible to a BellInterface.
345
+
346
+ Returns:
347
+ The initialized BellInterface.
348
+
349
+ Raises:
350
+ TypeError: If the interface cannot be converted to a BellInterface.
351
+ """
352
+
353
+ bell_interface = cast(BellInterface, interface)
354
+
355
+ for channel_index, channel in bell_interface.channels.items():
356
+ channel.in_shared_variable = (
357
+ BellInterfaceHandler.output_variables.new_from_bellcode(
358
+ system_name=channel.system_name,
359
+ user_name=channel.user_name,
360
+ description=channel.description,
361
+ tags=channel.tags,
362
+ count=channel.in_count,
363
+ code=channel.in_code,
364
+ )
365
+ )
366
+
367
+ channel_var = channel.out_shared_variable_name
368
+ if channel_var:
369
+ if channel_var not in BellInterfaceHandler.input_variable_subs:
370
+ BellInterfaceHandler.input_variable_subs[channel_var] = []
371
+ BellInterfaceHandler.input_variable_subs[channel_var].append(
372
+ IfaceChanTuple(bell_interface.system_name, channel_index)
373
+ )
374
+
375
+ # Create a closure that captures the interface reference
376
+ def create_variable_callback(iface):
377
+ def callback(var: str, json_data: str):
378
+ # Forward to controller's callback which has access to all interfaces
379
+ if BellInterfaceHandler.variable_update_callback:
380
+ BellInterfaceHandler.variable_update_callback(
381
+ var, json_data
382
+ )
383
+
384
+ return callback
385
+
386
+ # Subscribe with the closure
387
+ messager.sub(channel_var, create_variable_callback(bell_interface))
388
+
389
+ return bell_interface
390
+
391
+ @staticmethod
392
+ def update(interface: Interface, fields: Dict[str, Any]) -> None:
393
+ """
394
+ Update a BellInterface.
395
+
396
+ Args:
397
+ interface: The interface to update. Must be a BellInterface or convertible to one.
398
+ fields: The fields to update.
399
+
400
+ Raises:
401
+ TypeError: If the interface cannot be converted to a BellInterface.
402
+ """
403
+
404
+ bell_interface = cast(BellInterface, interface)
405
+
406
+ # Handle basic field updates
407
+ for field, value in fields.items():
408
+ if hasattr(bell_interface, field) and value is not None:
409
+ setattr(bell_interface, field, value)
410
+
411
+ # TODO handle variable updates
412
+
413
+ @staticmethod
414
+ def remove(interface: Interface) -> None:
415
+ """
416
+ Remove a BellInterface.
417
+
418
+ Args:
419
+ interface: The interface to remove.
420
+ """
421
+
422
+ # TODO remove variable subscriptions
423
+
424
+ return
425
+
426
+ @staticmethod
427
+ def handle_variable_update(
428
+ interface: Interface, variable: str, json_data: str
429
+ ) -> None:
430
+ """
431
+ Handle a variable update for a BellInterface.
432
+
433
+ Args:
434
+ interface: The interface to handle the update for.
435
+ variable: The name of the variable that was updated.
436
+ json_data: The JSON data of the updated variable.
437
+ """
438
+
439
+ bell_interface = cast(BellInterface, interface)
440
+
441
+ # logger.debug(f"Handling variable update for {variable} with data: {json_data}")
442
+
443
+ # Find channels subscribed to this variable
444
+ channels = []
445
+ if variable in BellInterfaceHandler.input_variable_subs:
446
+ for item in BellInterfaceHandler.input_variable_subs[variable]:
447
+ if item.interface == bell_interface.system_name:
448
+ channels.append(item.channel)
449
+
450
+ # logger.debug(f"Channels subscribed to variable '{variable}': {channels}")
451
+
452
+ # No channels to update
453
+ if not channels:
454
+ return
455
+
456
+ # Parse the variable data
457
+ try:
458
+ data = decode_sv_message(json_data).data
459
+ code = data.get("code", "")
460
+
461
+ # Update each channel
462
+ for channel in channels:
463
+ BellInterfaceHandler.send_code(bell_interface, code, channel)
464
+
465
+ except Exception as e:
466
+ logger.error(f"Error parsing variable data: {e}")
467
+
468
+ @staticmethod
469
+ def connect(interface: Interface) -> bool:
470
+ """
471
+ Connect to a BellInterface using the specified adapter.
472
+
473
+ Args:
474
+ interface: The interface to connect.
475
+ adapter: The adapter to use for communication.
476
+
477
+ Returns:
478
+ True if connection was successful, False otherwise.
479
+ """
480
+
481
+ bell_interface = cast(BellInterface, interface)
482
+
483
+ # Check if the interface is enabled
484
+ if not bell_interface.enabled:
485
+ return False
486
+
487
+ # Get the appropriate connection strategy based on connection type
488
+ connected = False
489
+ match bell_interface.connection_type:
490
+ case BellConnectionType.TEST:
491
+ connected = BellTestConnection.connect(bell_interface)
492
+ case BellConnectionType.MODBUS_ADAPTER:
493
+ if BellInterfaceHandler.interface_controller:
494
+ adapter = BellInterfaceHandler.interface_controller.get_adapter_for_interface(
495
+ interface
496
+ )
497
+ if not adapter or not adapter.enabled:
498
+ return False # No adapter configured or adapter is disabled
499
+ connected = BellModbusConnection.connect(bell_interface, adapter)
500
+ # if connected:
501
+ # for channel in bell_interface.channels:
502
+ # in_data = BellModbusConnection.receive_code(bell_interface, channel, adapter)
503
+ # if in_data and len(in_data) >= 2:
504
+ # bell_interface.channels[channel].in_count = (in_data[0] << 8) | in_data[1]
505
+ # out_data = BellModbusConnection.get_sent_code(bell_interface, channel, adapter)
506
+ # if out_data and len(out_data) >= 2:
507
+ # bell_interface.channels[channel].out_count = (out_data[0] << 8) | out_data[1]
508
+ case _:
509
+ logger.error(
510
+ f"Unsupported connection type: {bell_interface.connection_type}"
511
+ )
512
+ interface.error = (
513
+ f"Unsupported connection type: {bell_interface.connection_type}"
514
+ )
515
+ interface.enabled = False
516
+ return False
517
+ return connected
518
+
519
+ @staticmethod
520
+ def disconnect(interface: Interface) -> None:
521
+ """
522
+ Disconnect from a BellInterface.
523
+
524
+ Args:
525
+ interface: The interface to disconnect.
526
+
527
+ Returns:
528
+ True if disconnection was successful, False otherwise.
529
+ """
530
+
531
+ bell_interface = cast(BellInterface, interface)
532
+
533
+ # Get the appropriate connection strategy based on connection type
534
+ match bell_interface.connection_type:
535
+ case BellConnectionType.TEST:
536
+ BellTestConnection.disconnect(bell_interface)
537
+ case BellConnectionType.MODBUS_ADAPTER:
538
+ if BellInterfaceHandler.interface_controller:
539
+ adapter = BellInterfaceHandler.interface_controller.get_adapter_for_interface(
540
+ interface
541
+ )
542
+ if not adapter:
543
+ return
544
+ BellModbusConnection.disconnect(bell_interface, adapter)
545
+ case _:
546
+ logger.error(
547
+ f"Unsupported connection type: {bell_interface.connection_type}"
548
+ )
549
+ interface.error = (
550
+ f"Unsupported connection type: {bell_interface.connection_type}"
551
+ )
552
+ interface.enabled = False
553
+ return
554
+
555
+ @staticmethod
556
+ def read_data(interface: Interface, adapter: BaseAdapter | None = None) -> None:
557
+ """
558
+ Read data from the adapter for a BellInterface.
559
+
560
+ Args:
561
+ interface: The interface to read data for.
562
+ adapter: The adapter to read from.
563
+ """
564
+
565
+ bell_interface = cast(BellInterface, interface)
566
+
567
+ # Get the appropriate connection strategy based on connection type
568
+ connection: type[BellConnection]
569
+ match bell_interface.connection_type:
570
+ case BellConnectionType.TEST:
571
+ connection = BellTestConnection
572
+ case BellConnectionType.MODBUS_ADAPTER:
573
+ connection = BellModbusConnection
574
+ if BellInterfaceHandler.interface_controller:
575
+ adapter = BellInterfaceHandler.interface_controller.get_adapter_for_interface(
576
+ interface
577
+ )
578
+ case _:
579
+ logger.error(
580
+ f"Unsupported connection type: {bell_interface.connection_type}"
581
+ )
582
+ interface.error = (
583
+ f"Unsupported connection type: {bell_interface.connection_type}"
584
+ )
585
+ interface.enabled = False
586
+ return
587
+
588
+ # Read from the hardware
589
+ try:
590
+ counts = [0, 0, 0, 0]
591
+ codes = ["", "", "", ""]
592
+
593
+ # Read the words from the adapter (starting at the configured address)
594
+ data = connection.receive_all_codes(bell_interface, adapter=adapter)
595
+
596
+ if not data:
597
+ logger.error(
598
+ f"No data read from adapter for interface '{bell_interface.system_name}'"
599
+ )
600
+ return
601
+
602
+ # Convert data to integer array
603
+ words = []
604
+ for i in range(0, len(data), 2):
605
+ if i + 1 < len(data):
606
+ word = (data[i] << 8) | data[i + 1]
607
+ words.append(word)
608
+
609
+ for channel in range(0, 4):
610
+ counts[channel] = words[channel * 9]
611
+ # Form code from the next four bytes as ints separated by commas
612
+ codes[channel] = (
613
+ f"{','.join([str(words[i]) for i in range((channel * 9 + 1), (channel * 9 + 4)) if words[i] != 0])}"
614
+ )
615
+ # Update the interface fields
616
+ for index, channel in bell_interface.channels.items():
617
+ channel.in_count = counts[index - 1]
618
+ channel.in_code = codes[index - 1]
619
+ if channel.in_shared_variable is not None:
620
+ channel.in_shared_variable.update(
621
+ {
622
+ "count": channel.in_count,
623
+ "code": channel.in_code,
624
+ }
625
+ )
626
+
627
+ except Exception as e:
628
+ logger.error(
629
+ f"Error reading data for interface '{bell_interface.system_name}': {e}"
630
+ )
631
+ interface.error = str(e)
632
+
633
+ @staticmethod
634
+ def write_data(interface: Interface, adapter: BaseAdapter | None = None) -> None:
635
+ """
636
+ Write data to the adapter for a BellInterface.
637
+
638
+ Args:
639
+ interface: The interface to write data for.
640
+ adapter: The adapter to write to.
641
+ """
642
+
643
+ bell_interface = cast(BellInterface, interface)
644
+
645
+ # Get the appropriate connection strategy based on connection type
646
+ connection: type[BellConnection]
647
+ match bell_interface.connection_type:
648
+ case BellConnectionType.TEST:
649
+ connection = BellTestConnection
650
+ case BellConnectionType.MODBUS_ADAPTER:
651
+ connection = BellModbusConnection
652
+ if BellInterfaceHandler.interface_controller:
653
+ adapter = BellInterfaceHandler.interface_controller.get_adapter_for_interface(
654
+ interface
655
+ )
656
+ case _:
657
+ logger.error(
658
+ f"Unsupported connection type: {bell_interface.connection_type}"
659
+ )
660
+ interface.error = (
661
+ f"Unsupported connection type: {bell_interface.connection_type}"
662
+ )
663
+ interface.enabled = False
664
+ return
665
+
666
+ for channel in range(1, 5):
667
+ # Prepare data to send
668
+ out_count = bell_interface.channels[channel].out_count
669
+ last_out_count = bell_interface.channels[channel].last_out_count
670
+ if out_count == last_out_count:
671
+ continue
672
+ bell_interface.channels[channel].last_out_count = out_count
673
+ out_code = bell_interface.channels[channel].out_code
674
+
675
+ words = [0] * 8 # 1 count + 7 codes
676
+ words[0] = out_count if out_count is not None else 0
677
+ for i in range(1, 8):
678
+ if i - 1 < len(out_code.split(",")):
679
+ try:
680
+ words[i] = int(out_code.split(",")[i - 1])
681
+ except ValueError:
682
+ words[i] = 0
683
+
684
+ # Convert words to byte array
685
+ data = bytearray()
686
+ for word in words:
687
+ data.append((word >> 8) & 0xFF) # High byte
688
+ data.append(word & 0xFF) # Low byte
689
+
690
+ # Send data to the adapter
691
+ try:
692
+ connection.send_code(bell_interface, channel, data, adapter=adapter)
693
+ # logger.debug(f"Sent data for interface '{bell_interface.system_name}': channel {channel} data {data.hex()}")
694
+ except Exception as e:
695
+ logger.error(
696
+ f"Error writing data for interface '{bell_interface.system_name}': {e}"
697
+ )
698
+ interface.error = str(e)
699
+
700
+ return
701
+
702
+ @staticmethod
703
+ def get_channel_status(interface: Interface):
704
+ """
705
+ Get the channel status of a BellInterface.
706
+
707
+ Args:
708
+ interface: The interface to get the status of.
709
+
710
+ Returns:
711
+ A string describing the current status.
712
+ """
713
+
714
+ bell_interface = cast(BellInterface, interface)
715
+
716
+ return bell_interface.model_dump_json(
717
+ include={"channels"},
718
+ by_alias=True,
719
+ )
720
+
721
+ @staticmethod
722
+ def set_forcing(interface: Interface, channel_index: int, state: str) -> None:
723
+ """
724
+ Set forcing for a channel in a BellInterface.
725
+
726
+ Args:
727
+ interface: The interface to set forcing for.
728
+ channel: The channel number.
729
+ is_input: Whether the channel is an input.
730
+ enabled: Whether forcing is enabled.
731
+ value: The value to force.
732
+ """
733
+ if state not in ["on", "off", "none"]:
734
+ raise ValueError("Invalid state. Must be 'on', 'off', or 'none'.")
735
+ if channel_index is None:
736
+ raise ValueError("Channel must be specified.")
737
+
738
+ # Set forcing for the specified channel
739
+ return
740
+
741
+ @staticmethod
742
+ def send_code(interface: Interface, code: str, channel: int) -> None:
743
+ """
744
+ Send a code to a specific channel of a BellInterface.
745
+
746
+ Args:
747
+ interface: The interface to send the code to.
748
+ code: The code to send.
749
+ channel: The channel number (1-4).
750
+ """
751
+ bell_interface = cast(BellInterface, interface)
752
+
753
+ if channel in bell_interface.channels:
754
+ bell_interface.channels[channel].out_code = code
755
+ bell_interface.channels[channel].out_count = (
756
+ bell_interface.channels[channel].out_count or 0
757
+ ) + 1
758
+
759
+ return
760
+
761
+ @staticmethod
762
+ def force_receive_code(interface: Interface, code: str, channel: int) -> None:
763
+ """
764
+ Force receiving a code on a specific channel of a BellInterface.
765
+
766
+ Args:
767
+ interface: The interface to receive the code on.
768
+ code: The code to receive.
769
+ channel: The channel number (1-4).
770
+ """
771
+ bell_interface = cast(BellInterface, interface)
772
+
773
+ if channel in bell_interface.channels:
774
+ bell_interface.channels[channel].in_code = code
775
+ bell_interface.channels[channel].in_count = (
776
+ bell_interface.channels[channel].in_count or 0
777
+ ) + 2 # So we don't miss a subsequent real input
778
+ var = bell_interface.channels[channel].in_shared_variable
779
+ if var is not None:
780
+ var.update(
781
+ {
782
+ "count": bell_interface.channels[channel].in_count,
783
+ "code": bell_interface.channels[channel].in_code,
784
+ }
785
+ )
786
+
787
+ @classmethod
788
+ def load_from_json(cls, interface_data: Any) -> None:
789
+ # Convert channel keys to integers
790
+ interface_data["channels"] = {
791
+ int(k): v for k, v in interface_data["channels"].items()
792
+ }
793
+ bell_interface = BellInterface.model_validate(interface_data)
794
+ if cls.interface_controller is None:
795
+ raise ValueError(
796
+ "Interface controller not set. Call set_controller() first."
797
+ )
798
+ cls.interface_controller.add(bell_interface)
799
+
800
+ @staticmethod
801
+ def save_to_json(interface: Interface) -> str:
802
+ return interface.model_dump_json()