File size: 9,074 Bytes
938949f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cfddb41
 
 
 
 
 
 
 
 
 
 
 
 
 
938949f
 
 
 
 
 
 
cfddb41
938949f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
"""
TrackerDispatcher: send tilt commands to physical trackers and verify execution.

Sits downstream of the CommandArbiter.  When an ArbiterDecision has
``dispatch=True``, the dispatcher:
  1. Sends the target angle to all 4 trackers via ThingsBoard RPC.
  2. Waits briefly, then reads actual ``angle`` telemetry.
  3. Confirms |actual − target| < tolerance for each tracker.
  4. Returns a DispatchResult with per-tracker status.

If RPC is unavailable (e.g. customer-level API), falls back to
shared-attribute writes (``setAngle``, ``setMode``).
"""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Dict, List, Optional

from config.settings import ANGLE_TOLERANCE_DEG, TRACKER_ID_MAP

logger = logging.getLogger(__name__)

# All tracker device names from the canonical ID map
TRACKER_NAMES = list(TRACKER_ID_MAP.values())


# ---------------------------------------------------------------------------
# Result containers
# ---------------------------------------------------------------------------

@dataclass
class TrackerResult:
    """Execution result for a single tracker."""

    device_name: str
    target_angle: float
    actual_angle: Optional[float] = None
    error: Optional[str] = None
    verified: bool = False          # True if |actual − target| < tolerance
    method: str = "unknown"         # "rpc", "attribute", "dry_run"


@dataclass
class DispatchResult:
    """Aggregate result for dispatching to all trackers."""

    timestamp: datetime
    target_angle: float
    source: str                     # from ArbiterDecision.source
    trackers: List[TrackerResult] = field(default_factory=list)
    all_verified: bool = False
    dry_run: bool = False

    @property
    def n_success(self) -> int:
        return sum(1 for t in self.trackers if t.verified)

    @property
    def n_failed(self) -> int:
        return sum(1 for t in self.trackers if t.error)

    def summary(self) -> str:
        if self.dry_run:
            return f"[DRY RUN] target={self.target_angle:.1f}°, source={self.source}"
        return (
            f"target={self.target_angle:.1f}°, source={self.source}, "
            f"verified={self.n_success}/{len(self.trackers)}"
        )


# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------

