From 4530c84915654845707ceca043d4002a317473c7 Mon Sep 17 00:00:00 2001 From: Wasiu Bakare Date: Thu, 18 Jun 2026 17:32:12 +0100 Subject: [PATCH] feat(skill): add phone USB power support --- docs/phone-termux-path.md | 119 ++++++++++++++++++++++ ori.yaml.phone.example | 21 +++- skills/energy-anomaly-detector/hooks.py | 35 +++++-- skills/energy-anomaly-detector/skill.yaml | 18 ++-- tests/test_energy_anomaly_skill.py | 86 ++++++++++++++-- tests/test_integration_rule_evaluation.py | 29 +++++- 6 files changed, 279 insertions(+), 29 deletions(-) create mode 100644 docs/phone-termux-path.md diff --git a/docs/phone-termux-path.md b/docs/phone-termux-path.md new file mode 100644 index 0000000..db5b99f --- /dev/null +++ b/docs/phone-termux-path.md @@ -0,0 +1,119 @@ +# Ori Phone Termux Path + +This document defines the phone-first Ori Energy wedge. The phone path is a +low-friction deployment profile for early African SME sites, not the final +hardware architecture for physical actuation. + +## Product Boundary + +The phone path turns a spare Android phone into an Ori edge runtime for Tier A +energy intelligence: + +- read a USB serial energy meter through Android USB OTG; +- build a site-local baseline for power draw; +- detect sustained overdraw, sudden spikes, and unstable draw; +- send operator alerts and dashboard logs; +- export consented telemetry to ori-cloud for fleet intelligence. + +The phone path does not provide certified relay control. Tier B/C physical +switching, load separation, generator/grid transfer, and equipment cutoffs +require a certified Ori Edge Node installation. Tier D safety semantics remain +inviolable, but a phone-only deployment has no direct relay authority unless a +separate certified actuator path is installed. + +## Onboarding Kit + +The basic phone onboarding kit contains: + +- an Android phone with Termux installed, supplied by the customer or bundled by + Ori; +- a PZEM-004T or equivalent USB/Modbus energy meter; +- a USB OTG adapter cable; +- a short setup card with the Ori install command and support contact. + +The USB energy meter is the small sensor device. It measures voltage, current, +power, energy, frequency, and power factor in hardware. Termux does not install +software onto the meter; Termux runs the Ori runtime, and the runtime's +`usb_serial` HAL reads the meter over the phone's USB port. + +Customer-facing copy should say that the meter "connects to your phone's USB +port using a small adapter cable." Do not imply that the phone's charging port +directly senses the building's mains supply. + +## Runtime Shape + +Use `ori.yaml.phone.example` as the starting profile: + +- `device.deployment_type: phone`; +- `sensors[].protocol: usb_serial`; +- `sensors[].type: usb_power` for a single plug-level phone starter sensor; +- `actions.relay.enabled: false`; +- `gateway.enabled: false` unless the phone is explicitly bridged to a local + gateway. + +The `energy-anomaly-detector` skill accepts `usb_power`, `usb_current`, and +`usb_voltage`. For `usb_power`, the hook treats the reading as watts and uses it +directly for cost projection. It does not reinterpret watts as amps. + +## PWA Role + +The PWA is an operator and commercial surface, not the sensing runtime. It can +show onboarding status, live site health, alerts, invoices, subscription state, +reports, and upgrade prompts from any modern phone, including iPhone. + +The Android Termux runtime remains the edge execution path because browsers and +PWAs cannot reliably read USB serial meters, run offline background loops, or +enforce Ori's runtime safety model. + +## Device Policy On Phone + +The phone runtime uses the same DevicePolicy concept as Pi deployments: + +- active trial or paid Starter Phone tier permits Tier A intelligence and + telemetry export; +- expired subscription restricts non-safety business features such as premium + reports, cloud sync, and advanced reasoning; +- Tier D safety behavior is never disabled by subscription state; +- relay entitlements are always false for phone-only deployments. + +Phone deployments need a stable device identity from the cloud registration +flow. Reinstalling Termux must not silently create a fresh paid trial for the +same site. ori-cloud should bind the phone runtime to a site, account, and +hardware fingerprint where available, while still allowing support-led recovery +when a phone is replaced. + +## Telemetry And Data Moat + +The phone should keep local readings available offline, but consented telemetry +must not remain trapped on the handset. The runtime should export: + +- normalized sensor readings at a cloud-controlled sampling rate; +- alert and action logs; +- baseline summaries and derived anomaly features; +- device health and sync status. + +Sensitive historical exports must follow the runtime-gateway/cloud security +posture: authenticated envelopes, replay protection, and encryption for +business/audit history when gateway encryption is enabled. + +## ori-cloud Requirements + +ori-cloud needs explicit support for phone deployments: + +- a `phone_starter` or equivalent subscription tier mapped to Tier A energy + intelligence, no relay entitlement, and limited local/cloud reasoning; +- device registration that issues runtime credentials, records deployment type + `phone`, and binds the runtime to account, site, and subscription; +- DevicePolicy generation for phone runtimes, including trial expiry, + subscription status, telemetry allowance, report allowance, and relay + prohibition; +- telemetry ingestion endpoints for phone runtime exports with consent, rate + limits, idempotency, and offline backfill handling; +- a PWA dashboard that reads cloud state instead of talking directly to the USB + meter; +- upgrade flow from Phone Starter to Certified Edge Node without losing the + site's historical baseline. + +The `/Users/adegneus/Ori-Platform/ori-energy` demo should be treated as the +product proxy for this flow: waitlist, onboarding, site health, alerts, +subscription state, and the Edge Node upgrade path. diff --git a/ori.yaml.phone.example b/ori.yaml.phone.example index d09bc09..3b563e9 100644 --- a/ori.yaml.phone.example +++ b/ori.yaml.phone.example @@ -1,15 +1,25 @@ # ori.yaml.phone.example # Phone-as-Gateway deployment profile (Termux on Android) +# +# This profile is the Ori Energy phone wedge: an Android phone runs the runtime +# in Termux and reads a USB serial energy meter through an OTG adapter. The +# phone is an edge runtime and gateway substitute for Tier A intelligence; it is +# not a substitute for certified Edge Node relay wiring. device: id: phone-gateway-ikeja-01 name: Ikeja Office Phone Gateway location: Lagos, Nigeria timezone: Africa/Lagos + country_code: NG deployment_type: phone + site_type: office sensors: - - id: mains-power + # PZEM-004T or equivalent USB/Modbus energy meter connected to the Android + # phone's USB port through an OTG adapter. The USB meter is the small sensor + # device in the phone onboarding kit; Termux only runs the Ori runtime. + - id: phone-main-power type: usb_power protocol: usb_serial device_path: /dev/ttyUSB0 @@ -18,8 +28,11 @@ sensors: skills: - name: energy-anomaly-detector - version: "0.2.1" - config: {} + version: "0.1.0" + config: + country_code: NG + tariff_per_kwh: 225.0 + currency_code: NGN reasoning: default_tier: local @@ -40,6 +53,8 @@ actions: sms: enabled: false relay: + # Phone deployments must keep relay disabled. Tier B/C physical actuation + # requires a certified Ori Edge Node installation. enabled: false gpio_pin: 26 diff --git a/skills/energy-anomaly-detector/hooks.py b/skills/energy-anomaly-detector/hooks.py index 4c3e27b..f8d7a4f 100644 --- a/skills/energy-anomaly-detector/hooks.py +++ b/skills/energy-anomaly-detector/hooks.py @@ -64,6 +64,8 @@ def _stddev(values): "CA": 120.0, } +_POWER_SENSOR_TYPES = frozenset({"usb_power"}) + def _resolve_timezone(tz_name): return resolve_timezone(tz_name) @@ -255,7 +257,9 @@ def pre_trigger_eval(context): context.derived["sustained_high_ratio"] = 0.0 context.derived["sustained_high_count"] = 0 context.derived["recent_volatility_percent"] = 0.0 + context.derived["measurement_kind"] = "current" context.derived["delta_amps"] = 0.0 + context.derived["delta_watts"] = 0.0 context.derived["line_voltage_used"] = 0.0 context.derived["power_factor_used"] = 0.0 context.derived["estimated_kw_delta"] = 0.0 @@ -269,6 +273,9 @@ def pre_trigger_eval(context): return context sensor_id = str(getattr(reading, "sensor_id", "")).strip() + sensor_type = str(getattr(reading, "sensor_type", "")).strip() + measurement_kind = "power" if sensor_type in _POWER_SENSOR_TYPES else "current" + context.derived["measurement_kind"] = measurement_kind current_value = as_float(getattr(reading, "value", 0.0), 0.0) history_window = context.derived["history_window"] persistence_window = context.derived["persistence_window"] @@ -339,12 +346,17 @@ def pre_trigger_eval(context): recent_values ) - # Deterministic cost estimator (P3-R6): - # keep both observed window and projected /day run-rate values. + # Deterministic cost estimator: USB power meters report watts + # directly, while clamp/current sensors need voltage and power factor. delta_amps = 0.0 + delta_watts = 0.0 if baseline_valid == 1 and current_value > baseline_24h: - delta_amps = current_value - baseline_24h + if measurement_kind == "power": + delta_watts = current_value - baseline_24h + else: + delta_amps = current_value - baseline_24h context.derived["delta_amps"] = delta_amps + context.derived["delta_watts"] = delta_watts tariff_per_kwh, tariff_is_explicit = _resolve_tariff(context) line_voltage, voltage_confidence = _resolve_line_voltage(context) @@ -353,15 +365,22 @@ def pre_trigger_eval(context): context.derived["line_voltage_used"] = line_voltage context.derived["power_factor_used"] = power_factor - confidence = ( - "exact" if tariff_is_explicit and voltage_confidence == "exact" else "estimated" - ) + confidence = "estimated" + if tariff_is_explicit: + if measurement_kind == "power" or voltage_confidence == "exact": + confidence = "exact" context.derived["cost_confidence"] = confidence - if delta_amps <= 0.0 or tariff_per_kwh <= 0.0: + if measurement_kind == "power": + kw_delta = delta_watts / 1000.0 + else: + kw_delta = (delta_amps * line_voltage * power_factor) / 1000.0 + delta_watts = delta_amps * line_voltage * power_factor + context.derived["delta_watts"] = delta_watts + + if kw_delta <= 0.0 or tariff_per_kwh <= 0.0: return context - kw_delta = (delta_amps * line_voltage * power_factor) / 1000.0 context.derived["estimated_kw_delta"] = kw_delta observed_hours = 0.0 diff --git a/skills/energy-anomaly-detector/skill.yaml b/skills/energy-anomaly-detector/skill.yaml index 6fc23ec..e725067 100644 --- a/skills/energy-anomaly-detector/skill.yaml +++ b/skills/energy-anomaly-detector/skill.yaml @@ -13,35 +13,41 @@ sensors_required: protocol: serial - type: voltage protocol: serial + - type: usb_power + protocol: usb_serial + - type: usb_current + protocol: usb_serial + - type: usb_voltage + protocol: usb_serial triggers: - name: sustained_overdraw - condition: "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current') and quality >= min_quality and baseline_valid == 1 and context_aware_suppression == 0 and deviation_percent >= overdraw_threshold_percent and sustained_high_ratio >= sustained_ratio_threshold" + condition: "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current' or sensor_type == 'usb_current' or sensor_type == 'usb_power') and quality >= min_quality and baseline_valid == 1 and context_aware_suppression == 0 and deviation_percent >= overdraw_threshold_percent and sustained_high_ratio >= sustained_ratio_threshold" cooldown_seconds: 600 escalate_to: local_slm action_tier: A - name: sudden_load_spike - condition: "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current') and quality >= min_quality and baseline_valid == 1 and context_aware_suppression == 0 and spike_ratio >= spike_ratio_threshold and deviation_percent >= (overdraw_threshold_percent / 2.0)" + condition: "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current' or sensor_type == 'usb_current' or sensor_type == 'usb_power') and quality >= min_quality and baseline_valid == 1 and context_aware_suppression == 0 and spike_ratio >= spike_ratio_threshold and deviation_percent >= (overdraw_threshold_percent / 2.0)" cooldown_seconds: 300 escalate_to: local_slm action_tier: A - name: unstable_power_draw - condition: "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current') and quality >= min_quality and baseline_valid == 1 and context_aware_suppression == 0 and recent_volatility_percent >= volatility_threshold_percent and deviation_percent >= (overdraw_threshold_percent / 3.0)" + condition: "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current' or sensor_type == 'usb_current' or sensor_type == 'usb_power') and quality >= min_quality and baseline_valid == 1 and context_aware_suppression == 0 and recent_volatility_percent >= volatility_threshold_percent and deviation_percent >= (overdraw_threshold_percent / 3.0)" cooldown_seconds: 600 escalate_to: local_slm action_tier: A - name: dangerous_overcurrent - condition: "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current') and value > dangerous_overcurrent_threshold" + condition: "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current' or sensor_type == 'usb_current') and value > dangerous_overcurrent_threshold" bypass_llm: true action_tier: D cooldown_seconds: 0 # Tier D — Grid overvoltage: protect equipment from damaging voltage levels - name: voltage_overvoltage - condition: "sensor_type == 'voltage' and sensor_id == 'grid-main_voltage' and value > overvoltage_threshold" + condition: "(sensor_type == 'voltage' or sensor_type == 'usb_voltage') and sensor_id == 'grid-main_voltage' and value > overvoltage_threshold" bypass_llm: true action_tier: D cooldown_seconds: 0 @@ -50,7 +56,7 @@ triggers: # phcn_restore_active is injected by hooks when load is confirmed on generator. # Default is 0 (safe for deployments without live context). - name: grid_phcn_restored - condition: "sensor_type == 'voltage' and sensor_id == 'grid-main_voltage' and value >= grid_restore_voltage and phcn_restore_active == 1" + condition: "(sensor_type == 'voltage' or sensor_type == 'usb_voltage') and sensor_id == 'grid-main_voltage' and value >= grid_restore_voltage and phcn_restore_active == 1" action_tier: B reasoning_policy: post_action cooldown_seconds: 0 diff --git a/tests/test_energy_anomaly_skill.py b/tests/test_energy_anomaly_skill.py index 5dbb422..b7ed20b 100644 --- a/tests/test_energy_anomaly_skill.py +++ b/tests/test_energy_anomaly_skill.py @@ -68,6 +68,8 @@ def _event( sensor_id: str = "load-current-01", sensor_type: str = "current_clamp", value: float, + unit: str = "ampere", + source: str = "i2c", quality: float = 1.0, timestamp: int = 1_710_000_000_123, ) -> OriEvent: @@ -75,10 +77,10 @@ def _event( sensor_id=sensor_id, sensor_type=sensor_type, value=value, - unit="ampere", + unit=unit, timestamp=timestamp, quality=quality, - metadata={"source": "i2c"}, + metadata={"source": source}, ) return OriEvent.from_reading(reading, "energy-site-01") @@ -91,22 +93,48 @@ def _ctx(skill, event, store): return hook_ctx, context -def _history_reading(sensor_id: str, value: float, ts: int) -> SensorReading: +def _history_reading( + sensor_id: str, + value: float, + ts: int, + *, + sensor_type: str = "current_clamp", + unit: str = "ampere", + source: str = "i2c", +) -> SensorReading: return SensorReading( sensor_id=sensor_id, - sensor_type="current_clamp", + sensor_type=sensor_type, value=value, - unit="ampere", + unit=unit, timestamp=ts, quality=1.0, - metadata={"source": "i2c"}, + metadata={"source": source}, ) -def _seed_history(store: _Store, sensor_id: str, values: list[float]) -> None: +def _seed_history( + store: _Store, + sensor_id: str, + values: list[float], + *, + sensor_type: str = "current_clamp", + unit: str = "ampere", + source: str = "i2c", +) -> None: base_ts = 1_709_999_000_000 for idx, value in enumerate(reversed(values)): - store.add_history(sensor_id, _history_reading(sensor_id, value, base_ts + idx)) + store.add_history( + sensor_id, + _history_reading( + sensor_id, + value, + base_ts + idx, + sensor_type=sensor_type, + unit=unit, + source=source, + ), + ) @pytest.mark.asyncio @@ -198,6 +226,48 @@ async def test_rule_matches_sustained_overdraw(): assert result.rule_name == "sustained_overdraw" +@pytest.mark.asyncio +async def test_usb_power_matches_sustained_overdraw_and_uses_watts_for_cost(): + skill = _load_skill() + skill.config["overdraw_threshold_percent"] = 10.0 + skill.config["sustained_ratio_threshold"] = 0.6 + skill.config["tariff_per_kwh"] = 100.0 + store = _Store() + sensor_id = "phone-main-power" + _seed_history( + store, + sensor_id, + [1_700.0, 1_650.0, 1_720.0, 1_680.0, 1_710.0, 1_690.0, 1_000.0, 1_000.0], + sensor_type="usb_power", + unit="watt", + source="usb_serial", + ) + + event = _event( + sensor_id=sensor_id, + sensor_type="usb_power", + value=1_750.0, + unit="watt", + source="usb_serial", + quality=0.95, + ) + hook_ctx, context = _ctx(skill, event, store) + trigger = next(t for t in skill.triggers if t.name == "sustained_overdraw") + + result = await RuleEngine().evaluate(event, [trigger], context=context) + + assert result.matched is True + assert hook_ctx.derived["measurement_kind"] == "power" + assert hook_ctx.derived["delta_amps"] == 0.0 + assert hook_ctx.derived["delta_watts"] == pytest.approx( + 1_750.0 - hook_ctx.derived["baseline_24h"] + ) + assert hook_ctx.derived["estimated_kw_delta"] == pytest.approx( + hook_ctx.derived["delta_watts"] / 1000.0 + ) + assert hook_ctx.derived["cost_confidence"] == "exact" + + @pytest.mark.asyncio async def test_contextual_suppression_blocks_sustained_overdraw_tier_a(): skill = _load_skill() diff --git a/tests/test_integration_rule_evaluation.py b/tests/test_integration_rule_evaluation.py index 2d21dd1..028acfb 100644 --- a/tests/test_integration_rule_evaluation.py +++ b/tests/test_integration_rule_evaluation.py @@ -16,12 +16,18 @@ ) -def _request(value: float, **kwargs: object) -> RuleEvaluationRequest: +def _request( + value: float, + *, + sensor_type: str = "current_clamp", + unit: str = "ampere", + **kwargs: object, +) -> RuleEvaluationRequest: return RuleEvaluationRequest( sensor_id="main-circuit-current", - sensor_type="current_clamp", + sensor_type=sensor_type, value=value, - unit="ampere", + unit=unit, timestamp_ms=1_710_000_000_123, quality=1.0, device_id="demo-site-a", @@ -81,7 +87,7 @@ async def test_dangerous_overcurrent_returns_tier_d_with_proof_fields() -> None: assert result.bypass_llm is True assert ( result.proof_rule_condition - == "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current') and value > dangerous_overcurrent_threshold" + == "(sensor_type == 'current_clamp' or sensor_type == 'ads1115_current' or sensor_type == 'current' or sensor_type == 'usb_current') and value > dangerous_overcurrent_threshold" ) assert result.proof_threshold_name == "dangerous_overcurrent_threshold" assert result.proof_threshold == pytest.approx(20.0) @@ -92,6 +98,21 @@ async def test_dangerous_overcurrent_returns_tier_d_with_proof_fields() -> None: assert result.latency_ms >= 0 +@pytest.mark.asyncio +async def test_usb_current_can_return_tier_d_with_proof_fields() -> None: + result = await evaluate_sensor_reading( + _request(52.0, sensor_type="usb_current", unit="ampere") + ) + + assert result.matched is True + assert result.action_tier == "D" + assert result.trigger_name == "dangerous_overcurrent" + assert result.proof_threshold_name == "dangerous_overcurrent_threshold" + assert result.proof_threshold == pytest.approx(20.0) + assert result.proof_sensor_value == pytest.approx(52.0) + assert result.proof_tier_selected == "D" + + @pytest.mark.asyncio async def test_below_dangerous_overcurrent_threshold_does_not_match_tier_d() -> None: result = await evaluate_sensor_reading(_request(12.0))