Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions neon_data_models/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,29 @@ class CcaiControl(Enum):
CREATE_PROMPT = "!MSG:CREATE_PROMPT"
START_AUTO_PROMPTS = "!START_AUTO_PROMPTS"
STOP_AUTO_PROMPTS = "!STOP_AUTO_PROMPTS"


class NodeNativeAction(Enum):
"""
Defines platform-native actions a Node client may perform in response to
a `node.invoke_native` request. Each value is also the capability key a
Node advertises in `node.hello`.
"""
LAUNCH_CAMERA_APP = "launch_camera_app"
LAUNCH_VOICE_RECORDER_APP = "launch_voice_recorder_app"
LAUNCH_REMINDERS_APP = "launch_reminders_app"
LAUNCH_CLOCK_APP = "launch_clock_app"
LAUNCH_SMS_APP = "launch_sms_app"
LAUNCH_EMAIL_APP = "launch_email_app"


class NativeActionErrorCode(Enum):
"""
Defines error codes a Node may return in a `node.invoke_native.response`.
String values are used so failures are debuggable in logs without a
translation table; numeric codes may be layered on later additively.
"""
NOT_SUPPORTED = "not_supported"
PERMISSION_DENIED = "permission_denied"
UNAVAILABLE = "unavailable"
INTERNAL_ERROR = "internal_error"
76 changes: 70 additions & 6 deletions neon_data_models/models/api/node_v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

from datetime import datetime, timedelta
from pydantic import Field
from typing import List, Literal, Optional, Annotated, Dict
from pydantic import Field, model_validator
from typing import Any, List, Literal, Optional, Annotated, Dict

from neon_data_models.enum import UserData, AlertType, Weekdays
from neon_data_models.enum import (UserData, AlertType, Weekdays,
NodeNativeAction, NativeActionErrorCode)
from neon_data_models.models.base import BaseModel
from neon_data_models.models.base.contexts import NodeCapabilities
from neon_data_models.models.base.messagebus import BaseMessage, MessageContext


Expand Down Expand Up @@ -134,6 +136,66 @@ class ClearDataData(BaseModel):
data: ClearDataData


class NodeHello(BaseMessage):
class NodeHelloData(BaseModel):
node_id: str = Field(description="Session-scoped Node client ID")
# node_name is stamped onto every outbound bus message via
# `context.node`, so an unbounded value would be amplified per-message
node_name: str = Field(
default="", max_length=128,
description="User-editable Node device name")
capabilities: NodeCapabilities = Field(
default={},
description="Mapping of native action to supported state. "
"`false` or absent means the action is not supported "
"on this Node.")

msg_type: Literal["node.hello"] = "node.hello"
data: NodeHelloData


class NodeInvokeNative(BaseMessage):
class InvokeNativeData(BaseModel):
action: NodeNativeAction = Field(
description="Native action for the Node to perform")
params: Dict[str, Any] = Field(
default={},
description="Parameters for the requested action. Most actions "
"take none. To pre-fill the message composer, "
"`launch_sms_app` accepts `to` and `body`; "
"`launch_email_app` accepts `to`, `subject`, and "
"`body`. Nodes ignore unrecognized keys.")

msg_type: Literal["node.invoke_native"] = "node.invoke_native"
data: InvokeNativeData


class NodeInvokeNativeResponse(BaseMessage):
class InvokeNativeResponseData(BaseModel):
class NativeActionError(BaseModel):
code: NativeActionErrorCode
message: str = Field(
default="", description="Human-readable error description")

action: NodeNativeAction = Field(
description="Native action this response corresponds to")
status: Literal["success", "error"]
error: Optional[NativeActionError] = Field(
default=None, description="Required when `status` is `error`")

@model_validator(mode="after")
def validate_error_state(self):
if self.status == "error" and self.error is None:
raise ValueError("`error` is required when status is 'error'")
if self.status == "success" and self.error is not None:
raise ValueError("`error` is invalid when status is 'success'")
return self

msg_type: Literal["node.invoke_native.response"] = \
"node.invoke_native.response"
data: InvokeNativeResponseData