class TrackerDispatcher:
    """Send angle commands to trackers and verify execution.

    Parameters
    ----------
    tb_client : ThingsBoardClient, optional
        Lazy-initialised if not provided.
    tracker_names : list[str], optional
        Override which trackers to control (default: all 4).
    verify_timeout_sec : float
        How long to wait before reading back actual angles.
    angle_tolerance_deg : float
        Maximum acceptable |actual − target| for verification.
    dry_run : bool
        If True, log commands but don't actually send them.
    """

    def __init__(
        self,
        tb_client=None,
        tracker_names: Optional[List[str]] = None,
        verify_timeout_sec: float = 5.0,
        angle_tolerance_deg: float = ANGLE_TOLERANCE_DEG,
        dry_run: bool = False,
    ):
        self._tb = tb_client
        self.tracker_names = tracker_names or TRACKER_NAMES
        self.verify_timeout = verify_timeout_sec
        self.tolerance = angle_tolerance_deg
        self.dry_run = dry_run

    def _client(self):
        if self._tb is None:
            from src.data.thingsboard_client import ThingsBoardClient
            self._tb = ThingsBoardClient()
        return self._tb

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def dispatch(
        self,
        decision,
        angle_overrides: Optional[Dict[str, float]] = None,
    ) -> DispatchResult:
        """Send an ArbiterDecision to all trackers.

        Parameters
        ----------
        decision : ArbiterDecision
            Must have ``dispatch=True`` and ``angle`` set.
        angle_overrides : dict, optional
            Per-tracker angle overrides (device_name → angle).
            Trackers not in this dict get the default ``decision.angle``.
            Typically empty — all trackers follow the same pattern.

        Returns
        -------
        DispatchResult with per-tracker verification status.
        """
        now = datetime.now(tz=timezone.utc)
        result = DispatchResult(
            timestamp=now,
            target_angle=decision.angle,
            source=str(decision.source),
            dry_run=self.dry_run,
        )

        if not decision.dispatch:
            logger.debug("Decision dispatch=False, skipping: %s", decision)
            return result

        for name in self.tracker_names:
            angle = decision.angle
            if angle_overrides and name in angle_overrides:
                angle = angle_overrides[name]
            tr = self._send_to_tracker(name, angle)
            result.trackers.append(tr)

        # Verify after a brief wait (let controller process the command)
        if not self.dry_run and any(t.error is None for t in result.trackers):
            time.sleep(self.verify_timeout)
            self._verify_all(result)

        result.all_verified = all(t.verified for t in result.trackers)
        logger.info("Dispatch: %s", result.summary())
        return result

    def read_current_angles(self) -> Dict[str, Optional[float]]:
        """Read actual angle from all trackers. Useful for status display."""
        angles = {}
        for name in self.tracker_names:
            try:
                vals = self._client().get_latest_telemetry(name, ["angle"])
                angles[name] = vals.get("angle")
            except Exception as exc:
                logger.warning("Failed to read %s angle: %s", name, exc)
                angles[name] = None
        return angles

    # ------------------------------------------------------------------
    # Internal
    # ------------------------------------------------------------------

    def _send_to_tracker(self, device_name: str, angle: float) -> TrackerResult:
        """Send angle command to a single tracker."""
        tr = TrackerResult(device_name=device_name, target_angle=angle)

        if self.dry_run:
            tr.method = "dry_run"
            tr.verified = True
            logger.info("[DRY RUN] %s → %.1f°", device_name, angle)
            return tr

        client = self._client()

        # Shared attribute write is the reliable method — tracker controllers
        # poll setAngle/setMode from shared attributes on their update cycle.
        # RPC requires the device to be online for real-time communication,
        # which is not guaranteed.
        # Retry attribute write with exponential backoff (1s, 2s)
        attr_exc = None
        for attempt in range(3):
            try:
                client.set_device_attributes(device_name, {"setAngle": angle, "setMode": "manual"})
                tr.method = "attribute"
                return tr
            except Exception as exc:
                attr_exc = exc
                if attempt < 2:
                    time.sleep(1 << attempt)  # 1s, 2s
                    logger.warning("Attribute write retry %d for %s: %s", attempt + 1, device_name, exc)

        logger.warning("Attribute write failed for %s after 3 attempts: %s, trying RPC", device_name, attr_exc)

        # Fallback: try RPC (may timeout if device is offline)
        try:
            client.send_rpc_command(device_name, "setAngle", angle)
            tr.method = "rpc"
            return tr
        except Exception as rpc_exc:
            tr.error = f"attribute (3 retries) and RPC both failed: {attr_exc}; {rpc_exc}"
            logger.error("Cannot send to %s: %s", device_name, tr.error)
            return tr

    def _verify_all(self, result: DispatchResult) -> None:
        """Read actual angles and compare to target."""
        for tr in result.trackers:
            if tr.error:
                continue
            try:
                vals = self._client().get_latest_telemetry(tr.device_name, ["angle"])
                tr.actual_angle = vals.get("angle")
                if tr.actual_angle is not None:
                    diff = abs(tr.actual_angle - tr.target_angle)
                    tr.verified = diff <= self.tolerance
                    if not tr.verified:
                        logger.warning(
                            "%s: actual=%.1f° target=%.1f° (diff=%.1f° > tol=%.1f°)",
                            tr.device_name, tr.actual_angle, tr.target_angle,
                            diff, self.tolerance,
                        )
            except Exception as exc:
                logger.warning("Verify failed for %s: %s", tr.device_name, exc)
                tr.error = f"verification read failed: {exc}"