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,1096 @@
1
+ """
2
+ Handler for Basic IO interfaces.
3
+ """
4
+
5
+ import logging
6
+ from math import ceil
7
+ from typing import Any, Dict, cast
8
+
9
+ from anyio import sleep
10
+ from fastapi import APIRouter, HTTPException, Request
11
+ from fastapi.responses import StreamingResponse
12
+ from opensignalbox.common.messaging import get_messager
13
+ from opensignalbox.common.sv_wire import decode_sv_message
14
+ from opensignalbox.interface.adapters.base import BaseAdapter
15
+ from opensignalbox.interface.interfaces.base import InterfaceHandler
16
+ from opensignalbox.interface.interfaces.basicio.connections import (
17
+ BasicIOConnection,
18
+ BasicIOModbusConnection,
19
+ BasicIOTestConnection,
20
+ )
21
+ from opensignalbox.interface.models import Interface
22
+ from opensignalbox.interface.utils import swap_bytes
23
+
24
+ from .models import (
25
+ BasicIOConnectionType,
26
+ BasicIOForceMessage,
27
+ BasicIOInterface,
28
+ BasicIOInterfaceCreate,
29
+ BasicIOInterfacePublic,
30
+ BasicIOInterfaceUpdate,
31
+ IfaceChanTuple,
32
+ InputChannel,
33
+ InputChannelCreate,
34
+ OutputChannel,
35
+ OutputChannelCreate,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+ messager = get_messager()
40
+
41
+ STATUS_UPDATE_PERIOD = 0.2 # seconds
42
+
43
+
44
+ class BasicIOInterfaceHandler(InterfaceHandler):
45
+ """Handler for Basic IO interfaces."""
46
+
47
+ # Metadata for the API discovery
48
+ metadata = {
49
+ "supported_connection_types": [
50
+ "BasicIOModbusConnection",
51
+ "BasicIOTestConnection",
52
+ ],
53
+ "required_fields": ["system_name", "user_name", "interface_type"],
54
+ "optional_fields": [
55
+ "description",
56
+ "adapter_name",
57
+ "enabled",
58
+ "modbus_address",
59
+ "input_start_address",
60
+ "output_start_address",
61
+ "input_bits",
62
+ "output_bits",
63
+ ],
64
+ }
65
+
66
+ @classmethod
67
+ def get_router(cls) -> Any:
68
+ """Get API router for Basic IO interface endpoints.
69
+
70
+ Returns:
71
+ FastAPI router with Basic IO interface specific endpoints
72
+ """
73
+
74
+ router = APIRouter(tags=["interfaces/basicio"])
75
+
76
+ @router.get("/{system_name}")
77
+ async def get_interface(system_name: str):
78
+ """Get a BasicIO interface by system name."""
79
+ if not cls.interface_controller:
80
+ raise HTTPException(
81
+ status_code=500, detail="Interface controller not initialized"
82
+ )
83
+
84
+ try:
85
+ interface = cls.interface_controller.get(system_name)
86
+ if interface.interface_type != "BasicIO":
87
+ raise HTTPException(
88
+ status_code=404,
89
+ detail=f"BasicIO interface '{system_name}' not found",
90
+ )
91
+ return BasicIOInterfacePublic(
92
+ **interface.model_dump(),
93
+ input_map=[
94
+ InputChannelCreate(**input.model_dump(), channel=channel)
95
+ for channel, input in interface.input_channels.items()
96
+ ],
97
+ output_map=[
98
+ OutputChannelCreate(**output.model_dump(), channel=channel)
99
+ for channel, output in interface.output_channels.items()
100
+ ],
101
+ )
102
+ except KeyError as exc:
103
+ raise HTTPException(
104
+ status_code=404, detail=f"Interface '{system_name}' not found"
105
+ ) from exc
106
+
107
+ @router.post("/")
108
+ async def create_interface(interface: BasicIOInterfaceCreate):
109
+ """Create a new Basic IO interface."""
110
+ if not cls.interface_controller:
111
+ raise HTTPException(
112
+ status_code=500, detail="Interface controller not initialized"
113
+ )
114
+
115
+ if interface.interface_type != "BasicIO":
116
+ raise HTTPException(
117
+ status_code=400, detail="Interface type must be BasicIO"
118
+ )
119
+
120
+ try:
121
+ interface_model = BasicIOInterface(**interface.model_dump())
122
+
123
+ # Add the interface
124
+ new_interface = cls.interface_controller.add(interface_model)
125
+ return new_interface
126
+ except FileExistsError as exc:
127
+ raise HTTPException(
128
+ status_code=400,
129
+ detail=f"Interface '{interface.system_name}' already exists",
130
+ ) from exc
131
+ except TypeError as exc:
132
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
133
+
134
+ @router.patch("/{system_name}")
135
+ async def update_interface(
136
+ system_name: str, interface_update: BasicIOInterfaceUpdate
137
+ ):
138
+ """Update a Basic IO interface."""
139
+ if not cls.interface_controller:
140
+ raise HTTPException(
141
+ status_code=500, detail="Interface controller not initialized"
142
+ )
143
+
144
+ try:
145
+ # Check that the interface exists and is Basic IO type
146
+ interface = cls.interface_controller.get(system_name)
147
+ if interface.interface_type != "BasicIO":
148
+ raise HTTPException(
149
+ status_code=404,
150
+ detail=f"Basic IO interface '{system_name}' not found",
151
+ )
152
+
153
+ # Extract update fields
154
+ fields = {
155
+ k: v
156
+ for k, v in interface_update.model_dump().items()
157
+ if v is not None
158
+ }
159
+
160
+ # Update the interface
161
+ updated_interface = cls.interface_controller.update(system_name, fields)
162
+ return updated_interface
163
+ except KeyError as exc:
164
+ raise HTTPException(
165
+ status_code=404, detail=f"Interface '{system_name}' not found"
166
+ ) from exc
167
+
168
+ @router.delete("/{system_name}")
169
+ async def delete_interface(system_name: str):
170
+ """Delete a Basic IO interface."""
171
+ if not cls.interface_controller:
172
+ raise HTTPException(
173
+ status_code=500, detail="Interface controller not initialized"
174
+ )
175
+
176
+ try:
177
+ # Check that the interface exists and is Basic IO type
178
+ interface = cls.interface_controller.get(system_name)
179
+ if interface.interface_type != "BasicIO":
180
+ raise HTTPException(
181
+ status_code=404,
182
+ detail=f"Basic IO interface '{system_name}' not found",
183
+ )
184
+
185
+ cls.interface_controller.remove(system_name)
186
+ return {"status": "success"}
187
+ except KeyError as exc:
188
+ raise HTTPException(
189
+ status_code=404, detail=f"Interface '{system_name}' not found"
190
+ ) from exc
191
+
192
+ @router.post("/{system_name}/connect")
193
+ async def connect_interface(system_name: str):
194
+ """Connect to a Basic IO interface."""
195
+ if not cls.interface_controller:
196
+ raise HTTPException(
197
+ status_code=500, detail="Interface controller not initialized"
198
+ )
199
+
200
+ try:
201
+ interface = cls.interface_controller.get(system_name)
202
+ if interface.interface_type != "BasicIO":
203
+ raise HTTPException(
204
+ status_code=404,
205
+ detail=f"Basic IO interface '{system_name}' not found",
206
+ )
207
+
208
+ # Connect to the interface
209
+ success = cls.connect(interface)
210
+ if success:
211
+ return {"status": "success"}
212
+ else:
213
+ raise HTTPException(
214
+ status_code=500,
215
+ detail=f"Failed to connect to interface. Detail: {interface.error}",
216
+ )
217
+ except KeyError as exc:
218
+ raise HTTPException(
219
+ status_code=404, detail=f"Interface '{system_name}' not found"
220
+ ) from exc
221
+
222
+ @router.get("/{system_name}/channel-status")
223
+ async def status_stream(request: Request, system_name: str):
224
+ """Stream the channel status of a Basic IO interface."""
225
+ if not cls.interface_controller:
226
+ raise HTTPException(
227
+ status_code=500, detail="Interface controller not initialized"
228
+ )
229
+
230
+ try:
231
+
232
+ async def status_generator():
233
+ if not cls.interface_controller:
234
+ raise HTTPException(
235
+ status_code=500,
236
+ detail="Interface controller not initialized",
237
+ )
238
+ interface = cls.interface_controller.get(system_name)
239
+ if interface.interface_type != "BasicIO":
240
+ raise HTTPException(
241
+ status_code=404,
242
+ detail=f"Basic IO interface '{system_name}' not found",
243
+ )
244
+ while True:
245
+ if await request.is_disconnected():
246
+ break
247
+ # Use controller's get_status method
248
+ yield f"data: {cls.get_channel_status(interface)}\n\n"
249
+ await sleep(STATUS_UPDATE_PERIOD)
250
+
251
+ return StreamingResponse(
252
+ status_generator(), media_type="text/event-stream"
253
+ )
254
+
255
+ except KeyError as exc:
256
+ raise HTTPException(
257
+ status_code=404, detail=f"Interface '{system_name}' not found"
258
+ ) from exc
259
+
260
+ @router.put("/{system_name}/forcing")
261
+ async def set_forcing(system_name: str, force_message: BasicIOForceMessage):
262
+ """Set forcing for a channel in a Basic IO interface."""
263
+ if not cls.interface_controller:
264
+ raise HTTPException(
265
+ status_code=500, detail="Interface controller not initialized"
266
+ )
267
+
268
+ try:
269
+ interface = cls.interface_controller.get(system_name)
270
+ if interface.interface_type != "BasicIO":
271
+ raise HTTPException(
272
+ status_code=404,
273
+ detail=f"Basic IO interface '{system_name}' not found",
274
+ )
275
+
276
+ cls.set_forcing(
277
+ interface,
278
+ force_message.channel,
279
+ force_message.iotype,
280
+ force_message.state,
281
+ )
282
+ return {"status": "success"}
283
+ except KeyError as exc:
284
+ raise HTTPException(
285
+ status_code=404, detail=f"Interface '{system_name}' not found"
286
+ ) from exc
287
+
288
+ return router
289
+
290
+ @staticmethod
291
+ def add(interface: Interface) -> Interface:
292
+ """
293
+ Initialize a BasicIOInterface from an Interface.
294
+
295
+ Args:
296
+ interface: The interface to initialize. Must be a BasicIOInterface or
297
+ convertible to a BasicIOInterface.
298
+
299
+ Returns:
300
+ The initialized BasicIOInterface.
301
+
302
+ Raises:
303
+ TypeError: If the interface cannot be converted to a BasicIOInterface.
304
+ """
305
+
306
+ basicio_interface = cast(BasicIOInterface, interface)
307
+
308
+ # Initialize channels
309
+ for input in basicio_interface.input_channels.values():
310
+ input.shared_variable = (
311
+ BasicIOInterfaceHandler.output_variables.new_from_logic(
312
+ system_name=input.system_name,
313
+ user_name=input.user_name,
314
+ description=input.description,
315
+ tags=input.tags,
316
+ value=input.default_value,
317
+ )
318
+ )
319
+
320
+ # Create channels for all configured bits
321
+ for channel in range(1, basicio_interface.input_bits + 1):
322
+ if channel not in basicio_interface.input_channels:
323
+ basicio_interface.input_channels[channel] = InputChannel(
324
+ system_name="",
325
+ user_name="",
326
+ description="",
327
+ tags=[],
328
+ default_value=False,
329
+ )
330
+
331
+ # Set up variable subscriptions
332
+ if BasicIOInterfaceHandler.input_variable_subs is not None:
333
+ for channel, output in basicio_interface.output_channels.items():
334
+ if output.shared_variable_name:
335
+ if (
336
+ output.shared_variable_name
337
+ not in BasicIOInterfaceHandler.input_variable_subs
338
+ ):
339
+ BasicIOInterfaceHandler.input_variable_subs[
340
+ output.shared_variable_name
341
+ ] = []
342
+ BasicIOInterfaceHandler.input_variable_subs[
343
+ output.shared_variable_name
344
+ ].append(IfaceChanTuple(basicio_interface.system_name, channel))
345
+
346
+ # Create a closure that captures the interface reference
347
+ def create_variable_callback(iface):
348
+ def callback(var: str, json_data: str):
349
+ # Forward to controller's callback which has access to all interfaces
350
+ if BasicIOInterfaceHandler.variable_update_callback:
351
+ BasicIOInterfaceHandler.variable_update_callback(
352
+ var, json_data
353
+ )
354
+
355
+ return callback
356
+
357
+ # Subscribe with the closure
358
+ messager.sub(
359
+ output.shared_variable_name,
360
+ create_variable_callback(basicio_interface),
361
+ )
362
+
363
+ # Ensure all output channels exist
364
+ for channel in range(1, basicio_interface.output_bits + 1):
365
+ if channel not in basicio_interface.output_channels:
366
+ basicio_interface.output_channels[channel] = OutputChannel(
367
+ shared_variable_name=None
368
+ )
369
+
370
+ return basicio_interface
371
+
372
+ @staticmethod
373
+ def update(interface: Interface, fields: Dict[str, Any]) -> None:
374
+ """
375
+ Update a BasicIOInterface.
376
+
377
+ Args:
378
+ interface: The interface to update. Must be a BasicIOInterface or convertible to one.
379
+ fields: The fields to update.
380
+
381
+ Raises:
382
+ TypeError: If the interface cannot be converted to a BasicIOInterface.
383
+ """
384
+
385
+ basicio_interface = cast(BasicIOInterface, interface)
386
+
387
+ # Handle basic field updates
388
+ for field, value in fields.items():
389
+ if hasattr(basicio_interface, field) and value is not None:
390
+ setattr(basicio_interface, field, value)
391
+
392
+ # Clear input channels above input_bits limit
393
+ if "input_bits" in fields:
394
+ input_bits = fields["input_bits"]
395
+ channels_to_remove = [
396
+ ch for ch in basicio_interface.input_channels.keys() if ch > input_bits
397
+ ]
398
+ for channel_index in channels_to_remove:
399
+ # Clean up shared variable
400
+ channel = basicio_interface.input_channels[channel_index]
401
+ if channel.shared_variable:
402
+ BasicIOInterfaceHandler.output_variables.remove(
403
+ channel.shared_variable.system_name
404
+ )
405
+ del basicio_interface.input_channels[channel_index]
406
+
407
+ # Clear output channels above output_bits limit
408
+ if "output_bits" in fields:
409
+ output_bits = fields["output_bits"]
410
+ channels_to_remove = [
411
+ ch
412
+ for ch in basicio_interface.output_channels.keys()
413
+ if ch > output_bits
414
+ ]
415
+ for channel_index in channels_to_remove:
416
+ # Clean up variable subscription
417
+ if basicio_interface.output_channels[
418
+ channel_index
419
+ ].shared_variable_name:
420
+ old_variable_name = basicio_interface.output_channels[
421
+ channel_index
422
+ ].shared_variable_name
423
+ if old_variable_name in BasicIOInterfaceHandler.input_variable_subs:
424
+ BasicIOInterfaceHandler.input_variable_subs[
425
+ old_variable_name
426
+ ] = [
427
+ item
428
+ for item in BasicIOInterfaceHandler.input_variable_subs[
429
+ old_variable_name
430
+ ]
431
+ if not (
432
+ item.interface == basicio_interface.system_name
433
+ and item.channel == channel_index
434
+ )
435
+ ]
436
+ # Clean up empty lists
437
+ if not BasicIOInterfaceHandler.input_variable_subs[
438
+ old_variable_name
439
+ ]:
440
+ del BasicIOInterfaceHandler.input_variable_subs[
441
+ old_variable_name
442
+ ]
443
+ del basicio_interface.output_channels[channel_index]
444
+
445
+ # Handle input channel updates (if provided)
446
+ input_map = fields.get("input_map")
447
+ if input_map is not None:
448
+ # Get list of channels in the new map
449
+ new_channels = {
450
+ channel_data.get("channel")
451
+ for channel_data in input_map
452
+ if channel_data.get("channel") is not None
453
+ }
454
+
455
+ # Remove channels not in the new map
456
+ channels_to_remove = [
457
+ ch
458
+ for ch in basicio_interface.input_channels.keys()
459
+ if ch not in new_channels
460
+ ]
461
+ logger.debug(
462
+ f"Removing channels: {channels_to_remove} from input channels of {basicio_interface.system_name}"
463
+ )
464
+ for channel_index in channels_to_remove:
465
+ # Clean up shared variable
466
+ channel = basicio_interface.input_channels[channel_index]
467
+ if channel.shared_variable:
468
+ BasicIOInterfaceHandler.output_variables.remove(
469
+ channel.shared_variable.system_name
470
+ )
471
+ del basicio_interface.input_channels[channel_index]
472
+
473
+ # Process each channel in the input map
474
+ for channel_data in input_map:
475
+ channel_index = channel_data.get("channel")
476
+ if channel_index is None:
477
+ continue
478
+
479
+ # Update or create channel
480
+ if channel_index in basicio_interface.input_channels:
481
+ # Update existing channel
482
+ for field, value in channel_data.items():
483
+ if field != "channel" and hasattr(
484
+ basicio_interface.input_channels[channel_index], field
485
+ ):
486
+ setattr(
487
+ basicio_interface.input_channels[channel_index],
488
+ field,
489
+ value,
490
+ )
491
+ else:
492
+ # Create new channel
493
+ basicio_interface.input_channels[channel_index] = InputChannel(
494
+ system_name=channel_data.get("system_name", ""),
495
+ user_name=channel_data.get("user_name", ""),
496
+ description=channel_data.get("description", ""),
497
+ tags=channel_data.get("tags", []),
498
+ default_value=channel_data.get("default_value", False),
499
+ )
500
+
501
+ # Register shared variable if needed
502
+ basicio_interface.input_channels[
503
+ channel_index
504
+ ].shared_variable = (
505
+ BasicIOInterfaceHandler.output_variables.new_from_logic(
506
+ system_name=basicio_interface.input_channels[
507
+ channel_index
508
+ ].system_name,
509
+ user_name=basicio_interface.input_channels[
510
+ channel_index
511
+ ].user_name,
512
+ description=basicio_interface.input_channels[
513
+ channel_index
514
+ ].description,
515
+ tags=basicio_interface.input_channels[channel_index].tags,
516
+ value=basicio_interface.input_channels[
517
+ channel_index
518
+ ].default_value,
519
+ )
520
+ )
521
+
522
+ # Handle output channel updates (if provided)
523
+ output_map = fields.get("output_map")
524
+ if output_map is not None:
525
+ # Get list of channels in the new map
526
+ new_channels = {
527
+ channel_data.get("channel")
528
+ for channel_data in output_map
529
+ if channel_data.get("channel") is not None
530
+ }
531
+
532
+ # Remove channels not in the new map
533
+ channels_to_remove = [
534
+ ch
535
+ for ch in basicio_interface.output_channels.keys()
536
+ if ch not in new_channels
537
+ ]
538
+ for channel_index in channels_to_remove:
539
+ # Clean up variable subscription
540
+ if basicio_interface.output_channels[
541
+ channel_index
542
+ ].shared_variable_name:
543
+ old_variable_name = basicio_interface.output_channels[
544
+ channel_index
545
+ ].shared_variable_name
546
+ if old_variable_name in BasicIOInterfaceHandler.input_variable_subs:
547
+ BasicIOInterfaceHandler.input_variable_subs[
548
+ old_variable_name
549
+ ] = [
550
+ item
551
+ for item in BasicIOInterfaceHandler.input_variable_subs[
552
+ old_variable_name
553
+ ]
554
+ if not (
555
+ item.interface == basicio_interface.system_name
556
+ and item.channel == channel_index
557
+ )
558
+ ]
559
+ # Clean up empty lists
560
+ if not BasicIOInterfaceHandler.input_variable_subs[
561
+ old_variable_name
562
+ ]:
563
+ del BasicIOInterfaceHandler.input_variable_subs[
564
+ old_variable_name
565
+ ]
566
+ del basicio_interface.output_channels[channel_index]
567
+
568
+ # Process each channel in the output map
569
+ for channel_data in output_map:
570
+ channel_index = channel_data.get("channel")
571
+ if channel_index is None:
572
+ continue
573
+
574
+ # Check if we're changing the variable subscription
575
+ old_variable_name = None
576
+ if channel_index in basicio_interface.output_channels:
577
+ old_variable_name = basicio_interface.output_channels[
578
+ channel_index
579
+ ].shared_variable_name
580
+
581
+ new_variable_name = channel_data.get("shared_variable_name")
582
+
583
+ # Update or create channel
584
+ if channel_index in basicio_interface.output_channels:
585
+ # Update existing channel
586
+ for field, value in channel_data.items():
587
+ if field != "channel" and hasattr(
588
+ basicio_interface.output_channels[channel_index], field
589
+ ):
590
+ setattr(
591
+ basicio_interface.output_channels[channel_index],
592
+ field,
593
+ value,
594
+ )
595
+ else:
596
+ # Create new channel
597
+ basicio_interface.output_channels[channel_index] = OutputChannel(
598
+ shared_variable_name=channel_data.get("shared_variable_name")
599
+ )
600
+
601
+ # Handle variable subscription changes if needed
602
+ if old_variable_name != new_variable_name:
603
+ # Remove old subscription if it exists
604
+ if (
605
+ old_variable_name
606
+ and old_variable_name
607
+ in BasicIOInterfaceHandler.input_variable_subs
608
+ ):
609
+ BasicIOInterfaceHandler.input_variable_subs[
610
+ old_variable_name
611
+ ] = [
612
+ item
613
+ for item in BasicIOInterfaceHandler.input_variable_subs[
614
+ old_variable_name
615
+ ]
616
+ if not (
617
+ item.interface == basicio_interface.system_name
618
+ and item.channel == channel_index
619
+ )
620
+ ]
621
+ # Clean up empty lists
622
+ if not BasicIOInterfaceHandler.input_variable_subs[
623
+ old_variable_name
624
+ ]:
625
+ del BasicIOInterfaceHandler.input_variable_subs[
626
+ old_variable_name
627
+ ]
628
+ # Unsubscribe
629
+ BasicIOInterfaceHandler.input_variable_subs.pop(
630
+ old_variable_name
631
+ )
632
+
633
+ # Add new subscription if needed
634
+ if new_variable_name:
635
+ if (
636
+ new_variable_name
637
+ not in BasicIOInterfaceHandler.input_variable_subs
638
+ ):
639
+ BasicIOInterfaceHandler.input_variable_subs[
640
+ new_variable_name
641
+ ] = []
642
+ BasicIOInterfaceHandler.input_variable_subs[
643
+ new_variable_name
644
+ ].append(
645
+ IfaceChanTuple(basicio_interface.system_name, channel_index)
646
+ )
647
+
648
+ # Create subscription
649
+ def create_variable_callback(iface):
650
+ def callback(var: str, json_data: str):
651
+ # Forward to controller's callback which has access to all interfaces
652
+ if BasicIOInterfaceHandler.variable_update_callback:
653
+ BasicIOInterfaceHandler.variable_update_callback(
654
+ var, json_data
655
+ )
656
+
657
+ return callback
658
+
659
+ # Subscribe with the closure
660
+ messager.sub(
661
+ new_variable_name,
662
+ create_variable_callback(basicio_interface),
663
+ )
664
+
665
+ @staticmethod
666
+ def remove(interface: Interface) -> None:
667
+ """
668
+ Remove a BasicIOInterface.
669
+
670
+ Args:
671
+ interface: The interface to remove.
672
+ """
673
+
674
+ basicio_interface = cast(BasicIOInterface, interface)
675
+
676
+ # Clean up variable subscriptions
677
+ for channel in basicio_interface.input_channels.values():
678
+ if channel.shared_variable:
679
+ BasicIOInterfaceHandler.output_variables.remove(
680
+ channel.shared_variable.system_name
681
+ )
682
+ for variable, sub in BasicIOInterfaceHandler.input_variable_subs.items():
683
+ if sub[0] == basicio_interface.system_name:
684
+ BasicIOInterfaceHandler.input_variable_subs.pop(variable)
685
+
686
+ @staticmethod
687
+ def handle_variable_update(
688
+ interface: Interface, variable: str, json_data: str
689
+ ) -> None:
690
+ """
691
+ Handle a variable update for a BasicIOInterface.
692
+
693
+ Args:
694
+ interface: The interface to handle the update for.
695
+ variable: The name of the variable that was updated.
696
+ json_data: The JSON data of the updated variable.
697
+ """
698
+
699
+ basicio_interface = cast(BasicIOInterface, interface)
700
+
701
+ # Find channels subscribed to this variable
702
+ channels = []
703
+ if variable in BasicIOInterfaceHandler.input_variable_subs:
704
+ for item in BasicIOInterfaceHandler.input_variable_subs[variable]:
705
+ if item.interface == basicio_interface.system_name:
706
+ channels.append(item.channel)
707
+
708
+ # No channels to update
709
+ if not channels:
710
+ return
711
+
712
+ # Parse the variable data
713
+ try:
714
+ data = decode_sv_message(json_data).data
715
+ value = bool(data.get("value", False))
716
+
717
+ # Update each channel
718
+ for channel in channels:
719
+ if channel in basicio_interface.output_channels:
720
+ basicio_interface.output_channels[channel].variable_value = value
721
+ except Exception as e:
722
+ logger.error(f"Error parsing variable data: {e}")
723
+
724
+ @staticmethod
725
+ def connect(interface: Interface) -> bool:
726
+ """
727
+ Connect to a BasicIOInterface using the specified adapter.
728
+
729
+ Args:
730
+ interface: The interface to connect.
731
+ adapter: The adapter to use for communication.
732
+
733
+ Returns:
734
+ True if connection was successful, False otherwise.
735
+ """
736
+
737
+ basicio_interface = cast(BasicIOInterface, interface)
738
+
739
+ # Check if the interface is enabled
740
+ if not basicio_interface.enabled:
741
+ return False
742
+
743
+ # Get the appropriate connection strategy based on connection type
744
+ match basicio_interface.connection_type:
745
+ case BasicIOConnectionType.TEST:
746
+ return BasicIOTestConnection.connect(basicio_interface)
747
+ case (
748
+ BasicIOConnectionType.MODBUS_ADAPTER
749
+ | BasicIOConnectionType.DINGTIAN_MODBUS_ADAPTER
750
+ ):
751
+ if BasicIOInterfaceHandler.interface_controller:
752
+ adapter = BasicIOInterfaceHandler.interface_controller.get_adapter_for_interface(
753
+ interface
754
+ )
755
+ if not adapter or not adapter.enabled:
756
+ return False # No adapter configured or adapter is disabled
757
+ return BasicIOModbusConnection.connect(basicio_interface, adapter)
758
+ return False # No interface controller
759
+ case _:
760
+ logger.error(
761
+ f"Unsupported connection type: {basicio_interface.connection_type}"
762
+ )
763
+ interface.error = (
764
+ f"Unsupported connection type: {basicio_interface.connection_type}"
765
+ )
766
+ interface.enabled = False
767
+ return False
768
+
769
+ @staticmethod
770
+ def disconnect(interface: Interface) -> None:
771
+ """
772
+ Disconnect from a BasicIOInterface.
773
+
774
+ Args:
775
+ interface: The interface to disconnect.
776
+
777
+ Returns:
778
+ True if disconnection was successful, False otherwise.
779
+ """
780
+
781
+ basicio_interface = cast(BasicIOInterface, interface)
782
+
783
+ # Get the appropriate connection strategy based on connection type
784
+ match basicio_interface.connection_type:
785
+ case BasicIOConnectionType.TEST:
786
+ BasicIOTestConnection.disconnect(basicio_interface)
787
+ case (
788
+ BasicIOConnectionType.MODBUS_ADAPTER
789
+ | BasicIOConnectionType.DINGTIAN_MODBUS_ADAPTER
790
+ ):
791
+ if BasicIOInterfaceHandler.interface_controller:
792
+ adapter = BasicIOInterfaceHandler.interface_controller.get_adapter_for_interface(
793
+ interface
794
+ )
795
+ if not adapter:
796
+ return
797
+ BasicIOModbusConnection.disconnect(basicio_interface, adapter)
798
+ case _:
799
+ logger.error(
800
+ f"Unsupported connection type: {basicio_interface.connection_type}"
801
+ )
802
+ interface.error = (
803
+ f"Unsupported connection type: {basicio_interface.connection_type}"
804
+ )
805
+ interface.enabled = False
806
+ return
807
+
808
+ @staticmethod
809
+ def read_data(interface: Interface, adapter: BaseAdapter | None = None) -> None:
810
+ """
811
+ Read data from the adapter for a BasicIOInterface.
812
+
813
+ Args:
814
+ interface: The interface to read data for.
815
+ adapter: The adapter to read from.
816
+ """
817
+
818
+ basicio_interface = cast(BasicIOInterface, interface)
819
+
820
+ # Skip if no input bits
821
+ if basicio_interface.input_bits <= 0:
822
+ return
823
+
824
+ # Get the appropriate connection strategy based on connection type
825
+ connection: type[BasicIOConnection]
826
+ match basicio_interface.connection_type:
827
+ case BasicIOConnectionType.TEST:
828
+ connection = BasicIOTestConnection
829
+ case (
830
+ BasicIOConnectionType.MODBUS_ADAPTER
831
+ | BasicIOConnectionType.DINGTIAN_MODBUS_ADAPTER
832
+ ):
833
+ connection = BasicIOModbusConnection
834
+ if BasicIOInterfaceHandler.interface_controller:
835
+ adapter = BasicIOInterfaceHandler.interface_controller.get_adapter_for_interface(
836
+ interface
837
+ )
838
+ case _:
839
+ logger.error(
840
+ f"Unsupported connection type: {basicio_interface.connection_type}"
841
+ )
842
+ interface.error = (
843
+ f"Unsupported connection type: {basicio_interface.connection_type}"
844
+ )
845
+ interface.enabled = False
846
+ return
847
+
848
+ # Read from the hardware
849
+ try:
850
+ # Read the words from the adapter (starting at the configured address)
851
+ data = connection.receive_data(basicio_interface, adapter=adapter)
852
+
853
+ if not data:
854
+ logger.error(
855
+ f"No data read from adapter for interface '{basicio_interface.system_name}'"
856
+ )
857
+ return
858
+
859
+ # Convert data to integer array
860
+ words = []
861
+ for i in range(0, len(data), 2):
862
+ if i + 1 < len(data):
863
+ word = (data[i] << 8) | data[i + 1]
864
+ words.append(word)
865
+
866
+ # Apply byte and word swapping if needed
867
+ if basicio_interface.input_byte_swap:
868
+ words = [swap_bytes(word) for word in words]
869
+
870
+ if basicio_interface.input_word_swap and len(words) >= 2:
871
+ for i in range(0, len(words), 2):
872
+ if i + 1 < len(words):
873
+ words[i], words[i + 1] = words[i + 1], words[i]
874
+
875
+ # Update channel values
876
+ for channel_index in range(1, basicio_interface.input_bits + 1):
877
+ if channel_index in basicio_interface.input_channels:
878
+ # Calculate word index and bit position
879
+ word_idx = (channel_index - 1) // 16
880
+ bit_pos = (channel_index - 1) % 16
881
+
882
+ # Get the bit value if the word index is valid
883
+ if word_idx < len(words):
884
+ bit_value = bool((words[word_idx] >> bit_pos) & 1)
885
+
886
+ channel = basicio_interface.input_channels[channel_index]
887
+
888
+ # Update the hardware value
889
+ channel.hardware_value = bit_value
890
+
891
+ channel.variable_value = (
892
+ channel.force_value if channel.force_enable else bit_value
893
+ )
894
+
895
+ # Update the shared variable if changed
896
+ if channel.shared_variable is not None:
897
+ if (
898
+ channel.shared_variable.data.model_dump()["value"]
899
+ != channel.variable_value
900
+ ):
901
+ channel.shared_variable.update(
902
+ {"value": channel.variable_value}
903
+ )
904
+ except Exception as e:
905
+ logger.error(
906
+ f"Error reading data for interface '{basicio_interface.system_name}': {e}"
907
+ )
908
+ interface.error = str(e)
909
+
910
+ @staticmethod
911
+ def write_data(interface: Interface, adapter: BaseAdapter | None = None) -> None:
912
+ """
913
+ Write data to the adapter for a BasicIOInterface.
914
+
915
+ Args:
916
+ interface: The interface to write data for.
917
+ adapter: The adapter to write to.
918
+ """
919
+
920
+ basicio_interface = cast(BasicIOInterface, interface)
921
+
922
+ # Skip if no output bits
923
+ if basicio_interface.output_bits <= 0:
924
+ return
925
+
926
+ # Calculate number of words needed to represent all bits
927
+ num_words = ceil(basicio_interface.output_bits / 16)
928
+
929
+ # Prepare words array
930
+ words = [0] * num_words
931
+
932
+ # Set bits based on channel values
933
+ for channel in range(1, basicio_interface.output_bits + 1):
934
+ if channel in basicio_interface.output_channels:
935
+ # Calculate word index and bit position
936
+ word_idx = (channel - 1) // 16
937
+ bit_pos = (channel - 1) % 16
938
+
939
+ # Determine the value to write
940
+ if basicio_interface.output_channels[channel].force_enable:
941
+ # Use forced value if forcing is enabled
942
+ value = basicio_interface.output_channels[channel].force_value
943
+ else:
944
+ # Otherwise use the variable value
945
+ value = basicio_interface.output_channels[channel].variable_value
946
+
947
+ # Set the bit in the corresponding word
948
+ if value:
949
+ words[word_idx] |= 1 << bit_pos
950
+
951
+ # Apply byte and word swapping if needed
952
+ if basicio_interface.output_byte_swap:
953
+ words = [swap_bytes(word) for word in words]
954
+
955
+ if basicio_interface.output_word_swap and len(words) >= 2:
956
+ for i in range(0, len(words), 2):
957
+ if i + 1 < len(words):
958
+ words[i], words[i + 1] = words[i + 1], words[i]
959
+
960
+ # Convert words to byte array
961
+ data = bytearray()
962
+ for word in words:
963
+ data.append(word & 0xFF) # Low byte
964
+ data.append((word >> 8) & 0xFF) # High byte
965
+
966
+ connection: type[BasicIOConnection]
967
+ # Get the appropriate connection strategy based on connection type
968
+ match basicio_interface.connection_type:
969
+ case BasicIOConnectionType.TEST:
970
+ connection = BasicIOTestConnection
971
+ case (
972
+ BasicIOConnectionType.MODBUS_ADAPTER
973
+ | BasicIOConnectionType.DINGTIAN_MODBUS_ADAPTER
974
+ ):
975
+ connection = BasicIOModbusConnection
976
+ if BasicIOInterfaceHandler.interface_controller:
977
+ adapter = BasicIOInterfaceHandler.interface_controller.get_adapter_for_interface(
978
+ interface
979
+ )
980
+ case _:
981
+ logger.error(
982
+ f"Unsupported connection type: {basicio_interface.connection_type}"
983
+ )
984
+ interface.error = (
985
+ f"Unsupported connection type: {basicio_interface.connection_type}"
986
+ )
987
+ interface.enabled = False
988
+ return
989
+
990
+ # Write to the adapter
991
+ try:
992
+ connection.send_data(basicio_interface, data=data, adapter=adapter)
993
+ # Update hardware value after successful write
994
+ for channel in range(1, basicio_interface.output_bits + 1):
995
+ if channel in basicio_interface.output_channels:
996
+ if basicio_interface.output_channels[channel].force_enable:
997
+ basicio_interface.output_channels[
998
+ channel
999
+ ].hardware_value = basicio_interface.output_channels[
1000
+ channel
1001
+ ].force_value
1002
+ else:
1003
+ basicio_interface.output_channels[
1004
+ channel
1005
+ ].hardware_value = basicio_interface.output_channels[
1006
+ channel
1007
+ ].variable_value
1008
+ except Exception as e:
1009
+ logger.error(
1010
+ f"Error writing data for interface '{basicio_interface.system_name}': {e}"
1011
+ )
1012
+ interface.error = str(e)
1013
+
1014
+ @staticmethod
1015
+ def get_channel_status(interface: Interface):
1016
+ """
1017
+ Get the channel status of a BasicIOInterface.
1018
+
1019
+ Args:
1020
+ interface: The interface to get the status of.
1021
+
1022
+ Returns:
1023
+ A string describing the current status.
1024
+ """
1025
+
1026
+ basicio_interface = cast(BasicIOInterface, interface)
1027
+
1028
+ return basicio_interface.model_dump_json(
1029
+ include={"input_channels", "output_channels"}, by_alias=True
1030
+ )
1031
+
1032
+ @staticmethod
1033
+ def set_forcing(
1034
+ interface: Interface, channel_index: int, iotype: str, state: str
1035
+ ) -> None:
1036
+ """
1037
+ Set forcing for a channel in a BasicIOInterface.
1038
+
1039
+ Args:
1040
+ interface: The interface to set forcing for.
1041
+ channel: The channel number.
1042
+ is_input: Whether the channel is an input.
1043
+ enabled: Whether forcing is enabled.
1044
+ value: The value to force.
1045
+ """
1046
+
1047
+ if iotype not in ["input", "output"]:
1048
+ raise ValueError("Invalid iotype. Must be 'input' or 'output'.")
1049
+ if state not in ["on", "off", "none"]:
1050
+ raise ValueError("Invalid state. Must be 'on', 'off', or 'none'.")
1051
+ if channel_index is None:
1052
+ raise ValueError("Channel must be specified.")
1053
+
1054
+ basicio_interface = cast(BasicIOInterface, interface)
1055
+
1056
+ # Set forcing for the specified channel
1057
+ if iotype == "input":
1058
+ if channel_index in basicio_interface.input_channels:
1059
+ channel = basicio_interface.input_channels[channel_index]
1060
+ channel.force_enable = state in ["on", "off"]
1061
+ channel.force_value = state == "on"
1062
+
1063
+ # Update shared variable if forced
1064
+ if channel.force_enable:
1065
+ channel.variable_value = channel.force_value
1066
+ else:
1067
+ # If not forcing, reset the variable value to hardware value
1068
+ channel.variable_value = channel.hardware_value
1069
+
1070
+ if channel.shared_variable is not None:
1071
+ # Update the shared variable value if changed
1072
+ if (
1073
+ channel.shared_variable.data.model_dump()["value"]
1074
+ != channel.variable_value
1075
+ ):
1076
+ channel.shared_variable.update(
1077
+ {"value": channel.variable_value}
1078
+ )
1079
+ else:
1080
+ if channel_index in basicio_interface.output_channels:
1081
+ channel = basicio_interface.output_channels[channel_index]
1082
+ channel.force_enable = state in ["on", "off"]
1083
+ channel.force_value = state == "on"
1084
+
1085
+ @classmethod
1086
+ def load_from_json(cls, interface_data: Any) -> None:
1087
+ basicio_interface = BasicIOInterface.model_validate(interface_data)
1088
+ if cls.interface_controller is None:
1089
+ raise ValueError(
1090
+ "Interface controller not set. Call set_controller() first."
1091
+ )
1092
+ cls.interface_controller.add(basicio_interface)
1093
+
1094
+ @staticmethod
1095
+ def save_to_json(interface: Interface) -> str:
1096
+ return interface.model_dump_json()