class CoreAlertExpired(BaseMessage):
class AlertData(BaseModel):
alert_type: AlertType
Expand All @@ -154,6 +216,8 @@ class AlertData(BaseModel):
__all__ = [NodeAudioInput.__name__, NodeTextInput.__name__, NodeGetStt.__name__,
NodeGetTts.__name__, NodeKlatResponse.__name__,
NodeAudioInputResponse.__name__, NodeGetSttResponse.__name__,
NodeGetTtsResponse.__name__, CoreWWDetected.__name__,
CoreIntentFailure.__name__, CoreErrorResponse.__name__,
CoreClearData.__name__, CoreAlertExpired.__name__]
NodeGetTtsResponse.__name__, NodeHello.__name__,
NodeInvokeNative.__name__, NodeInvokeNativeResponse.__name__,
CoreWWDetected.__name__, CoreIntentFailure.__name__,
CoreErrorResponse.__name__, CoreClearData.__name__,
CoreAlertExpired.__name__]
43 changes: 41 additions & 2 deletions neon_data_models/models/base/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

from datetime import datetime, timedelta, timezone
from typing import Literal, List, Optional, Any, Tuple
from typing import Annotated, Dict, Literal, List, Optional, Any, Tuple
from uuid import uuid4

from pydantic import Field, model_validator
from pydantic import BeforeValidator, Field, PlainSerializer, model_validator

from neon_data_models.enum import NodeNativeAction
from neon_data_models.models.base import BaseModel


Expand Down Expand Up @@ -155,3 +156,41 @@ def validate_inputs(cls, values):

class GradioContext(BaseModel):
session: str = Field(description="Gradio session ID")


def _drop_unknown_capabilities(value):
if isinstance(value, dict):
known_actions = {action.value for action in NodeNativeAction}
return {key: supported for key, supported in value.items()
if isinstance(key, NodeNativeAction) or key in known_actions}
return value


# Keys are constrained to `NodeNativeAction` so equivalent actions don't get
# re-defined under new names. Unrecognized keys are dropped rather than
# rejected: a Node newer than this schema may advertise actions this version
# cannot invoke anyway, and that must not invalidate the whole message.
NodeCapabilities = Annotated[
Dict[NodeNativeAction, bool],
BeforeValidator(_drop_unknown_capabilities),
PlainSerializer(lambda caps: {action.value: supported
for action, supported in caps.items()},
return_type=Dict[str, bool])]


class NodeContext(BaseModel):
"""
Identity and capability snapshot for the Node session a message originated
from. HANA populates this on every outbound bus message from a Node
session, cached from the session's `node.hello`, so skills can read
capabilities synchronously without an extra round-trip.
"""
node_id: str = Field(description="Session-scoped Node client ID")
node_name: str = Field(
default="", max_length=128,
description="User-editable Node device name")
site_id: Optional[str] = Field(
default=None, description="User-defined room/site label")
capabilities: NodeCapabilities = Field(
default={}, description="Mapping of native action to supported "
"state. Absent keys mean unsupported.")
9 changes: 7 additions & 2 deletions neon_data_models/models/base/messagebus.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@
from pydantic import ConfigDict, Field, field_validator, model_validator

from neon_data_models.models.base import BaseModel
from neon_data_models.models.base.contexts import (GradioContext,
from neon_data_models.models.base.contexts import (GradioContext,
SessionContext, KlatContext,
TimingContext, MQContext)
TimingContext, MQContext,
NodeContext)
from neon_data_models.models.client.node import NodeData
from neon_data_models.models.user.neon_profile import UserProfile

Expand All @@ -40,6 +41,10 @@ class MessageContext(BaseModel):
session: SessionContext = Field(description="Session Data",
default=SessionContext())
node_data: Optional[NodeData] = Field(description="Node Data", default=None)
node: Optional[NodeContext] = Field(
description="Identity and capabilities of the originating Node "
"session, populated hub-side from `node.hello`",
default=None)
timing: TimingContext = Field(
description="User Interaction Timing Information",
default=TimingContext())
Expand Down
Loading
Loading